Files
psxrecomp/tools/embed_spirv.py
Matthew Stanley c8b692eda2 feat(video): opt-in sub-pixel vertex precision + perspective-correct textures
Makes the parked projection/perspective work reachable and gets it into the
renderers people actually use. Addresses issue #92 (PGXP-style geometry and
texture correction).

The GTE side cache, SWC2 RAM provenance, GP0 triangle preparation and the
software-renderer consumption path already landed in 2ceaf5a ("park projection
and perspective correction", from kem0x PR #14), but were unreachable: the
generic setters gte_geometry_correction_set() / gpu_texture_correction_set()
had no production caller, and the corrected vertices went straight to
sw_set_precise_triangle(), bypassing the renderer vtable — while the default
renderer is OpenGL.

This adds:

- [video] geometry_correction and [video] perspective_texturing, in game.toml
  and settings.toml, both defaulting false. Independent knobs; the launcher
  seed marks them present so a save round-trips a hand-edited key instead of
  dropping it. Startup names the active pair, and notes when geometry
  correction is on below supersampling 2 (where it cannot be seen).
- gr_set_precise_triangle() / gr_set_perspective_triangle() on the
  GpuRenderBackend vtable, so gpu.c hands the correction to whichever backend
  is live rather than to the software rasterizer directly. NULL entries no-op.
- OpenGL and Vulkan consumption. Positions are already float end-to-end, so
  sub-pixel vertices drop in; the readback dirty rect widens by one pixel to
  cover them. Perspective UVs use a per-vertex a_q weight and clip coordinates
  pre-multiplied by w = 1/q, letting the hardware's own perspective divide
  interpolate a smooth UV varying beside the affine noperspective one. a_q == 0
  — the default, and what every prim gets with the feature off — makes w
  exactly 1.0 and selects the affine varying, so the faithful path is the
  pre-feature pipeline unchanged.

The PS1-visible GTE SXY FIFO stays integer either way: guest-side screen-bounds
culling and SXY readback are untouched, and this remains visual-only.

Also fixes a pre-existing concurrent-build race found while validating: every
runtime target runs embed_spirv.py over the same shaders, and each wrote
"<shader>.spv" next to the source, so under -j N one process deleted the file
another had just written (FileNotFoundError on a shader that compiled fine).
The intermediates now go to a per-invocation temp dir.

Parked-work provenance retained; see ENHANCEMENTS.md G1.

Co-authored-by: Kareem Olim <kareemolim@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:02:58 -07:00

80 lines
3.2 KiB
Python

#!/usr/bin/env python3
"""embed_spirv.py — compile GLSL shaders to SPIR-V (glslc) and emit one C header.
Each input shader (*.vert / *.frag) is compiled to SPIR-V and written into the
output header as a `static const uint32_t spv_<name>_<stage>[]` array plus its
byte length, so the Vulkan backend can create shader modules with zero runtime
file dependencies (keeps the self-contained exe). Stage is inferred from the
file extension.
Usage:
embed_spirv.py --glslc <glslc> --out <header.h> shader1.vert shader2.frag ...
"""
import argparse, os, subprocess, sys, struct, tempfile
STAGE = {".vert": "vert", ".frag": "frag", ".comp": "comp"}
def ident(path):
base = os.path.basename(path)
name, ext = os.path.splitext(base)
return "%s_%s" % (name.replace("-", "_"), STAGE[ext]), STAGE[ext]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--glslc", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("shaders", nargs="+")
a = ap.parse_args()
os.makedirs(os.path.dirname(a.out), exist_ok=True)
out = ["/* Auto-generated by embed_spirv.py — do not edit. */",
"#ifndef PSX_VK_SHADERS_SPV_H",
"#define PSX_VK_SHADERS_SPV_H",
"#include <stdint.h>", ""]
# Scratch dir for the intermediate .spv files. It MUST NOT be the shader
# source dir: every runtime target (psx-runtime, psx-oracle, each game exe)
# runs this script over the SAME shaders, and under `ninja -j N` two of them
# overlap. Writing "<shader>.spv" next to the source made them race — one
# process removed the file the other had just written, and the loser died
# with FileNotFoundError on a shader it had compiled successfully. Keeping
# the scratch per-invocation makes concurrent runs independent, and the
# TemporaryDirectory also stops a crash from leaving .spv litter in the
# source tree.
with tempfile.TemporaryDirectory(prefix="psx_spirv_") as scratch:
for sh in a.shaders:
name, stage = ident(sh)
spv = os.path.join(scratch, os.path.basename(sh) + ".spv")
cmd = [a.glslc, "--target-env=vulkan1.1", "-O", sh, "-o", spv]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
sys.stderr.write("glslc failed for %s:\n%s\n" % (sh, r.stderr))
return 1
with open(spv, "rb") as f:
data = f.read()
os.remove(spv)
if len(data) % 4 != 0:
sys.stderr.write("SPIR-V for %s not 4-byte aligned\n" % sh)
return 1
words = struct.unpack("<%dI" % (len(data) // 4), data)
out.append("static const uint32_t spv_%s[] = {" % name)
line = " "
for w in words:
tok = "0x%08x," % w
if len(line) + len(tok) > 96:
out.append(line); line = " "
line += tok
if line.strip():
out.append(line)
out.append("};")
out.append("static const uint32_t spv_%s_size = sizeof(spv_%s);" % (name, name))
out.append("")
out.append("#endif")
with open(a.out, "w") as f:
f.write("\n".join(out))
return 0
if __name__ == "__main__":
sys.exit(main())