reze-engine 0.56.9 → 0.56.11

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.
@@ -10,21 +10,26 @@ import { SCENE_TAP_STUB } from "./scene-tap";
10
10
  // renderer that bolted a second key light onto a toon shader has shipped. A
11
11
  // light here brightens; it does not restate the shading.
12
12
  //
13
- // So there are no per-light shadows, no area lights and no clustering. At this
14
- // count a flat loop in the fragment shader is cheaper than anything that would
15
- // avoid it, and the cap is what keeps that true.
13
+ // So there are no per-light shadows, no area lights and no clustering. The loop
14
+ // runs over the lights a scene HAS, not over the cap, so a scene with four pays
15
+ // for four; the cap only bounds the buffer and the worst case.
16
16
  //
17
17
  // LAYOUT. A 4-float header (count, then padding that keeps the records
18
- // vec4-aligned), then MAX_LIGHTS records of 8 floats:
18
+ // vec4-aligned), then MAX_LIGHTS records of 16 floats — four vec4s:
19
19
  //
20
- // [0..2] position, world space [3] radius
20
+ // [0..2] position, world space [3] radius
21
21
  // [4..6] colour PREMULTIPLIED by intensity [7] type
22
+ // [8..10] aim, unit, pointing away from the light [11] cos of the outer angle
23
+ // [12] cos of the inner angle [13..15] spare
22
24
  //
23
25
  // Colour carries intensity because nothing reads them apart: every use is the
24
26
  // product, and storing two numbers that are only ever multiplied is two numbers
25
- // that can disagree. `type` is reserved — every light is a point light today
26
- // and the loop does not branch on it, so it is honest padding rather than a
27
- // switch with one case.
27
+ // that can disagree.
28
+ //
29
+ // A POINT LIGHT IS A SPOT WITH NO CONE, and that is how the loop stays
30
+ // branchless: it stores aim (0,0,0) and both cosines at -1, which makes the
31
+ // cone term below saturate to exactly 1. `type` says which a light is for
32
+ // anyone reading the buffer; the shading never asks.
28
33
  import { castDistanceStub } from "./passes/cast-distance";
29
34
  import { audioApi } from "./audio-api";
30
35
  import { midiApi } from "./midi-api";
@@ -35,14 +40,15 @@ import { idApi } from "./id-api";
35
40
  * vec4-aligned, which is what lets a future pass read them as vec4s. */
36
41
  export const LIGHT_HEADER = 4;
37
42
  /** Floats per light — see the layout above. */
38
- export const LIGHT_STRIDE = 8;
43
+ export const LIGHT_STRIDE = 16;
39
44
  /**
40
- * The cap, and it is a real one: the loop below runs per fragment, so this is
41
- * the number that decides whether lights are free or a cost. Sixteen is the
42
- * bounded middle tier the design settled on — enough for a stage rig, far below
43
- * the point where clustering would start to pay for itself.
45
+ * The cap. The loop below runs per fragment over the lights a scene has, so
46
+ * this bounds the worst case rather than the ordinary one — and it has to clear
47
+ * a game stage: a Unity rip of one arrives with thirty-odd point lights, which
48
+ * the old ceiling of sixteen cut in half. Past this the extras are dropped.
49
+ * Clustering is what replaces it when a scene wants hundreds.
44
50
  */
45
- export const MAX_LIGHTS = 16;
51
+ export const MAX_LIGHTS = 48;
46
52
  /** Floats in the whole buffer. */
47
53
  export const LIGHTS_FLOATS = LIGHT_HEADER + MAX_LIGHTS * LIGHT_STRIDE;
48
54
  /**
@@ -199,6 +205,19 @@ fn lightEmitMain(@builtin(global_invocation_id) gid: vec3u) {
199
205
  _rzLightsOut[b + 4u] = c.x;
200
206
  _rzLightsOut[b + 5u] = c.y;
201
207
  _rzLightsOut[b + 6u] = c.z;
208
+ // An effect emits POINT lights: RzLight carries no aim, and widening it would
209
+ // fail to compile every emitter already written against it. The rest of the
210
+ // record is written anyway — these slots are reused frame to frame, and a spot
211
+ // the document placed here last frame would otherwise keep its cone.
212
+ _rzLightsOut[b + 7u] = 0.0;
213
+ _rzLightsOut[b + 8u] = 0.0;
214
+ _rzLightsOut[b + 9u] = 0.0;
215
+ _rzLightsOut[b + 10u] = 0.0;
216
+ _rzLightsOut[b + 11u] = -1.0;
217
+ _rzLightsOut[b + 12u] = -1.0;
218
+ _rzLightsOut[b + 13u] = 0.0;
219
+ _rzLightsOut[b + 14u] = 0.0;
220
+ _rzLightsOut[b + 15u] = 0.0;
202
221
  }
203
222
  `;
204
223
  }
@@ -228,6 +247,19 @@ fn rzLightColor(i: u32) -> vec3f {
228
247
  return vec3f(_rzLights[b], _rzLights[b + 1u], _rzLights[b + 2u]);
229
248
  }
230
249
 
250
+ /** Where light i points, away from itself. The zero vector for a point light. */
251
+ fn rzLightAim(i: u32) -> vec3f {
252
+ let b = ${LIGHT_HEADER}u + i * ${LIGHT_STRIDE}u + 8u;
253
+ return vec3f(_rzLights[b], _rzLights[b + 1u], _rzLights[b + 2u]);
254
+ }
255
+
256
+ /** The cosines a spot fades between: x its outer edge, y its inner one. A point
257
+ * light stores (-1, -1), which saturates the cone term to 1. */
258
+ fn rzLightCone(i: u32) -> vec2f {
259
+ let b = ${LIGHT_HEADER}u + i * ${LIGHT_STRIDE}u + 11u;
260
+ return vec2f(_rzLights[b], _rzLights[b + 1u]);
261
+ }
262
+
231
263
  /**
232
264
  * Every positional light's contribution at a surface point, as light — not as a
233
265
  * finished colour. Multiply by whatever the surface's albedo is.
@@ -257,14 +289,25 @@ fn rzLightsDiffuse(p: vec3f, n: vec3f) -> vec3f {
257
289
  for (var i = 0u; i < count; i = i + 1u) {
258
290
  let d = rzLightPos(i) - p;
259
291
  let dist = length(d);
292
+ // Out of reach before anything else is computed. The falloff is already
293
+ // exactly zero at the radius, so this changes no pixel — it is what keeps a
294
+ // stage rig's far lamps off the bill at every fragment they do not light.
295
+ if (dist >= rzLightRadius(i)) { continue; }
296
+ let toLight = d / max(dist, 1e-4);
260
297
  // Facing the light, and nothing behind it. No wrap or half-lambert: this
261
298
  // layer adds light, and a wrapped term would lift the shadow side, which is
262
299
  // the ramp's business and not this one's.
263
- let ndl = max(dot(n, d / max(dist, 1e-4)), 0.0);
300
+ let ndl = max(dot(n, toLight), 0.0);
264
301
  if (ndl <= 0.0) { continue; }
265
302
  let t = clamp(dist / max(rzLightRadius(i), 1e-4), 0.0, 1.0);
266
303
  let falloff = 1.0 - t * t;
267
- acc = acc + rzLightColor(i) * (ndl * falloff * falloff);
304
+ // How far inside the cone this point sits: 1 within the inner angle, 0 past
305
+ // the outer one, squared for the same soft edge the falloff has. A point
306
+ // light's (-1, -1) divides by the floor and clamps to 1, so it pays one
307
+ // dot product and no branch.
308
+ let cone = rzLightCone(i);
309
+ let aim = clamp((dot(-toLight, rzLightAim(i)) - cone.x) / max(cone.y - cone.x, 1e-4), 0.0, 1.0);
310
+ acc = acc + rzLightColor(i) * (ndl * falloff * falloff * aim * aim);
268
311
  }
269
312
  return acc;
270
313
  }
@@ -1 +1 @@
1
- {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/common.ts"],"names":[],"mappings":"AA2BA,eAAO,MAAM,oBAAoB,QAmIhC,CAAC;AAiIF,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAGD;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,aAAa,orMAgHzB,CAAA;AAQD,eAAO,MAAM,4BAA4B,QAC0D,CAAA"}
1
+ {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/common.ts"],"names":[],"mappings":"AA2BA,eAAO,MAAM,oBAAoB,QA4IhC,CAAC;AAiIF,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAGD;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,aAAa,orMAgHzB,CAAA;AAQD,eAAO,MAAM,4BAA4B,QAC0D,CAAA"}
@@ -28,7 +28,16 @@ struct CameraUniforms {
28
28
  view: mat4x4f,
29
29
  projection: mat4x4f,
30
30
  viewPos: vec3f,
31
- _padding: f32,
31
+ /** Render-target height in device pixels. */
32
+ viewportHeight: f32,
33
+ /** The scene clock in seconds — what the time node reads. Paused with the
34
+ * transport and stepped exactly by an export, so an animated material lands
35
+ * in the file where it stood on screen. (No backticks in here: this WGSL
36
+ * lives in a template literal and one would end it.) */
37
+ time: f32,
38
+ // NOTHING AFTER THIS. A trailing vec3f aligns to 16 and pushes the struct to
39
+ // 176 bytes against a 160-byte buffer, which fails validation on every
40
+ // pipeline at once; ending on the f32 rounds the struct to exactly 160.
32
41
  };
33
42
 
34
43
  struct Light {
@@ -19,7 +19,11 @@ export declare const CAST_FIELD_DIV = 1;
19
19
  *
20
20
  * The ground, a stage and a media plane all write ids exactly as she does, so
21
21
  * seeding "anything drawn" would grow a border around the floor: a rectangle
22
- * round the frame rather than a sticker. Only the subject ids seed.
22
+ * round the frame rather than a sticker. The subject ids seed, and so do the
23
+ * props': a sword in her hand is part of her silhouette. A prop left out is a
24
+ * HOLE in the seeds wherever it covers her, and the field grows into it from
25
+ * her side, so the border ran round the inside of the prop instead of the
26
+ * outside.
23
27
  */
24
28
  export declare function buildCastSeedShader(samples: number): string;
25
29
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"cast-distance.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/cast-distance.ts"],"names":[],"mappings":"AA0CA;4EAC4E;AAC5E,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED;;kEAEkE;AAClE,eAAO,MAAM,gBAAgB,EAAE,gBAA8B,CAAA;AAC7D;;8DAE8D;AAC9D,eAAO,MAAM,gBAAgB,EAAE,gBAA6B,CAAA;AAE5D;uDACuD;AACvD,eAAO,MAAM,oBAAoB,EAAE,gBAA4B,CAAA;AAE/D,iFAAiF;AACjF,eAAO,MAAM,cAAc,IAAI,CAAA;AAgB/B;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAyE3D;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,IAAI,MAAM,CA4B5C;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,CA4B/C;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAmC/F;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,CAIzC"}
1
+ {"version":3,"file":"cast-distance.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/cast-distance.ts"],"names":[],"mappings":"AA0CA;4EAC4E;AAC5E,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED;;kEAEkE;AAClE,eAAO,MAAM,gBAAgB,EAAE,gBAA8B,CAAA;AAC7D;;8DAE8D;AAC9D,eAAO,MAAM,gBAAgB,EAAE,gBAA6B,CAAA;AAE5D;uDACuD;AACvD,eAAO,MAAM,oBAAoB,EAAE,gBAA4B,CAAA;AAE/D,iFAAiF;AACjF,eAAO,MAAM,cAAc,IAAI,CAAA;AAgB/B;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAqF3D;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,IAAI,MAAM,CA4B5C;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,CA4B/C;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAmC/F;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,CAIzC"}
@@ -74,7 +74,11 @@ fn vs(@builtin(vertex_index) i: u32) -> VSOut {
74
74
  *
75
75
  * The ground, a stage and a media plane all write ids exactly as she does, so
76
76
  * seeding "anything drawn" would grow a border around the floor: a rectangle
77
- * round the frame rather than a sticker. Only the subject ids seed.
77
+ * round the frame rather than a sticker. The subject ids seed, and so do the
78
+ * props': a sword in her hand is part of her silhouette. A prop left out is a
79
+ * HOLE in the seeds wherever it covers her, and the field grows into it from
80
+ * her side, so the border ran round the inside of the prop instead of the
81
+ * outside.
78
82
  */
79
83
  export function buildCastSeedShader(samples) {
80
84
  return (
@@ -85,6 +89,9 @@ const RZ_ID_SAMPLES: i32 = ${samples};
85
89
 
86
90
  @group(0) @binding(0) var _rzIdTex: texture_multisampled_2d<u32>;
87
91
  @group(0) @binding(1) var<storage, read> _rzCast: array<vec4f>;
92
+ /** The visible props' object ids, count first. Props are not subjects, so the
93
+ * cast buffer does not carry them. */
94
+ @group(0) @binding(2) var<storage, read> _rzSeedProps: array<u32>;
88
95
 
89
96
  // The two cast accessors this pass needs, spelled out rather than pulled in.
90
97
  // CAST_API brings subjects, trails, anchors and their aliases with it, and a
@@ -100,6 +107,17 @@ fn rzSubjectId(i: i32) -> u32 {
100
107
  if (i < 0 || i >= rzSubjectCount()) { return 0u; }
101
108
  return u32(_rzCast[i * ${EFFECT_SUBJECT_VEC4S} + 1].w);
102
109
  }
110
+ /** Does this object seed the field: a subject, or a prop. */
111
+ fn rzSeeds(o: u32, subjects: i32) -> bool {
112
+ for (var i = 0; i < subjects; i++) {
113
+ if (o == rzSubjectId(i)) { return true; }
114
+ }
115
+ let props = min(_rzSeedProps[0], arrayLength(&_rzSeedProps) - 1u);
116
+ for (var j = 1u; j <= props; j++) {
117
+ if (o == _rzSeedProps[j]) { return true; }
118
+ }
119
+ return false;
120
+ }
103
121
 
104
122
  ${FULLSCREEN_VS}
105
123
 
@@ -133,9 +151,7 @@ fn fs(@builtin(position) pos: vec4f) -> SeedOut {
133
151
  for (var sample = 0; sample < RZ_ID_SAMPLES; sample++) {
134
152
  let o = textureLoad(_rzIdTex, p, sample).y;
135
153
  if (o == 0u) { continue; }
136
- for (var i = 0; i < n; i++) {
137
- if (o == rzSubjectId(i)) { covered += 1.0; break; }
138
- }
154
+ if (rzSeeds(o, n)) { covered += 1.0; }
139
155
  }
140
156
  var out: SeedOut;
141
157
  out.coverage = covered / f32(RZ_ID_SAMPLES);
@@ -254,7 +270,7 @@ export function castDistanceApi(group, tex, samp, scale) {
254
270
  *
255
271
  * There is no ceiling. A pixel on the far side of the frame gets the real
256
272
  * number. The ground, a stage and a media plane are not the cast and do not
257
- * seed it.
273
+ * seed it. A prop does: what she holds is inside her border.
258
274
  *
259
275
  * Screen pixels whatever the field's own resolution is, so an author writes the
260
276
  * width they mean and never has to know how this is built. uv is the effect's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.56.9",
3
+ "version": "0.56.11",
4
4
  "description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/engine.ts CHANGED
@@ -826,9 +826,10 @@ interface ModelInstance {
826
826
  /**
827
827
  * A PROP: a PMX object a character holds or wears — a microphone, a fan, a
828
828
  * sword. The third answer beside stage and plane. It keeps what a cast member
829
- * has that scenery does not (physics, outlines, its own clip) and drops what
830
- * makes one a performer: no effect subject id, no seeding of the scene clock,
831
- * no bone picking. Like a card it leaves the floor alone. See addProp.
829
+ * has that scenery does not (physics, outlines, its own clip, a place in the
830
+ * cast's silhouette for rzCastDistance) and drops what makes one a performer:
831
+ * no effect subject id, no seeding of the scene clock, no bone picking. Like a
832
+ * card it leaves the floor alone. See addProp.
832
833
  */
833
834
  isProp: boolean
834
835
  /** Who this model hangs from, or null. Any model can: a prop by design, a
@@ -1593,6 +1594,23 @@ interface EffectInstance {
1593
1594
  trails: EffectTrails | null
1594
1595
  }
1595
1596
 
1597
+ /**
1598
+ * A light the DOCUMENT places, as setLights takes it.
1599
+ *
1600
+ * `aim` is what separates the two kinds: with one the light is a spot pointing
1601
+ * that way, without one it is a point light. Angles are the whole cone in
1602
+ * degrees, the way a fixture and every DCC states them.
1603
+ */
1604
+ export type SceneLight = {
1605
+ position: XYZ
1606
+ color: XYZ
1607
+ intensity?: number
1608
+ radius?: number
1609
+ aim?: XYZ
1610
+ angle?: number
1611
+ innerAngle?: number
1612
+ }
1613
+
1596
1614
  export class Engine {
1597
1615
  private static instance: Engine | null = null
1598
1616
 
@@ -1611,7 +1629,7 @@ export class Engine {
1611
1629
  // `!` field here is genuinely absent until init() — this one no longer is.
1612
1630
  private camera: Camera
1613
1631
  private cameraUniformBuffer!: GPUBuffer
1614
- private cameraMatrixData = new Float32Array(36)
1632
+ private cameraMatrixData = new Float32Array(40)
1615
1633
  // Blender-style scene config groups (resolved from EngineOptions)
1616
1634
  private world!: { color: Vec3; strength: number }
1617
1635
  private sun!: { color: Vec3; strength: number; direction: Vec3 }
@@ -1993,6 +2011,10 @@ export class Engine {
1993
2011
  private castStepBindGroups: GPUBindGroup[] = []
1994
2012
  private castResolveBindGroup: GPUBindGroup | null = null
1995
2013
  private castStepStrideBuffers: GPUBuffer[] = []
2014
+ /** The visible props' object ids for the seed pass, count in slot 0. Grown in
2015
+ * powers of two; see writeCastSeedProps. */
2016
+ private castSeedPropBuffer: GPUBuffer | null = null
2017
+ private castSeedPropData = new Uint32Array(8)
1996
2018
  /** Does anything installed actually read the field? Set from the effect list. */
1997
2019
  private castDistanceWanted = false
1998
2020
 
@@ -3690,14 +3712,7 @@ export class Engine {
3690
3712
  const strides: number[] = []
3691
3713
  for (let k = 1 << Math.ceil(Math.log2(Math.max(w, h))); k >= 1; k >>= 1) strides.push(k)
3692
3714
 
3693
- this.castSeedBindGroup = this.device.createBindGroup({
3694
- label: "cast distance seed",
3695
- layout: this.castSeedPipeline.getBindGroupLayout(0),
3696
- entries: [
3697
- { binding: 0, resource: this.idView! },
3698
- { binding: 1, resource: { buffer: this.castBuffer } },
3699
- ],
3700
- })
3715
+ this.createCastSeedBindGroup()
3701
3716
  strides.forEach((stride, i) => {
3702
3717
  const buf = this.device!.createBuffer({
3703
3718
  label: `cast distance stride ${stride}`,
@@ -3729,6 +3744,59 @@ export class Engine {
3729
3744
  })
3730
3745
  }
3731
3746
 
3747
+ /** The seed pass's inputs: the id attachment, the cast, and the prop ids. */
3748
+ private createCastSeedBindGroup(): void {
3749
+ const device = this.device!
3750
+ if (!this.castSeedPropBuffer) {
3751
+ this.castSeedPropBuffer = device.createBuffer({
3752
+ label: "cast distance prop seeds",
3753
+ size: this.castSeedPropData.byteLength,
3754
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
3755
+ })
3756
+ device.queue.writeBuffer(this.castSeedPropBuffer, 0, this.castSeedPropData)
3757
+ }
3758
+ this.castSeedBindGroup = device.createBindGroup({
3759
+ label: "cast distance seed",
3760
+ layout: this.castSeedPipeline!.getBindGroupLayout(0),
3761
+ entries: [
3762
+ { binding: 0, resource: this.idView! },
3763
+ { binding: 1, resource: { buffer: this.castBuffer } },
3764
+ { binding: 2, resource: { buffer: this.castSeedPropBuffer } },
3765
+ ],
3766
+ })
3767
+ }
3768
+
3769
+ /**
3770
+ * Which props seed the field this frame: every visible one.
3771
+ *
3772
+ * A prop is not a subject — nothing follows it and no effect reads its bones —
3773
+ * but it is part of her silhouette. Uploaded only when the list changes.
3774
+ */
3775
+ private writeCastSeedProps(): void {
3776
+ let n = 0
3777
+ this.forEachInstance((inst) => {
3778
+ if (inst.isProp && inst.model.visible) n++
3779
+ })
3780
+ let data = this.castSeedPropData
3781
+ let changed = data[0] !== n
3782
+ if (n + 1 > data.length) {
3783
+ data = new Uint32Array(1 << Math.ceil(Math.log2(n + 1)))
3784
+ this.castSeedPropData = data
3785
+ this.castSeedPropBuffer?.destroy()
3786
+ this.castSeedPropBuffer = null
3787
+ this.createCastSeedBindGroup()
3788
+ changed = true
3789
+ }
3790
+ data[0] = n
3791
+ let i = 1
3792
+ this.forEachInstance((inst) => {
3793
+ if (!inst.isProp || !inst.model.visible) return
3794
+ if (data[i] !== inst.objectId) changed = true
3795
+ data[i++] = inst.objectId
3796
+ })
3797
+ if (changed) this.device!.queue.writeBuffer(this.castSeedPropBuffer!, 0, data)
3798
+ }
3799
+
3732
3800
  /**
3733
3801
  * The flood, encoded once a frame before the field pass reads it.
3734
3802
  *
@@ -3738,6 +3806,7 @@ export class Engine {
3738
3806
  */
3739
3807
  private encodeCastDistance(encoder: GPUCommandEncoder): void {
3740
3808
  if (!this.castDistanceWanted || !this.castSeedBindGroup || !this.castResolveBindGroup) return
3809
+ this.writeCastSeedProps()
3741
3810
  const seed = encoder.beginRenderPass({
3742
3811
  label: "cast distance (seed)",
3743
3812
  colorAttachments: [
@@ -9325,12 +9394,17 @@ export class Engine {
9325
9394
  * keep the meaning the caller gave them, which is the same rule the anchor
9326
9395
  * table follows. Passing none (or an empty list) turns the layer off and the
9327
9396
  * scene renders exactly as it did before lights existed.
9397
+ *
9398
+ * A light with an `aim` is a SPOT: it reaches only inside `angle`, full
9399
+ * inside `innerAngle`, and the two are degrees from the axis the way every
9400
+ * tool states a cone. Without one it is a point light, which the shading
9401
+ * spells as a cone that covers everything rather than as a second case.
9328
9402
  */
9329
9403
  setLights(
9330
9404
  /** Structural {x,y,z} rather than the Vec3 class, the same choice effect
9331
9405
  * params make: a scene document's JSON passes straight in, and so does a
9332
9406
  * literal typed into a console. Vec3 satisfies it either way. */
9333
- lights: { position: XYZ; color: XYZ; intensity?: number; radius?: number }[] | null,
9407
+ lights: SceneLight[] | null,
9334
9408
  ): void {
9335
9409
  const list = (lights ?? []).slice(0, MAX_LIGHTS)
9336
9410
  this.docLightCount = list.length
@@ -9357,7 +9431,25 @@ export class Engine {
9357
9431
  this.lightsData[b + 4] = Math.max(l.color.x * k, 0)
9358
9432
  this.lightsData[b + 5] = Math.max(l.color.y * k, 0)
9359
9433
  this.lightsData[b + 6] = Math.max(l.color.z * k, 0)
9360
- // [b + 7] is `type`, reserved: every light is a point light today.
9434
+ // A spot only where an aim was given AND it points somewhere: a zero
9435
+ // vector cannot be normalised, and a cone around nothing would light
9436
+ // nothing at all — a light that vanishes because its aim was left at the
9437
+ // default is worse than one that shines everywhere.
9438
+ const len = l.aim ? Math.hypot(l.aim.x, l.aim.y, l.aim.z) : 0
9439
+ this.lightsData[b + 7] = len > 0 ? 1 : 0
9440
+ this.lightsData[b + 8] = len > 0 ? l.aim!.x / len : 0
9441
+ this.lightsData[b + 9] = len > 0 ? l.aim!.y / len : 0
9442
+ this.lightsData[b + 10] = len > 0 ? l.aim!.z / len : 0
9443
+ // Half-angles in degrees, as cosines. Ordered so the outer edge is never
9444
+ // inside the inner one — a cone written the other way round would divide
9445
+ // by a negative span and light its own rim instead of its middle.
9446
+ const outer = len > 0 ? Math.cos((Math.min(Math.max(l.angle ?? 45, 0), 179) / 2) * (Math.PI / 180)) : -1
9447
+ const inner = len > 0 ? Math.cos((Math.min(Math.max(l.innerAngle ?? (l.angle ?? 45) * 0.8, 0), 179) / 2) * (Math.PI / 180)) : -1
9448
+ this.lightsData[b + 11] = outer
9449
+ this.lightsData[b + 12] = Math.max(inner, outer)
9450
+ this.lightsData[b + 13] = 0
9451
+ this.lightsData[b + 14] = 0
9452
+ this.lightsData[b + 15] = 0
9361
9453
  }
9362
9454
  if (list.length) {
9363
9455
  this.device.queue.writeBuffer(
@@ -9466,6 +9558,8 @@ export class Engine {
9466
9558
  inst.styleGroups.clear()
9467
9559
  })
9468
9560
  this.zeroStyleBuffer?.destroy()
9561
+ this.castSeedPropBuffer?.destroy()
9562
+ this.castSeedPropBuffer = null
9469
9563
  this.releaseCullBuffers()
9470
9564
  this.cullFrustaBuffer?.destroy()
9471
9565
  this.cullFrustaBuffer = null
@@ -15811,6 +15905,11 @@ export class Engine {
15811
15905
  // shader derives the full viewport (width via projection aspect) for its
15812
15906
  // babylon-mmd constant-pixel edge extrusion.
15813
15907
  this.cameraMatrixData[35] = this.canvas.height
15908
+ // The SCENE clock, which is what a material may animate on: it pauses with
15909
+ // the transport and an export steps it exactly, so a ripple is in the same
15910
+ // place in the file as it was on screen. Wall time would drift between the
15911
+ // two. The buffer was already allocated at 40 floats; this is one of them.
15912
+ this.cameraMatrixData[36] = this.sceneClock
15814
15913
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
15815
15914
 
15816
15915
  // 360 backdrop: the composite reconstructs each pixel's view ray from the
@@ -132,6 +132,16 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
132
132
  outputs: { color: "color", alpha: "float" },
133
133
  contextOutputs: { color: "tex_color", alpha: "tex_s.a" },
134
134
  },
135
+ // The scene clock, in seconds. A material is otherwise a function of position
136
+ // alone — this is what lets one SCROLL: ripples on water, a conveyor, a sign.
137
+ // It is the same clock the effects read, so a paused scene holds still and an
138
+ // export steps it frame by frame.
139
+ time: {
140
+ inputs: {},
141
+ outputs: { value: "float" },
142
+ contextOutputs: { value: "camera.time" },
143
+ },
144
+
135
145
  geometry: {
136
146
  inputs: {},
137
147
  outputs: {
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  type MaterialPreset,
15
15
  type MaterialPresetMap,
16
16
  type ModelTransform,
17
+ type SceneLight,
17
18
  type ModelAttachment,
18
19
  type GizmoDragEvent,
19
20
  type GizmoDragCallback,
package/src/reflection.ts CHANGED
@@ -156,5 +156,8 @@ export function buildMirrorCamera(
156
156
  out[33] = camera[33] - t * ny
157
157
  out[34] = camera[34] - t * nz
158
158
  out[35] = camera[35]
159
+ // The clock, unfolded: a mirrored draw of an animated material must be at the
160
+ // same instant as the one it reflects, or the ripples disagree across the pane.
161
+ out[36] = camera[36]
159
162
  return out
160
163
  }
@@ -10,21 +10,26 @@ import { SCENE_TAP_STUB } from "./scene-tap"
10
10
  // renderer that bolted a second key light onto a toon shader has shipped. A
11
11
  // light here brightens; it does not restate the shading.
12
12
  //
13
- // So there are no per-light shadows, no area lights and no clustering. At this
14
- // count a flat loop in the fragment shader is cheaper than anything that would
15
- // avoid it, and the cap is what keeps that true.
13
+ // So there are no per-light shadows, no area lights and no clustering. The loop
14
+ // runs over the lights a scene HAS, not over the cap, so a scene with four pays
15
+ // for four; the cap only bounds the buffer and the worst case.
16
16
  //
17
17
  // LAYOUT. A 4-float header (count, then padding that keeps the records
18
- // vec4-aligned), then MAX_LIGHTS records of 8 floats:
18
+ // vec4-aligned), then MAX_LIGHTS records of 16 floats — four vec4s:
19
19
  //
20
- // [0..2] position, world space [3] radius
20
+ // [0..2] position, world space [3] radius
21
21
  // [4..6] colour PREMULTIPLIED by intensity [7] type
22
+ // [8..10] aim, unit, pointing away from the light [11] cos of the outer angle
23
+ // [12] cos of the inner angle [13..15] spare
22
24
  //
23
25
  // Colour carries intensity because nothing reads them apart: every use is the
24
26
  // product, and storing two numbers that are only ever multiplied is two numbers
25
- // that can disagree. `type` is reserved — every light is a point light today
26
- // and the loop does not branch on it, so it is honest padding rather than a
27
- // switch with one case.
27
+ // that can disagree.
28
+ //
29
+ // A POINT LIGHT IS A SPOT WITH NO CONE, and that is how the loop stays
30
+ // branchless: it stores aim (0,0,0) and both cosines at -1, which makes the
31
+ // cone term below saturate to exactly 1. `type` says which a light is for
32
+ // anyone reading the buffer; the shading never asks.
28
33
 
29
34
  import { castDistanceStub } from "./passes/cast-distance"
30
35
  import { audioApi } from "./audio-api"
@@ -37,14 +42,15 @@ import { idApi } from "./id-api"
37
42
  * vec4-aligned, which is what lets a future pass read them as vec4s. */
38
43
  export const LIGHT_HEADER = 4
39
44
  /** Floats per light — see the layout above. */
40
- export const LIGHT_STRIDE = 8
45
+ export const LIGHT_STRIDE = 16
41
46
  /**
42
- * The cap, and it is a real one: the loop below runs per fragment, so this is
43
- * the number that decides whether lights are free or a cost. Sixteen is the
44
- * bounded middle tier the design settled on — enough for a stage rig, far below
45
- * the point where clustering would start to pay for itself.
47
+ * The cap. The loop below runs per fragment over the lights a scene has, so
48
+ * this bounds the worst case rather than the ordinary one — and it has to clear
49
+ * a game stage: a Unity rip of one arrives with thirty-odd point lights, which
50
+ * the old ceiling of sixteen cut in half. Past this the extras are dropped.
51
+ * Clustering is what replaces it when a scene wants hundreds.
46
52
  */
47
- export const MAX_LIGHTS = 16
53
+ export const MAX_LIGHTS = 48
48
54
  /** Floats in the whole buffer. */
49
55
  export const LIGHTS_FLOATS = LIGHT_HEADER + MAX_LIGHTS * LIGHT_STRIDE
50
56
 
@@ -210,6 +216,19 @@ fn lightEmitMain(@builtin(global_invocation_id) gid: vec3u) {
210
216
  _rzLightsOut[b + 4u] = c.x;
211
217
  _rzLightsOut[b + 5u] = c.y;
212
218
  _rzLightsOut[b + 6u] = c.z;
219
+ // An effect emits POINT lights: RzLight carries no aim, and widening it would
220
+ // fail to compile every emitter already written against it. The rest of the
221
+ // record is written anyway — these slots are reused frame to frame, and a spot
222
+ // the document placed here last frame would otherwise keep its cone.
223
+ _rzLightsOut[b + 7u] = 0.0;
224
+ _rzLightsOut[b + 8u] = 0.0;
225
+ _rzLightsOut[b + 9u] = 0.0;
226
+ _rzLightsOut[b + 10u] = 0.0;
227
+ _rzLightsOut[b + 11u] = -1.0;
228
+ _rzLightsOut[b + 12u] = -1.0;
229
+ _rzLightsOut[b + 13u] = 0.0;
230
+ _rzLightsOut[b + 14u] = 0.0;
231
+ _rzLightsOut[b + 15u] = 0.0;
213
232
  }
214
233
  `
215
234
  }
@@ -240,6 +259,19 @@ fn rzLightColor(i: u32) -> vec3f {
240
259
  return vec3f(_rzLights[b], _rzLights[b + 1u], _rzLights[b + 2u]);
241
260
  }
242
261
 
262
+ /** Where light i points, away from itself. The zero vector for a point light. */
263
+ fn rzLightAim(i: u32) -> vec3f {
264
+ let b = ${LIGHT_HEADER}u + i * ${LIGHT_STRIDE}u + 8u;
265
+ return vec3f(_rzLights[b], _rzLights[b + 1u], _rzLights[b + 2u]);
266
+ }
267
+
268
+ /** The cosines a spot fades between: x its outer edge, y its inner one. A point
269
+ * light stores (-1, -1), which saturates the cone term to 1. */
270
+ fn rzLightCone(i: u32) -> vec2f {
271
+ let b = ${LIGHT_HEADER}u + i * ${LIGHT_STRIDE}u + 11u;
272
+ return vec2f(_rzLights[b], _rzLights[b + 1u]);
273
+ }
274
+
243
275
  /**
244
276
  * Every positional light's contribution at a surface point, as light — not as a
245
277
  * finished colour. Multiply by whatever the surface's albedo is.
@@ -269,14 +301,25 @@ fn rzLightsDiffuse(p: vec3f, n: vec3f) -> vec3f {
269
301
  for (var i = 0u; i < count; i = i + 1u) {
270
302
  let d = rzLightPos(i) - p;
271
303
  let dist = length(d);
304
+ // Out of reach before anything else is computed. The falloff is already
305
+ // exactly zero at the radius, so this changes no pixel — it is what keeps a
306
+ // stage rig's far lamps off the bill at every fragment they do not light.
307
+ if (dist >= rzLightRadius(i)) { continue; }
308
+ let toLight = d / max(dist, 1e-4);
272
309
  // Facing the light, and nothing behind it. No wrap or half-lambert: this
273
310
  // layer adds light, and a wrapped term would lift the shadow side, which is
274
311
  // the ramp's business and not this one's.
275
- let ndl = max(dot(n, d / max(dist, 1e-4)), 0.0);
312
+ let ndl = max(dot(n, toLight), 0.0);
276
313
  if (ndl <= 0.0) { continue; }
277
314
  let t = clamp(dist / max(rzLightRadius(i), 1e-4), 0.0, 1.0);
278
315
  let falloff = 1.0 - t * t;
279
- acc = acc + rzLightColor(i) * (ndl * falloff * falloff);
316
+ // How far inside the cone this point sits: 1 within the inner angle, 0 past
317
+ // the outer one, squared for the same soft edge the falloff has. A point
318
+ // light's (-1, -1) divides by the floor and clamps to 1, so it pays one
319
+ // dot product and no branch.
320
+ let cone = rzLightCone(i);
321
+ let aim = clamp((dot(-toLight, rzLightAim(i)) - cone.x) / max(cone.y - cone.x, 1e-4), 0.0, 1.0);
322
+ acc = acc + rzLightColor(i) * (ndl * falloff * falloff * aim * aim);
280
323
  }
281
324
  return acc;
282
325
  }
@@ -31,7 +31,16 @@ struct CameraUniforms {
31
31
  view: mat4x4f,
32
32
  projection: mat4x4f,
33
33
  viewPos: vec3f,
34
- _padding: f32,
34
+ /** Render-target height in device pixels. */
35
+ viewportHeight: f32,
36
+ /** The scene clock in seconds — what the time node reads. Paused with the
37
+ * transport and stepped exactly by an export, so an animated material lands
38
+ * in the file where it stood on screen. (No backticks in here: this WGSL
39
+ * lives in a template literal and one would end it.) */
40
+ time: f32,
41
+ // NOTHING AFTER THIS. A trailing vec3f aligns to 16 and pushes the struct to
42
+ // 176 bytes against a 160-byte buffer, which fails validation on every
43
+ // pipeline at once; ending on the f32 rounds the struct to exactly 160.
35
44
  };
36
45
 
37
46
  struct Light {