reze-engine 0.54.16 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/engine.d.ts +210 -0
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +1063 -11
  4. package/dist/graph/presets/pool_floor.d.ts +3 -0
  5. package/dist/graph/presets/pool_floor.d.ts.map +1 -0
  6. package/dist/graph/presets/pool_floor.js +42 -0
  7. package/dist/graph/presets/water.d.ts +3 -0
  8. package/dist/graph/presets/water.d.ts.map +1 -0
  9. package/dist/graph/presets/water.js +31 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +8 -0
  13. package/dist/model.d.ts.map +1 -1
  14. package/dist/model.js +10 -0
  15. package/dist/overlay.d.ts +200 -0
  16. package/dist/overlay.d.ts.map +1 -0
  17. package/dist/overlay.js +806 -0
  18. package/dist/physics/autofit.d.ts +147 -0
  19. package/dist/physics/autofit.d.ts.map +1 -0
  20. package/dist/physics/autofit.js +501 -0
  21. package/dist/pmx-document.d.ts +218 -0
  22. package/dist/pmx-document.d.ts.map +1 -0
  23. package/dist/pmx-document.js +685 -0
  24. package/dist/shaders/passes/field-blit.d.ts +26 -0
  25. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  26. package/dist/shaders/passes/field-blit.js +65 -0
  27. package/dist/shaders/passes/ground-noise.d.ts +7 -0
  28. package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
  29. package/dist/shaders/passes/ground-noise.js +88 -0
  30. package/dist/shaders/passes/overlay.d.ts +3 -0
  31. package/dist/shaders/passes/overlay.d.ts.map +1 -0
  32. package/dist/shaders/passes/overlay.js +164 -0
  33. package/dist/shaders/passes/sim.d.ts +34 -0
  34. package/dist/shaders/passes/sim.d.ts.map +1 -0
  35. package/dist/shaders/passes/sim.js +169 -0
  36. package/dist/shaders/passes/wireframe.d.ts +2 -0
  37. package/dist/shaders/passes/wireframe.d.ts.map +1 -0
  38. package/dist/shaders/passes/wireframe.js +107 -0
  39. package/dist/shaders/score-api.d.ts +10 -0
  40. package/dist/shaders/score-api.d.ts.map +1 -0
  41. package/dist/shaders/score-api.js +114 -0
  42. package/package.json +1 -1
  43. package/src/engine.ts +1147 -13
  44. package/src/index.ts +50 -0
  45. package/src/model.ts +9 -0
  46. package/src/overlay.ts +1000 -0
  47. package/src/pmx-document.ts +882 -0
  48. package/src/shaders/passes/overlay.ts +166 -0
  49. package/src/shaders/passes/wireframe.ts +108 -0
@@ -0,0 +1,65 @@
1
+ import { sceneFsOutWgsl } from "./scene-contract";
2
+ /**
3
+ * The field layer, drawn INTO the scene.
4
+ *
5
+ * Field effects render into their own targets at their own resolution — that
6
+ * part was always right. What was wrong was where the result landed: the
7
+ * composite sampled it AFTER tone mapping, which is why a field effect could
8
+ * never bloom, could never light anything, and tone mapped differently from the
9
+ * particle and ribbon mounts drawing the same kind of glow.
10
+ *
11
+ * Two draws inside the scene pass — background before any geometry, foreground
12
+ * after all of it — so the bloom pyramid sees field effects and one tone map
13
+ * covers the whole frame.
14
+ *
15
+ * NO DEPTH, neither written nor tested. A foreground is handed the scene's
16
+ * depth as a parameter and does its own occlusion; testing it here would
17
+ * occlude it a second time, and a background has nothing to test against.
18
+ *
19
+ * COVERAGE IS CLAMPED HERE. Additive field effects SUM their coverage into the
20
+ * layer (see FIELD_LAYER_BLEND_ADDITIVE), which is what stops the composite's
21
+ * unpremultiply from dividing colour by nothing — but N additive effects can
22
+ * sum past 1, and the layer is rgba16float so nothing stopped it on the way in.
23
+ * A coverage above 1 would darken what the layer is drawn over by more than
24
+ * fully covering it.
25
+ */
26
+ export function fieldBlitShaderWgsl() {
27
+ return /* wgsl */ `
28
+ @group(0) @binding(0) var _rzLayer: texture_2d<f32>;
29
+ @group(0) @binding(1) var _rzLayerSamp: sampler;
30
+ // (bloom, _, fullW, fullH). The size is the SCENE target's, not the layer's:
31
+ // the layer is often half resolution, and normalised uv is what makes that
32
+ // invisible.
33
+ @group(0) @binding(2) var<uniform> _rzBlitU: vec4f;
34
+
35
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
36
+ let x = f32((vi & 1u) << 2u) - 1.0;
37
+ let y = f32((vi & 2u) << 1u) - 1.0;
38
+ return vec4f(x, y, 0.0, 1.0);
39
+ }
40
+
41
+ ${sceneFsOutWgsl()}
42
+ @fragment fn fs(@builtin(position) fragCoord: vec4f) -> FSOut {
43
+ var out: FSOut;
44
+ // Bilinear, because the layer is usually half resolution and the whole point
45
+ // of that choice is that low-frequency effects upsample invisibly.
46
+ let uv = fragCoord.xy / max(_rzBlitU.zw, vec2f(1.0));
47
+ let c = textureSampleLevel(_rzLayer, _rzLayerSamp, uv, 0.0);
48
+ let cov = clamp(c.a, 0.0, 1.0);
49
+ // PREMULTIPLIED, and blitted with a premultiplied-over blend — the field
50
+ // targets have been premultiplied since N effects had to compose into them
51
+ // associatively. Handing this to the material blend would scale rgb by alpha
52
+ // a second time and every layer would come out dark.
53
+ out.color = vec4f(max(c.rgb, vec3f(0.0)), cov);
54
+ // The aux target: bloom mask, then coverage. THIS is what finally makes
55
+ // // @bloom mean something on a field effect — the flag has been parsed and
56
+ // ignored for every mount but particles and ribbons, which is the kind of
57
+ // author-surface lie the install-time guards exist to kill.
58
+ //
59
+ // The mask is written UNWEIGHTED and the blend weights it by src.a — the
60
+ // convention the materials already follow (they write vec4f(1, 1, 0, alpha)).
61
+ out.mask = vec4f(_rzBlitU.x, 1.0, 0.0, cov);
62
+ return out;
63
+ }
64
+ `;
65
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The tile as 8-bit luminance, plus its mip chain (box-filtered, which is exact
3
+ * for a repeating texture). Mips are the point as much as the speed: procedural
4
+ * noise has no mip chain, so at grazing angles it shimmered.
5
+ */
6
+ export declare function buildGroundNoiseMips(): Uint8Array[];
7
+ //# sourceMappingURL=ground-noise.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ground-noise.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/ground-noise.ts"],"names":[],"mappings":"AAkDA;;;;GAIG;AACH,wBAAgB,oBAAoB,IAAI,UAAU,EAAE,CAsCnD"}
@@ -0,0 +1,88 @@
1
+ // Bakes the ground's frosted micro-texture into a seamless, mipmapped tile.
2
+ //
3
+ // The shader used to evaluate four octaves of value noise per pixel — sixteen
4
+ // hash rounds — and the ground covers the entire screen as soon as the camera
5
+ // tilts toward the horizon. This produces the same character of pattern once, at
6
+ // load, so the fragment shader pays one filtered fetch instead.
7
+ //
8
+ // SEAMLESS BY CONSTRUCTION. The original hash was not periodic, so a finite
9
+ // crop of it could never tile. Here every octave's lattice wraps at a whole
10
+ // number of cells across the tile, which makes the result repeat exactly at the
11
+ // edges. The pattern is therefore not pixel-identical to the analytic version —
12
+ // it is the same kind of noise, not the same noise. At the default strength of
13
+ // 0.05 it modulates brightness by ±2.5%, where the repeat is not findable by eye.
14
+ import { GROUND_NOISE_RES, GROUND_NOISE_TILE_WORLD } from "./ground";
15
+ /** Lattice cells across the tile at octave 0. The analytic version sampled fbm
16
+ * at worldXZ × 3, so a tile spanning 8 world units covers 24 base cells — the
17
+ * same spatial frequency the surface had before. */
18
+ const BASE_CELLS = GROUND_NOISE_TILE_WORLD * 3;
19
+ const OCTAVES = 4;
20
+ /** Deterministic lattice hash. Integer coordinates are wrapped by the caller,
21
+ * which is what makes the tile seamless. */
22
+ function hash(ix, iy) {
23
+ let h = Math.imul(ix, 374761393) + Math.imul(iy, 668265263);
24
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
25
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
26
+ }
27
+ /** Value noise on a lattice that repeats every `period` cells. */
28
+ function periodicValueNoise(x, y, period) {
29
+ const ix = Math.floor(x);
30
+ const iy = Math.floor(y);
31
+ const fx = x - ix;
32
+ const fy = y - iy;
33
+ // Smoothstep weights — same curve the WGSL used.
34
+ const ux = fx * fx * (3 - 2 * fx);
35
+ const uy = fy * fy * (3 - 2 * fy);
36
+ const x0 = ((ix % period) + period) % period;
37
+ const y0 = ((iy % period) + period) % period;
38
+ const x1 = (x0 + 1) % period;
39
+ const y1 = (y0 + 1) % period;
40
+ const a = hash(x0, y0);
41
+ const b = hash(x1, y0);
42
+ const c = hash(x0, y1);
43
+ const d = hash(x1, y1);
44
+ return (a + (b - a) * ux) * (1 - uy) + (c + (d - c) * ux) * uy;
45
+ }
46
+ /**
47
+ * The tile as 8-bit luminance, plus its mip chain (box-filtered, which is exact
48
+ * for a repeating texture). Mips are the point as much as the speed: procedural
49
+ * noise has no mip chain, so at grazing angles it shimmered.
50
+ */
51
+ export function buildGroundNoiseMips() {
52
+ const levels = [];
53
+ const res = GROUND_NOISE_RES;
54
+ const base = new Uint8Array(res * res);
55
+ for (let py = 0; py < res; py++) {
56
+ for (let px = 0; px < res; px++) {
57
+ // Texel centre, in units of octave-0 lattice cells.
58
+ const x = ((px + 0.5) / res) * BASE_CELLS;
59
+ const y = ((py + 0.5) / res) * BASE_CELLS;
60
+ let v = 0;
61
+ let amp = 0.5;
62
+ let freq = 1;
63
+ for (let o = 0; o < OCTAVES; o++) {
64
+ v += amp * periodicValueNoise(x * freq, y * freq, BASE_CELLS * freq);
65
+ freq *= 2;
66
+ amp *= 0.5;
67
+ }
68
+ base[py * res + px] = Math.max(0, Math.min(255, Math.round(v * 255)));
69
+ }
70
+ }
71
+ levels.push(base);
72
+ let src = base;
73
+ let size = res;
74
+ while (size > 1) {
75
+ const half = size >> 1;
76
+ const dst = new Uint8Array(half * half);
77
+ for (let y = 0; y < half; y++) {
78
+ for (let x = 0; x < half; x++) {
79
+ const s = y * 2 * size + x * 2;
80
+ dst[y * half + x] = (src[s] + src[s + 1] + src[s + size] + src[s + size + 1] + 2) >> 2;
81
+ }
82
+ }
83
+ levels.push(dst);
84
+ src = dst;
85
+ size = half;
86
+ }
87
+ return levels;
88
+ }
@@ -0,0 +1,3 @@
1
+ export declare const OVERLAY_SHADER_WGSL = "\nstruct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _pad: f32 };\n// 16 bytes exactly. Scalars rather than a vec3f tail: a vec3f aligns to 16 and\n// would push the struct past the buffer the pipeline is handed.\nstruct OverlayUniforms { viewport: vec2f, dashPeriodPx: f32, _pad0: f32 };\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> overlay: OverlayUniforms;\n\nstruct VSOut {\n @builtin(position) pos: vec4f,\n @location(0) @interpolate(flat) color: vec4f,\n}\n\nfn qrot(q: vec4f, v: vec3f) -> vec3f {\n let t = 2.0 * cross(q.xyz, v);\n return v + q.w * t + cross(q.xyz, t);\n}\n\n// Placement mode, carried per VERTEX because it belongs to the shape rather than\n// to the copy.\n//\n// 0 world \u2014 rotate, scale, translate. The shapes that ARE geometry:\n// rigidbody volumes, joint crosses, plain lines.\n// 3 facing \u2014 a flat filled shape in the camera's plane, radius scale.x.\n// The bone marker's solid centre.\n// 5 solid \u2014 placed in world like mode 0, but filled: the rigidbody\n// volumes, which read as volumes rather than as a cage of lines.\n// 4 annulus \u2014 the marker's ring: world radius scale.x, and a stroke that is\n// a PIXEL width like every other line, so the two links leave it\n// as a continuation of the same stroke rather than a step. The\n// inner and outer rims are offset radially in screen space,\n// which is what lets a small circle carry a thick stroke \u2014 the\n// stroked polyline that preceded it tore into a sunburst as soon\n// as the width passed the segment length.\n// 2 link \u2014 the taper from a bone to its child: along the bone, splayed\n// across it in whichever direction the camera can actually see,\n// so the two lines never collapse into one.\nfn placeWorld(p: vec3f, capSign: f32, iRot: vec4f, iPos: vec4f, iScale: vec4f, mode: f32) -> vec3f {\n let right = vec3f(camera.view[0].x, camera.view[1].x, camera.view[2].x);\n let up = vec3f(camera.view[0].y, camera.view[1].y, camera.view[2].y);\n\n if (mode > 2.5) {\n return iPos.xyz + (right * p.x + up * p.y) * iScale.x;\n }\n if (mode > 1.5) {\n let boneDir = normalize(qrot(iRot, vec3f(0.0, 1.0, 0.0)));\n let camFwd = vec3f(camera.view[0].z, camera.view[1].z, camera.view[2].z);\n var splay = cross(boneDir, camFwd);\n let sl = length(splay);\n // Looking straight down the bone there is no visible across, and any\n // perpendicular does \u2014 the taper is a dot on screen either way.\n splay = select(right, splay / max(sl, 1e-6), sl > 1e-4);\n return iPos.xyz + splay * (p.x * iScale.x) + boneDir * (p.y * iScale.y);\n }\n return qrot(iRot, p * iScale.xyz + vec3f(0.0, capSign * iPos.w, 0.0)) + iPos.xyz;\n}\n\n@vertex fn vs(\n @location(0) pos: vec3f,\n @location(1) dir: vec3f,\n @location(2) caps: vec2f,\n @location(3) side: f32,\n @location(4) t: f32,\n @location(5) mode: f32,\n @location(6) iRot: vec4f,\n @location(7) iPos: vec4f,\n @location(8) iScale: vec4f,\n @location(9) iColor: vec4f,\n) -> VSOut {\n let vp = camera.projection * camera.view;\n let half = 0.5 * overlay.viewport;\n\n var out: VSOut;\n out.color = iColor;\n\n // A solid volume is placed in world, like a stroked shape, but filled.\n if (mode > 4.5) {\n out.pos = vp * vec4f(placeWorld(pos, caps.x, iRot, iPos, iScale, 0.0), 1.0);\n return out;\n }\n\n // The marker's ring: a circle of world radius, stroked to a pixel width.\n if (mode > 3.5) {\n let centre = vp * vec4f(iPos.xyz, 1.0);\n let w = max(abs(centre.w), 1e-6);\n let pxPerWorld = camera.projection[1].y * half.y / w;\n // side is -1 on the inner rim and +1 on the outer.\n let radiusPx = iScale.x * pxPerWorld + side * abs(iScale.w) * 0.5;\n let radial = normalize(pos.xy);\n out.pos = vec4f(centre.xy + ((radial * radiusPx) / half) * centre.w, centre.z, centre.w);\n return out;\n }\n\n let c0 = vp * vec4f(placeWorld(pos, caps.x, iRot, iPos, iScale, mode), 1.0);\n\n // A filled vertex is already where it belongs.\n if (mode > 2.5) {\n out.pos = c0;\n return out;\n }\n\n let c1 = vp * vec4f(placeWorld(pos + dir, caps.y, iRot, iPos, iScale, mode), 1.0);\n let w0 = max(abs(c0.w), 1e-6);\n let w1 = max(abs(c1.w), 1e-6);\n let s0 = (c0.xy / w0) * half;\n let s1 = (c1.xy / w1) * half;\n let delta = s1 - s0;\n let segLenPx = max(length(delta), 1e-6);\n let tangent = delta / segLenPx;\n let normalPx = vec2f(-tangent.y, tangent.x);\n let offsetPx = normalPx * side * abs(iScale.w) * 0.5;\n out.pos = vec4f(c0.xy + (offsetPx / half) * c0.w, c0.z, c0.w);\n return out;\n}\n\n@fragment fn fs(in: VSOut) -> @location(0) vec4f {\n return vec4f(in.color.rgb * in.color.a, in.color.a);\n}\n";
2
+ export declare const OVERLAY_COMPOSITE_SHADER_WGSL = "\n@group(0) @binding(0) var overlayTex: texture_2d<f32>;\n\n@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {\n let x = f32((vi & 1u) << 2u) - 1.0;\n let y = f32((vi & 2u) << 1u) - 1.0;\n return vec4f(x, y, 0.0, 1.0);\n}\n\n@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {\n return textureLoad(overlayTex, vec2<i32>(p.xy), 0);\n}\n";
3
+ //# sourceMappingURL=overlay.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"overlay.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/overlay.ts"],"names":[],"mappings":"AA8BA,eAAO,MAAM,mBAAmB,81JAuH/B,CAAA;AAID,eAAO,MAAM,6BAA6B,kYAYzC,CAAA"}
@@ -0,0 +1,164 @@
1
+ // Editor overlays — bones, rigidbodies and joints — as one instanced pass.
2
+ //
3
+ // Two kinds of geometry, because thick lines and thick rings want different
4
+ // answers.
5
+ //
6
+ // STROKED shapes (mode 0, 2) expand each segment into a screen-space quad, so
7
+ // their width is a pixel count and holds at any zoom. That works while a segment
8
+ // is longer on screen than the stroke is wide; past that, neighbouring quads
9
+ // meet at a corner nothing mitres and a circle drawn this way turns into a
10
+ // sunburst. So the one shape that must be both small and thick — the bone
11
+ // marker — is not stroked at all.
12
+ //
13
+ // FILLED shapes (mode 3) are triangles already: the marker's ring is an annulus
14
+ // and its centre is a disc, both facing the camera. No joins exist to get wrong,
15
+ // and the ring can be as heavy as it likes.
16
+ //
17
+ // The layer is 4x multisampled and composited over the finished frame rather
18
+ // than drawn into it: the swapchain is single-sample and an MSAA attachment
19
+ // cannot load from one. So fragments here are PREMULTIPLIED — rgb already
20
+ // carries alpha — and the two blends compose correctly, once against a
21
+ // transparent layer and once onto the frame.
22
+ //
23
+ // The capsule is why `caps` exists. A PMX capsule is a radius and a cylinder
24
+ // length, so scaling a unit capsule non-uniformly would stretch its hemispheres
25
+ // into ellipsoids. Instead the unit shape carries both caps at the origin and
26
+ // each vertex names which cap it belongs to (-1, 0, +1); the VS pushes it along
27
+ // local Y by that sign times `extent`. A segment can straddle the two — the
28
+ // cylinder's side lines run from +1 to -1 — so `caps` holds the sign at BOTH
29
+ // ends, not one.
30
+ export const OVERLAY_SHADER_WGSL = /* wgsl */ `
31
+ struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _pad: f32 };
32
+ // 16 bytes exactly. Scalars rather than a vec3f tail: a vec3f aligns to 16 and
33
+ // would push the struct past the buffer the pipeline is handed.
34
+ struct OverlayUniforms { viewport: vec2f, dashPeriodPx: f32, _pad0: f32 };
35
+
36
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
37
+ @group(0) @binding(1) var<uniform> overlay: OverlayUniforms;
38
+
39
+ struct VSOut {
40
+ @builtin(position) pos: vec4f,
41
+ @location(0) @interpolate(flat) color: vec4f,
42
+ }
43
+
44
+ fn qrot(q: vec4f, v: vec3f) -> vec3f {
45
+ let t = 2.0 * cross(q.xyz, v);
46
+ return v + q.w * t + cross(q.xyz, t);
47
+ }
48
+
49
+ // Placement mode, carried per VERTEX because it belongs to the shape rather than
50
+ // to the copy.
51
+ //
52
+ // 0 world — rotate, scale, translate. The shapes that ARE geometry:
53
+ // rigidbody volumes, joint crosses, plain lines.
54
+ // 3 facing — a flat filled shape in the camera's plane, radius scale.x.
55
+ // The bone marker's solid centre.
56
+ // 5 solid — placed in world like mode 0, but filled: the rigidbody
57
+ // volumes, which read as volumes rather than as a cage of lines.
58
+ // 4 annulus — the marker's ring: world radius scale.x, and a stroke that is
59
+ // a PIXEL width like every other line, so the two links leave it
60
+ // as a continuation of the same stroke rather than a step. The
61
+ // inner and outer rims are offset radially in screen space,
62
+ // which is what lets a small circle carry a thick stroke — the
63
+ // stroked polyline that preceded it tore into a sunburst as soon
64
+ // as the width passed the segment length.
65
+ // 2 link — the taper from a bone to its child: along the bone, splayed
66
+ // across it in whichever direction the camera can actually see,
67
+ // so the two lines never collapse into one.
68
+ fn placeWorld(p: vec3f, capSign: f32, iRot: vec4f, iPos: vec4f, iScale: vec4f, mode: f32) -> vec3f {
69
+ let right = vec3f(camera.view[0].x, camera.view[1].x, camera.view[2].x);
70
+ let up = vec3f(camera.view[0].y, camera.view[1].y, camera.view[2].y);
71
+
72
+ if (mode > 2.5) {
73
+ return iPos.xyz + (right * p.x + up * p.y) * iScale.x;
74
+ }
75
+ if (mode > 1.5) {
76
+ let boneDir = normalize(qrot(iRot, vec3f(0.0, 1.0, 0.0)));
77
+ let camFwd = vec3f(camera.view[0].z, camera.view[1].z, camera.view[2].z);
78
+ var splay = cross(boneDir, camFwd);
79
+ let sl = length(splay);
80
+ // Looking straight down the bone there is no visible across, and any
81
+ // perpendicular does — the taper is a dot on screen either way.
82
+ splay = select(right, splay / max(sl, 1e-6), sl > 1e-4);
83
+ return iPos.xyz + splay * (p.x * iScale.x) + boneDir * (p.y * iScale.y);
84
+ }
85
+ return qrot(iRot, p * iScale.xyz + vec3f(0.0, capSign * iPos.w, 0.0)) + iPos.xyz;
86
+ }
87
+
88
+ @vertex fn vs(
89
+ @location(0) pos: vec3f,
90
+ @location(1) dir: vec3f,
91
+ @location(2) caps: vec2f,
92
+ @location(3) side: f32,
93
+ @location(4) t: f32,
94
+ @location(5) mode: f32,
95
+ @location(6) iRot: vec4f,
96
+ @location(7) iPos: vec4f,
97
+ @location(8) iScale: vec4f,
98
+ @location(9) iColor: vec4f,
99
+ ) -> VSOut {
100
+ let vp = camera.projection * camera.view;
101
+ let half = 0.5 * overlay.viewport;
102
+
103
+ var out: VSOut;
104
+ out.color = iColor;
105
+
106
+ // A solid volume is placed in world, like a stroked shape, but filled.
107
+ if (mode > 4.5) {
108
+ out.pos = vp * vec4f(placeWorld(pos, caps.x, iRot, iPos, iScale, 0.0), 1.0);
109
+ return out;
110
+ }
111
+
112
+ // The marker's ring: a circle of world radius, stroked to a pixel width.
113
+ if (mode > 3.5) {
114
+ let centre = vp * vec4f(iPos.xyz, 1.0);
115
+ let w = max(abs(centre.w), 1e-6);
116
+ let pxPerWorld = camera.projection[1].y * half.y / w;
117
+ // side is -1 on the inner rim and +1 on the outer.
118
+ let radiusPx = iScale.x * pxPerWorld + side * abs(iScale.w) * 0.5;
119
+ let radial = normalize(pos.xy);
120
+ out.pos = vec4f(centre.xy + ((radial * radiusPx) / half) * centre.w, centre.z, centre.w);
121
+ return out;
122
+ }
123
+
124
+ let c0 = vp * vec4f(placeWorld(pos, caps.x, iRot, iPos, iScale, mode), 1.0);
125
+
126
+ // A filled vertex is already where it belongs.
127
+ if (mode > 2.5) {
128
+ out.pos = c0;
129
+ return out;
130
+ }
131
+
132
+ let c1 = vp * vec4f(placeWorld(pos + dir, caps.y, iRot, iPos, iScale, mode), 1.0);
133
+ let w0 = max(abs(c0.w), 1e-6);
134
+ let w1 = max(abs(c1.w), 1e-6);
135
+ let s0 = (c0.xy / w0) * half;
136
+ let s1 = (c1.xy / w1) * half;
137
+ let delta = s1 - s0;
138
+ let segLenPx = max(length(delta), 1e-6);
139
+ let tangent = delta / segLenPx;
140
+ let normalPx = vec2f(-tangent.y, tangent.x);
141
+ let offsetPx = normalPx * side * abs(iScale.w) * 0.5;
142
+ out.pos = vec4f(c0.xy + (offsetPx / half) * c0.w, c0.z, c0.w);
143
+ return out;
144
+ }
145
+
146
+ @fragment fn fs(in: VSOut) -> @location(0) vec4f {
147
+ return vec4f(in.color.rgb * in.color.a, in.color.a);
148
+ }
149
+ `;
150
+ // Lays the resolved overlay layer over the finished frame. Its input is
151
+ // premultiplied, so the blend is a straight `src + dst * (1 - src.a)`.
152
+ export const OVERLAY_COMPOSITE_SHADER_WGSL = /* wgsl */ `
153
+ @group(0) @binding(0) var overlayTex: texture_2d<f32>;
154
+
155
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
156
+ let x = f32((vi & 1u) << 2u) - 1.0;
157
+ let y = f32((vi & 2u) << 1u) - 1.0;
158
+ return vec4f(x, y, 0.0, 1.0);
159
+ }
160
+
161
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
162
+ return textureLoad(overlayTex, vec2<i32>(p.xy), 0);
163
+ }
164
+ `;
@@ -0,0 +1,34 @@
1
+ import { type CastLayout } from "./particles";
2
+ export declare const SIM_MAX = 1024;
3
+ /** rgba16float, ping-ponged. Two of these is the whole memory cost. */
4
+ export declare const SIM_FORMAT: GPUTextureFormat;
5
+ /** `// @sim 256` — the grid's resolution, in texels per side. */
6
+ export declare function parseSimSize(wgsl: string, max: number): number;
7
+ /** Whether this effect drives a grid at all. */
8
+ export declare function simEntryPoint(wgsl: string): boolean;
9
+ /**
10
+ * `rzSim(uv)`, for a shader that READS the grid rather than steps it.
11
+ *
12
+ * Always compiled in, even for an effect with no grid — it then samples a 1×1
13
+ * of zeroes. An accessor that exists only sometimes is one an author has to
14
+ * guard, and a missing function is a compile error rather than a blank result.
15
+ */
16
+ export declare function simReadApi(group: number, tex: number, samp: number, size: number): string;
17
+ /** `rzTime`/`rzDt`, for a module that has no clock of its own — the field pass.
18
+ * Same reason as above: a spliced simStep refers to them. */
19
+ export declare function simClockApi(timeExpr: string): string;
20
+ /**
21
+ * The step shader: one invocation per texel, once per frame.
22
+ *
23
+ * The previous grid is bound as a SAMPLED texture and the next as a storage
24
+ * texture, which is what makes the two different resources and the whole thing
25
+ * legal — a shader cannot read and write one texture coherently, and the
26
+ * ping-pong is not an optimisation but the only correct way to do this.
27
+ *
28
+ * Sampled rather than only fetched, because advection needs to read BETWEEN
29
+ * texels: a semi-Lagrangian step asks "what was at the place this parcel came
30
+ * from", and that place is almost never a texel centre. Point-sampling it is
31
+ * what turns smoke into a staircase.
32
+ */
33
+ export declare function buildSimShader(wgsl: string, size: number, _cast: CastLayout): string;
34
+ //# sourceMappingURL=sim.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sim.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/sim.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAA;AAgC7C,eAAO,MAAM,OAAO,OAAO,CAAA;AAC3B,uEAAuE;AACvE,eAAO,MAAM,UAAU,EAAE,gBAAgC,CAAA;AAEzD,iEAAiE;AACjE,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAM9D;AAED,gDAAgD;AAChD,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CA6BzF;AAED;8DAC8D;AAC9D,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAKpD;AAWD;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,MAAM,CAwDpF"}
@@ -0,0 +1,169 @@
1
+ import { audioApi } from "../audio-api";
2
+ import { scoreApi } from "../score-api";
3
+ import { EFFECT_SCENE_API } from "./composite";
4
+ // A persistent grid an effect can step and read: the one thing an effect could
5
+ // not have before, which is MEMORY.
6
+ //
7
+ // Every other mount is a pure function of position and time. A field shader
8
+ // recomputes its noise from scratch every frame; a trail reads history the
9
+ // engine happens to keep. Neither can build on what it produced last frame, and
10
+ // a whole class of effect is nothing but that:
11
+ //
12
+ // Fog that a dancer walks through does not "look displaced near her" — it IS
13
+ // displaced, and stays displaced, and creeps back over seconds. Water does not
14
+ // look rippled where a foot landed; a ripple leaves and propagates and
15
+ // reflects off the far edge long after the foot is gone. Both are the same
16
+ // shape of thing — a grid, stepped by its own previous value.
17
+ //
18
+ // It is also the only way to get the LOOK of a fluid, not merely its motion.
19
+ // Advection compounds: each frame's field is the last one pushed sideways, and a
20
+ // few hundred frames of that stretches and folds a smooth field into filaments
21
+ // and vortex sheets finer than the grid storing them. No noise function
22
+ // produces that, at any octave count, because the structure is not a function of
23
+ // position at all — it is a function of history.
24
+ //
25
+ // The engine owns the ping-pong, the dispatch and the sampling. The author owns
26
+ // the KERNEL, which is the part that differs: advection for smoke, the wave
27
+ // equation for water, a decaying stamp for footprints. That split is the same
28
+ // one the particle mounts make, and for the same reason — the loop is identical
29
+ // in every such effect and the physics never is.
30
+ // A megatexel of rgba16float is 8MB, and two of those is the whole cost. What
31
+ // actually bounds this is the STEP: one invocation per texel per frame, each
32
+ // taking a dozen samples, so doubling the side quadruples the work.
33
+ export const SIM_MAX = 1024;
34
+ /** rgba16float, ping-ponged. Two of these is the whole memory cost. */
35
+ export const SIM_FORMAT = "rgba16float";
36
+ /** `// @sim 256` — the grid's resolution, in texels per side. */
37
+ export function parseSimSize(wgsl, max) {
38
+ const m = /^\s*\/\/\s*@sim\s+(\d+)\s*$/m.exec(wgsl);
39
+ if (!m)
40
+ return 0;
41
+ // Clamped rather than rejected, as the particle count is: an author asking for
42
+ // 4096 gets the most the engine will give and a scene that still runs.
43
+ return Math.max(8, Math.min(max, parseInt(m[1], 10)));
44
+ }
45
+ /** Whether this effect drives a grid at all. */
46
+ export function simEntryPoint(wgsl) {
47
+ return /\bfn\s+simStep\s*\(/.test(wgsl);
48
+ }
49
+ /**
50
+ * `rzSim(uv)`, for a shader that READS the grid rather than steps it.
51
+ *
52
+ * Always compiled in, even for an effect with no grid — it then samples a 1×1
53
+ * of zeroes. An accessor that exists only sometimes is one an author has to
54
+ * guard, and a missing function is a compile error rather than a blank result.
55
+ */
56
+ export function simReadApi(group, tex, samp, size) {
57
+ return /* wgsl */ `
58
+ const RZ_SIM_SIZE: f32 = ${size > 0 ? size : 1}.0;
59
+ @group(${group}) @binding(${tex}) var _rzSimTex: texture_2d<f32>;
60
+ @group(${group}) @binding(${samp}) var _rzSimSamp: sampler;
61
+
62
+ /**
63
+ * The grid, bilinearly sampled. uv is 0..1 across it, and what that MEANS in
64
+ * the world is the effect's own business — the engine deliberately does not
65
+ * impose a mapping, because a fog laid over a stage and a ripple field around a
66
+ * character want different ones and both are two lines of arithmetic.
67
+ */
68
+ fn rzSim(uv: vec2f) -> vec4f {
69
+ return textureSampleLevel(_rzSimTex, _rzSimSamp, clamp(uv, vec2f(0.0), vec2f(1.0)), 0.0);
70
+ }
71
+ fn rzSimSize() -> f32 { return RZ_SIM_SIZE; }
72
+ fn rzSimTexel() -> f32 { return 1.0 / RZ_SIM_SIZE; }
73
+
74
+ // ── The step-only half, defined here too and never called here ──
75
+ //
76
+ // One effect file is spliced into EVERY module it has a mount in, so a file
77
+ // with a grid compiles its simStep inside the field and particle shaders as
78
+ // well — where it is dead code that nothing calls, but still code that has to
79
+ // resolve. Leaving these out is a compile error on a function the author was
80
+ // right to write. rzSimPrev reads the current grid rather than a previous one,
81
+ // which is the honest answer outside the step: there is no previous frame here.
82
+ fn rzSimPrev(uv: vec2f) -> vec4f { return rzSim(uv); }
83
+ fn rzSimFrame() -> i32 { return 1; }
84
+ `;
85
+ }
86
+ /** `rzTime`/`rzDt`, for a module that has no clock of its own — the field pass.
87
+ * Same reason as above: a spliced simStep refers to them. */
88
+ export function simClockApi(timeExpr) {
89
+ return /* wgsl */ `
90
+ fn rzTime() -> f32 { return ${timeExpr}; }
91
+ fn rzDt() -> f32 { return 0.0; }
92
+ `;
93
+ }
94
+ const SIM_UNIFORMS = /* wgsl */ `
95
+ struct SimU {
96
+ time: f32,
97
+ dt: f32,
98
+ size: f32,
99
+ frame: f32,
100
+ }
101
+ `;
102
+ /**
103
+ * The step shader: one invocation per texel, once per frame.
104
+ *
105
+ * The previous grid is bound as a SAMPLED texture and the next as a storage
106
+ * texture, which is what makes the two different resources and the whole thing
107
+ * legal — a shader cannot read and write one texture coherently, and the
108
+ * ping-pong is not an optimisation but the only correct way to do this.
109
+ *
110
+ * Sampled rather than only fetched, because advection needs to read BETWEEN
111
+ * texels: a semi-Lagrangian step asks "what was at the place this parcel came
112
+ * from", and that place is almost never a texel centre. Point-sampling it is
113
+ * what turns smoke into a staircase.
114
+ */
115
+ export function buildSimShader(wgsl, size, _cast) {
116
+ return (SIM_UNIFORMS +
117
+ /* wgsl */ `
118
+ @group(0) @binding(0) var<uniform> su: SimU;
119
+ @group(0) @binding(1) var _rzSimPrevTex: texture_2d<f32>;
120
+ @group(0) @binding(2) var _rzSimSamp: sampler;
121
+ @group(0) @binding(3) var _rzSimOut: texture_storage_2d<${SIM_FORMAT}, write>;
122
+ @group(0) @binding(4) var<storage, read> _rzCast: array<vec4f>;
123
+ // The same view uniform the composite reads, so the scene API below is the real
124
+ // thing here rather than a stub — a kernel can ask where a bone is, and a
125
+ // foreground spliced into this module resolves its camera calls.
126
+ @group(0) @binding(6) var<uniform> viewU: array<vec4<f32>, 15>;
127
+
128
+ fn rzTime() -> f32 { return su.time; }
129
+ fn rzDt() -> f32 { return su.dt; }
130
+ /** Texels per side. */
131
+ fn rzSimSize() -> f32 { return su.size; }
132
+ /** One texel, in uv — the step a neighbour lookup takes. */
133
+ fn rzSimTexel() -> f32 { return 1.0 / su.size; }
134
+ /**
135
+ * Steps since this effect was installed. Zero on the very first one, which is
136
+ * the only chance to seed a grid: everything after it builds on what is there.
137
+ */
138
+ fn rzSimFrame() -> i32 { return i32(su.frame); }
139
+
140
+ /** The grid as it was LAST frame, bilinear. The only input a kernel really has. */
141
+ fn rzSimPrev(uv: vec2f) -> vec4f {
142
+ return textureSampleLevel(_rzSimPrevTex, _rzSimSamp, clamp(uv, vec2f(0.0), vec2f(1.0)), 0.0);
143
+ }
144
+ ` +
145
+ EFFECT_SCENE_API +
146
+ audioApi(0, 5) +
147
+ scoreApi(0, 7) +
148
+ // rzSim itself, so a kernel may read the grid it is writing — through the
149
+ // PREVIOUS frame's texture, which is the only version of it that exists
150
+ // while the current one is still being written.
151
+ /* wgsl */ `
152
+ fn rzSim(uv: vec2f) -> vec4f { return rzSimPrev(uv); }
153
+ ` +
154
+ "\n// ── user effect (setEffect) ──\n" +
155
+ wgsl +
156
+ /* wgsl */ `
157
+
158
+ @compute @workgroup_size(8, 8)
159
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
160
+ let n = u32(${size});
161
+ if (gid.x >= n || gid.y >= n) { return; }
162
+ let xy = vec2i(i32(gid.x), i32(gid.y));
163
+ // The texel CENTRE, so a kernel that reads its own uv back through rzSimPrev
164
+ // lands on itself rather than a quarter-texel off.
165
+ let uv = (vec2f(f32(gid.x), f32(gid.y)) + vec2f(0.5)) / su.size;
166
+ textureStore(_rzSimOut, xy, simStep(uv, textureLoad(_rzSimPrevTex, xy, 0), su.dt));
167
+ }
168
+ `);
169
+ }
@@ -0,0 +1,2 @@
1
+ export declare const WIREFRAME_SHADER_WGSL = "\nstruct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _pad: f32 };\nstruct WireUniforms { color: vec4f, viewport: vec2f, thicknessPx: f32, _pad: f32 };\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> wire: WireUniforms;\n\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n// The model's own vertex buffer: pos(3), normal(3), uv(2) \u2014 8 floats a vertex.\n@group(1) @binding(1) var<storage, read> vertices: array<f32>;\n// uint16x4 per vertex, so two u32 each.\n@group(1) @binding(2) var<storage, read> joints: array<u32>;\n// unorm8x4 per vertex, one u32 each.\n@group(1) @binding(3) var<storage, read> weights: array<u32>;\n// Pairs of vertex indices, one pair an edge.\n@group(1) @binding(4) var<storage, read> edges: array<u32>;\n\nfn skinnedPos(i: u32) -> vec3f {\n let o = i * 8u;\n let rest = vec4f(vertices[o], vertices[o + 1u], vertices[o + 2u], 1.0);\n\n let j0 = joints[i * 2u];\n let j1 = joints[i * 2u + 1u];\n let packed = weights[i];\n var w = vec4f(\n f32(packed & 0xffu),\n f32((packed >> 8u) & 0xffu),\n f32((packed >> 16u) & 0xffu),\n f32((packed >> 24u) & 0xffu),\n ) / 255.0;\n let sum = w.x + w.y + w.z + w.w;\n w = select(vec4f(1.0, 0.0, 0.0, 0.0), w / max(sum, 1e-4), sum > 0.0001);\n\n var acc = vec4f(0.0);\n acc += (skinMats[j0 & 0xffffu] * rest) * w.x;\n acc += (skinMats[j0 >> 16u] * rest) * w.y;\n acc += (skinMats[j1 & 0xffffu] * rest) * w.z;\n acc += (skinMats[j1 >> 16u] * rest) * w.w;\n return acc.xyz;\n}\n\n// Six vertices an edge: (a,-1) (a,+1) (b,-1) (a,+1) (b,+1) (b,-1).\n@vertex fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> @builtin(position) vec4f {\n let a = edges[ii * 2u];\n let b = edges[ii * 2u + 1u];\n let vp = camera.projection * camera.view;\n let ca = vp * vec4f(skinnedPos(a), 1.0);\n let cb = vp * vec4f(skinnedPos(b), 1.0);\n\n let atB = vi == 2u || vi == 4u || vi == 5u;\n let side = select(-1.0, 1.0, vi == 1u || vi == 3u || vi == 4u);\n let own = select(ca, cb, atB);\n let other = select(cb, ca, atB);\n\n let half = 0.5 * wire.viewport;\n let s0 = (own.xy / max(abs(own.w), 1e-6)) * half;\n let s1 = (other.xy / max(abs(other.w), 1e-6)) * half;\n let d = s1 - s0;\n // A degenerate edge has no direction on screen; any perpendicular will do,\n // and it is a dot either way.\n let tangent = select(vec2f(1.0, 0.0), normalize(d), length(d) > 1e-6);\n let normalPx = vec2f(-tangent.y, tangent.x);\n let offsetPx = normalPx * side * wire.thicknessPx * 0.5;\n return vec4f(own.xy + (offsetPx / half) * own.w, own.z, own.w);\n}\n\n// The depth prepass: the solid mesh through the ordinary vertex stream, writing\n// depth and nothing else, so the far side of a body is hidden behind the near.\n@vertex fn vsDepth(\n @location(0) position: vec3f,\n @location(1) joints0: vec4<u32>,\n @location(2) weights0: vec4<f32>,\n) -> @builtin(position) vec4f {\n let rest = vec4f(position, 1.0);\n let sum = weights0.x + weights0.y + weights0.z + weights0.w;\n let w = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 / max(sum, 1e-4), sum > 0.0001);\n var acc = vec4f(0.0);\n for (var i = 0u; i < 4u; i++) {\n acc += (skinMats[joints0[i]] * rest) * w[i];\n }\n return camera.projection * camera.view * vec4f(acc.xyz, 1.0);\n}\n\n@fragment fn fs() -> @location(0) vec4f {\n // Premultiplied, like every other fragment in this layer.\n return vec4f(wire.color.rgb * wire.color.a, wire.color.a);\n}\n";
2
+ //# sourceMappingURL=wireframe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wireframe.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/wireframe.ts"],"names":[],"mappings":"AAoBA,eAAO,MAAM,qBAAqB,u8GAuFjC,CAAA"}