Files
EVEngine/scripts/compile_webgpu_shaders.py
T
西风逍遥游 31880f0021 feat(examples): gate examples in CI and make repository scripts UTF-8 on every host
`examples/<name>/` was a tested surface in name only. The source-quality job had
no examples check at all, and scripts/smoke_examples.sh accepted any process that
had not printed one of its error markers: a project that died during startup,
exited after a single frame, or crashed without a marker was reported as PASS.
Nothing stopped a malformed or unregistered example from reaching dev, which is
how 14 examples arrived without READMEs and 20 were never registered in the
overview table.

Source side
- scripts/check_examples.py classifies every directory under examples/ as either
  runnable (main.nut plus a config.nut that assigns the engine `config` table) or
  as a reviewed non-runnable sample listed in NON_RUNNABLE_EXAMPLES with a
  reason. Both kinds must own a README.md and appear in examples/README.md.
  Stale exemptions and overview links to missing directories fail as well.
- `make check/examples` and a new source-quality CI step run the gate.
- scripts/tests/test_check_examples.py covers it and pins each failure mode
  against fixtures.

Runtime side
- scripts/smoke_examples.sh now enforces liveness, not just marker absence: every
  runnable example must still be running after MIN_RUN_SECONDS (default 2s; CI
  passes 2s), and a process that dies inside that window fails whatever its exit
  code. A clean self-exit after the window passes with an explicit note instead
  of being silently accepted as a pass.
- The Linux CI step sets MIN_RUN_SECONDS=2 and keeps RUN_SECONDS=6, so the job's
  wall clock is unchanged (13.7 min of its 20 min budget on dev).

Windows / GBK
- scripts/utf8_stdio.py re-encodes stdout/stderr as UTF-8 and every scripts/*.py
  program calls it as the first statement of its __main__ block. On a Chinese
  Windows a redirected stream falls back to the ANSI code page (cp936), and
  printing a character that code page cannot encode (arrows, superscripts, bullet
  separators, emoji - all common in this repository) raised UnicodeEncodeError
  and turned a checker into a traceback.
- The mirror image is input: read_text() without encoding= decoded UTF-8
  repository text as cp936 and raised UnicodeDecodeError.
- scripts/tests/test_utf8_stdio.py enforces both conventions for every script and
  test module; text file I/O and child-process decoding now pass encoding="utf-8"
  explicitly.
- Unit-test invocations run with `python3 -X utf8` so the runner's own output
  never follows the host code page.

Violations the new gates found on top of dev, fixed here: 14 examples shipped
without a README (fluid3d, mesh-impact-lab, mesh-modifier-lab, spline-tube-lab on
the earlier dev tip, plus pcg-depth-of-field, pcg-detail-overwrite,
pcg-layer-culling, pcg-location-system, pcg-runtime-orchestration,
pcg-scene-player-culling, pcg-snow-wind, pcg-thunder-strike, pcg-ui-scaler and
pcg-underwater added by #397), 20 were missing from the overview table (those 14
plus archspace-editor, geometry-stroke-lab, interior-mapping, layered-animation,
triplanar-decal, terrain-stamping), and scripts/export_meshret_onnx.py had no
UTF-8 stdio guard.

Verified locally: `python -m ruff check scripts` (All checks passed);
scripts/check_test_manifest.py; scripts/check_quality_metadata.py;
scripts/check_architecture_contracts.py --base HEAD; scripts/check_nodiscard.py;
`make -n check/examples`; scripts/check_examples.py (125 examples: 118 runnable,
7 reviewed non-runnable); and `python -X utf8 -m unittest discover -s scripts/tests
-p "test_*.py"` (155 tests, OK). The runtime half of the contract needs a Linux
headless build, so it is verified by the CI examples smoke step rather than
locally.
2026-09-15 20:20:23 +08:00

144 lines
5.0 KiB
Python

#!/usr/bin/env python3
"""Compile the EVEngine GLSL shader sources to WGSL C-arrays for the WebGPU
backend.
For every `*.vert` / `*.frag` / `*.glsl` under src/modules/graphics/shaders,
this script runs the classic GLSL -> SPIR-V toolchain (glslc, or
glslangValidator as fallback) and then converts the SPIR-V to WGSL with Tint,
emitting a `<name>_wgsl.inc` file holding a `static const uint32_t` array (UTF-8
WGSL is embedded word-aligned via uint32_t storage, mirroring the existing
`*_spv.inc` convention).
Existing files are only rewritten when the WGSL content actually changes, so the
committed `.inc` files stay stable across builds.
Usage:
python3 scripts/compile_webgpu_shaders.py --shaders-dir <dir> [--tint <path>] [--glslc <path>]
Environment overrides: EVENGINE_GLSLC, EVENGINE_TINT.
"""
import argparse
import os
import shutil
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
import utf8_stdio
STAGE_EXT = {".vert": "vertex", ".frag": "fragment", ".glsl": "fragment"}
def find_tool(name, env_name, extra_hints):
if env_name and os.environ.get(env_name):
return os.environ[env_name]
p = shutil.which(name)
if p:
return p
for hint in extra_hints:
if hint and os.path.exists(hint):
return hint
return None
def emit_wgsl_inc(src_path: Path, wgsl: bytes) -> Path:
out = src_path.with_suffix(src_path.suffix + "_wgsl.inc")
stem = src_path.stem.replace("-", "_").replace(".", "_")
array_name = f"{stem}_wgsl"
# Pack the WGSL source so the C++ side can memcpy it as an opaque blob.
padded = wgsl + b"\x00" * (-len(wgsl) % 4)
words = struct.unpack(f"<{len(padded) // 4}I", padded)
lines = [f"static const uint32_t {array_name}[] = {{"]
for i in range(0, len(words), 8):
lines.append(" " + ", ".join(f"0x{w:08x}" for w in words[i : i + 8]) + ",")
lines.append("};")
lines.append(f"static const size_t {array_name}_count = {len(words)};")
content = "\n".join(lines) + "\n"
if out.exists() and out.read_bytes() == content.encode("utf-8"):
return out
out.write_text(content, encoding="utf-8")
print(f"[webgpu-shaders] wrote {out}")
return out
def compile_one(glsl_path: Path, glslc, glslang, tint, scratch: Path):
ext = glsl_path.suffix.lower()
stage = STAGE_EXT.get(ext)
if stage is None:
return
# Fragment includes (parallax_map.glsl / tex_cell_bomb.glsl / tonemap.glsl)
# are pulled into a main shader via #include; they have no entry point and
# cannot be compiled standalone. Skip them.
try:
src = glsl_path.read_text(encoding="utf-8", errors="replace")
except OSError:
return
if "void main" not in src:
print(f"[webgpu-shaders] SKIP {glsl_path.name}: include fragment (no main)")
return
spv_path = scratch / (glsl_path.stem + ".spv")
# 1) GLSL -> SPIR-V
if glslc:
cmd = [glslc, f"-fshader-stage={stage}", "-o", str(spv_path), str(glsl_path)]
elif glslang:
cmd = [glslang, f"-S{stage}", "-V", "-o", str(spv_path), str(glsl_path)]
else:
print(f"[webgpu-shaders] SKIP {glsl_path.name}: no glslc/glslangValidator on PATH")
return
subprocess.run(cmd, check=True, capture_output=True)
# 2) SPIR-V -> WGSL
if not tint:
print(f"[webgpu-shaders] SKIP {glsl_path.name}: no tint on PATH")
return
wgsl_path = scratch / (glsl_path.stem + ".wgsl")
subprocess.run([tint, str(spv_path), "--format", "wgsl", "-o", str(wgsl_path)],
check=True, capture_output=True)
wgsl = wgsl_path.read_bytes()
emit_wgsl_inc(glsl_path, wgsl)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--shaders-dir", required=True)
ap.add_argument("--tint", default="")
ap.add_argument("--glslc", default="")
args = ap.parse_args()
shaders_dir = Path(args.shaders_dir)
if not shaders_dir.is_dir():
print(f"[webgpu-shaders] missing shaders dir {shaders_dir}")
return 1
glslc = args.glslc or find_tool("glslc", "EVENGINE_GLSLC", [])
glslang = args.glslc or find_tool("glslangValidator", "EVENGINE_GLSLANG", [])
tint = args.tint or find_tool("tint", "EVENGINE_TINT", [])
if not (glslc or glslang):
print("[webgpu-shaders] WARNING: no GLSL compiler (glslc/glslangValidator); "
"WebGPU shader .inc files will not be (re)generated.")
if not tint:
print("[webgpu-shaders] WARNING: no tint found; WGSL .inc files will not be regenerated.")
with tempfile.TemporaryDirectory(prefix="eve_wgsl_") as td:
scratch = Path(td)
for glsl_path in sorted(shaders_dir.glob("*")):
if glsl_path.suffix.lower() in STAGE_EXT:
try:
compile_one(glsl_path, glslc, glslang, tint, scratch)
except (subprocess.CalledProcessError, OSError) as e:
print(f"[webgpu-shaders] ERROR {glsl_path.name}: {e}", file=sys.stderr)
return 0
if __name__ == "__main__":
utf8_stdio.enable_utf8_stdio()
sys.exit(main())