reze-engine 0.54.16 → 0.55.1

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 (59) hide show
  1. package/dist/camera.d.ts +13 -0
  2. package/dist/camera.d.ts.map +1 -1
  3. package/dist/camera.js +53 -2
  4. package/dist/engine.d.ts +317 -0
  5. package/dist/engine.d.ts.map +1 -1
  6. package/dist/engine.js +1263 -27
  7. package/dist/graph/presets/pool_floor.d.ts +3 -0
  8. package/dist/graph/presets/pool_floor.d.ts.map +1 -0
  9. package/dist/graph/presets/pool_floor.js +42 -0
  10. package/dist/graph/presets/water.d.ts +3 -0
  11. package/dist/graph/presets/water.d.ts.map +1 -0
  12. package/dist/graph/presets/water.js +31 -0
  13. package/dist/index.d.ts +3 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +8 -0
  16. package/dist/model.d.ts.map +1 -1
  17. package/dist/model.js +10 -0
  18. package/dist/overlay.d.ts +200 -0
  19. package/dist/overlay.d.ts.map +1 -0
  20. package/dist/overlay.js +806 -0
  21. package/dist/physics/autofit.d.ts +147 -0
  22. package/dist/physics/autofit.d.ts.map +1 -0
  23. package/dist/physics/autofit.js +501 -0
  24. package/dist/pmx-document.d.ts +218 -0
  25. package/dist/pmx-document.d.ts.map +1 -0
  26. package/dist/pmx-document.js +685 -0
  27. package/dist/shaders/passes/composite.d.ts.map +1 -1
  28. package/dist/shaders/passes/composite.js +18 -1
  29. package/dist/shaders/passes/field-blit.d.ts +26 -0
  30. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  31. package/dist/shaders/passes/field-blit.js +65 -0
  32. package/dist/shaders/passes/ground-noise.d.ts +7 -0
  33. package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
  34. package/dist/shaders/passes/ground-noise.js +88 -0
  35. package/dist/shaders/passes/ground.d.ts.map +1 -1
  36. package/dist/shaders/passes/ground.js +63 -17
  37. package/dist/shaders/passes/overlay.d.ts +3 -0
  38. package/dist/shaders/passes/overlay.d.ts.map +1 -0
  39. package/dist/shaders/passes/overlay.js +164 -0
  40. package/dist/shaders/passes/sim.d.ts +34 -0
  41. package/dist/shaders/passes/sim.d.ts.map +1 -0
  42. package/dist/shaders/passes/sim.js +169 -0
  43. package/dist/shaders/passes/wireframe.d.ts +2 -0
  44. package/dist/shaders/passes/wireframe.d.ts.map +1 -0
  45. package/dist/shaders/passes/wireframe.js +107 -0
  46. package/dist/shaders/score-api.d.ts +10 -0
  47. package/dist/shaders/score-api.d.ts.map +1 -0
  48. package/dist/shaders/score-api.js +114 -0
  49. package/package.json +1 -1
  50. package/src/camera.ts +60 -2
  51. package/src/engine.ts +1359 -25
  52. package/src/index.ts +50 -0
  53. package/src/model.ts +9 -0
  54. package/src/overlay.ts +1000 -0
  55. package/src/pmx-document.ts +882 -0
  56. package/src/shaders/passes/composite.ts +18 -1
  57. package/src/shaders/passes/ground.ts +65 -17
  58. package/src/shaders/passes/overlay.ts +166 -0
  59. package/src/shaders/passes/wireframe.ts +108 -0
@@ -109,7 +109,7 @@ override APPLY_GAMMA: bool = true;
109
109
  // monotone-cubic (Fritsch–Carlson) fit through the same 14 anchors — same values, C1
110
110
  // continuity kills the banding — sampled with hardware linear filtering.
111
111
  @group(0) @binding(5) var filmicLut: texture_2d<f32>;
112
- // viewU[0] = (exposure, invGamma, _, _); viewU[1] = (tint.rgb, intensity)
112
+ // viewU[0] = (exposure, invGamma, grain amount, grain seed); viewU[1] = (tint.rgb, intensity)
113
113
  // viewU[2] = (background.rgb, mode) — display-space sRGB, composited UNDER the
114
114
  // scene post-tonemap. BASE-layer mode: 0 transparent (DOM shows),
115
115
  // 1 solid color, 2 = 360 equirect skybox sampled by view ray. A user
@@ -495,6 +495,23 @@ const COMPOSITE_BODY = /* wgsl */ `
495
495
  if (APPLY_GAMMA) {
496
496
  disp = pow(disp, vec3f(viewU[0].y));
497
497
  }
498
+ // ── Film grain, on the SCENE ONLY ─────────────────────────────────────────
499
+ //
500
+ // Applied here, before the background is composited under, so it rides on what
501
+ // the engine drew and nothing else. That placement is the whole point when the
502
+ // background is footage: the plate came off a real sensor and already carries
503
+ // its own grain, and a second helping over the top would grade the photograph
504
+ // rather than match it. A clean CG figure on a grainy plate is one of the
505
+ // loudest tells there is — the noise gives it away long before the geometry.
506
+ //
507
+ // Multiplicative and weighted toward the mid-tones, which is how film behaves:
508
+ // little grain in the blacks, and the highlights clip it off.
509
+ if (viewU[0].z > 0.0) {
510
+ let gp = fragCoord.xy + vec2f(viewU[0].w, viewU[0].w * 1.7);
511
+ let gn = fract(sin(dot(gp, vec2f(12.9898, 78.233))) * 43758.5453) - 0.5;
512
+ let glum = dot(disp, vec3f(0.2126, 0.7152, 0.0722));
513
+ disp = max(disp * (1.0 + gn * viewU[0].z * 4.0 * glum * (1.0 - glum)), vec3f(0.0));
514
+ }
498
515
  // Composite over the background in display space (premultiplied out). The
499
516
  // background is TWO layers: a base (transparent / solid color / 360 equirect)
500
517
  // and an optional user WGSL effect over-composited onto it.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The field layer, drawn INTO the scene.
3
+ *
4
+ * Field effects render into their own targets at their own resolution — that
5
+ * part was always right. What was wrong was where the result landed: the
6
+ * composite sampled it AFTER tone mapping, which is why a field effect could
7
+ * never bloom, could never light anything, and tone mapped differently from the
8
+ * particle and ribbon mounts drawing the same kind of glow.
9
+ *
10
+ * Two draws inside the scene pass — background before any geometry, foreground
11
+ * after all of it — so the bloom pyramid sees field effects and one tone map
12
+ * covers the whole frame.
13
+ *
14
+ * NO DEPTH, neither written nor tested. A foreground is handed the scene's
15
+ * depth as a parameter and does its own occlusion; testing it here would
16
+ * occlude it a second time, and a background has nothing to test against.
17
+ *
18
+ * COVERAGE IS CLAMPED HERE. Additive field effects SUM their coverage into the
19
+ * layer (see FIELD_LAYER_BLEND_ADDITIVE), which is what stops the composite's
20
+ * unpremultiply from dividing colour by nothing — but N additive effects can
21
+ * sum past 1, and the layer is rgba16float so nothing stopped it on the way in.
22
+ * A coverage above 1 would darken what the layer is drawn over by more than
23
+ * fully covering it.
24
+ */
25
+ export declare function fieldBlitShaderWgsl(): string;
26
+ //# sourceMappingURL=field-blit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"field-blit.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/field-blit.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,mBAAmB,IAAI,MAAM,CAuC5C"}
@@ -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
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"ground.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/ground.ts"],"names":[],"mappings":"AAYA,wBAAgB,gBAAgB,IAAI,MAAM,CAkUzC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,QAAS,CAAA;AACxC,eAAO,MAAM,gBAAgB,QAAS,CAAA;AAEtC;;;oEAGoE;AACpE,eAAO,MAAM,iBAAiB,OAAO,CAAA;AAErC;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,+oCAiClC,CAAA"}
1
+ {"version":3,"file":"ground.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/ground.ts"],"names":[],"mappings":"AAmEA,wBAAgB,gBAAgB,IAAI,MAAM,CA2TzC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,QAAS,CAAA;AACxC,eAAO,MAAM,gBAAgB,QAAS,CAAA;AAEtC;;;oEAGoE;AACpE,eAAO,MAAM,iBAAiB,OAAO,CAAA;AAErC;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,+oCAiClC,CAAA"}
@@ -8,6 +8,59 @@ import { WORLD_AMBIENT_WGSL } from "../lights";
8
8
  // A FUNCTION, not a constant, since the id attachment is a device capability:
9
9
  // the struct this returns depends on what the probe at init found, and a string
10
10
  // baked at import time cannot know that. Called once, when the module is built.
11
+ // How far the softest setting spreads the taps, in units of the sharp kernel's
12
+ // own step, and how many taps carry that spread. Sixteen is what keeps a wide
13
+ // disk reading as a penumbra instead of sixteen shadows.
14
+ const SOFT_TAPS = 16;
15
+ const SOFT_MAX_SPREAD = 14;
16
+ /**
17
+ * PCF taps for one cascade, emitted per shadow map.
18
+ *
19
+ * At softness 0 this is the 3x3 box the ground has always used — same offsets,
20
+ * same weights, same result to the bit. Above it the taps spread over a Vogel
21
+ * disk whose radius IS the penumbra: an overcast sky throws no edge, and a
22
+ * razor-edged shadow under one is the loudest thing wrong in a composite.
23
+ *
24
+ * The branch is on a uniform, so a scene at softness 0 genuinely takes the
25
+ * nine-tap side rather than masking the wide one — the same reasoning the
26
+ * shadowStrength branch above it already documents.
27
+ *
28
+ * A string rather than a WGSL function because a texture is the one handle type
29
+ * this shader has never passed as a parameter, and the file is already a
30
+ * template that interpolates its constants.
31
+ *
32
+ * `acc` must already be declared and zeroed; the caller reads it back
33
+ * normalised, so there is no `/ 9.0` left at the call site.
34
+ */
35
+ function pcfWgsl(map, uv, texel, z, acc, pad) {
36
+ const golden = 2.39996323;
37
+ return [
38
+ `if (material.shadowSoftness <= 0.0) {`,
39
+ ` let st = ${texel} * 2.0;`,
40
+ ` for (var y = -1; y <= 1; y++) {`,
41
+ ` for (var x = -1; x <= 1; x++) {`,
42
+ // ...Level, not the implicit-derivative form: identical on a single-mip
43
+ // shadow map, and legal inside a branch.
44
+ ` ${acc} += textureSampleCompareLevel(${map}, shadowSampler, ${uv} + vec2f(f32(x), f32(y)) * st, ${z});`,
45
+ ` }`,
46
+ ` }`,
47
+ ` ${acc} *= ${1 / 9};`,
48
+ `} else {`,
49
+ ` let radius = ${texel} * 2.0 * (1.0 + material.shadowSoftness * ${SOFT_MAX_SPREAD}.0);`,
50
+ ` for (var s = 0; s < ${SOFT_TAPS}; s++) {`,
51
+ ` let fs = f32(s);`,
52
+ // sqrt of the index spaces the ring radii evenly by AREA; the golden angle
53
+ // keeps successive taps from lining up into spokes.
54
+ ` let r = sqrt((fs + 0.5) * ${1 / SOFT_TAPS});`,
55
+ ` let a = fs * ${golden} + rot;`,
56
+ ` ${acc} += textureSampleCompareLevel(${map}, shadowSampler, ${uv} + vec2f(cos(a), sin(a)) * (r * radius), ${z});`,
57
+ ` }`,
58
+ ` ${acc} *= ${1 / SOFT_TAPS};`,
59
+ `}`,
60
+ ]
61
+ .map((l) => pad + l)
62
+ .join("\n");
63
+ }
11
64
  export function groundShaderWgsl() {
12
65
  return /* wgsl */ `
13
66
  struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
@@ -20,7 +73,7 @@ struct GroundShadowMat {
20
73
  gridLineColor: vec3f, mirror: f32,
21
74
  // farCascade: 1 while a stage is loaded, 0 otherwise. See the branch below —
22
75
  // with no stage the far map is never drawn into, so its taps are known.
23
- mirrorBlur: f32, farCascade: f32, _mb1: f32, _mb2: f32,
76
+ mirrorBlur: f32, farCascade: f32, shadowSoftness: f32, _mb2: f32,
24
77
  // Every shadow caster in one sphere, refreshed per frame. w = radius; 0 means
25
78
  // nothing casts, negative means "do not use this" (a rigid caster has no
26
79
  // sphere, so a scene with a stage keeps the taps). See rzShadowPossible.
@@ -150,6 +203,11 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
150
203
  // The same reasoning the noise tint below already got, applied to the term
151
204
  // that costs a hundred times more.
152
205
  if (material.shadowStrength > 0.0 && shadowPossible) {
206
+ // Per-pixel rotation for the soft disk, so its rings break up into fine noise
207
+ // rather than banding. Interleaved gradient noise: a function of the pixel
208
+ // alone, so a still camera gives a still shadow — the sharp path ignores it.
209
+ let ign = fract(52.9829189 * fract(dot(i.position.xy, vec2f(0.06711056, 0.00583715))));
210
+ let rot = ign * 6.28318530718;
153
211
  // The far cascade's taps, skipped entirely when nothing ever drew into it.
154
212
  //
155
213
  // This branch is the expensive one on a wide floor: it runs wherever the NEAR
@@ -163,32 +221,20 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
163
221
  if (material.farCascade > 0.0 && frustum < 1.0 && frustum1 > 0.0) {
164
222
  let suv1 = vec2f(ndc1.x * 0.5 + 0.5, 0.5 - ndc1.y * 0.5);
165
223
  let suv1_c = clamp(suv1, vec2f(0.02), vec2f(0.98));
166
- let st1 = ${1 / SHADOW_CASCADES[SHADOW_CASCADES.length - 1].mapSize} * 2.0;
167
224
  let compareZ1 = ndc1.z - 0.0035;
168
225
  var acc1 = 0.0;
169
- for (var y = -1; y <= 1; y++) {
170
- for (var x = -1; x <= 1; x++) {
171
- acc1 += textureSampleCompareLevel(shadowMapFar, shadowSampler, suv1_c + vec2f(f32(x), f32(y)) * st1, compareZ1);
172
- }
173
- }
174
- vis = mix(1.0, acc1 * (1.0 / 9.0), frustum1);
226
+ ${pcfWgsl("shadowMapFar", "suv1_c", `${1 / SHADOW_CASCADES[SHADOW_CASCADES.length - 1].mapSize}`, "compareZ1", "acc1", " ")}
227
+ vis = mix(1.0, acc1, frustum1);
175
228
  }
176
229
  if (frustum > 0.0) {
177
230
  let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
178
231
  let suv_c = clamp(suv, vec2f(0.02), vec2f(0.98));
179
- let st = material.pcfTexel * 2.0;
180
232
  let compareZ = ndc.z - 0.0035;
181
233
  var acc = 0.0;
182
- for (var y = -1; y <= 1; y++) {
183
- for (var x = -1; x <= 1; x++) {
184
- // ...Level, not the implicit-derivative form: identical on a single-mip
185
- // shadow map, and legal inside this branch.
186
- acc += textureSampleCompareLevel(shadowMap, shadowSampler, suv_c + vec2f(f32(x), f32(y)) * st, compareZ);
187
- }
188
- }
234
+ ${pcfWgsl("shadowMap", "suv_c", "material.pcfTexel", "compareZ", "acc", " ")}
189
235
  // The base is whatever the far cascade decided, so the near border blends
190
236
  // cascade to cascade rather than snapping to lit mid-floor.
191
- vis = mix(vis, acc * (1.0 / 9.0), frustum);
237
+ vis = mix(vis, acc, frustum);
192
238
  }
193
239
  }
194
240
 
@@ -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"}