reze-engine 0.56.10 → 0.56.12

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
 
29
34
  import { castDistanceStub } from "./passes/cast-distance"
30
35
  import { audioApi } from "./audio-api"
@@ -37,14 +42,25 @@ 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 the buffer is
49
+ * storage rather than uniform — 128 records is 8 KiB, which costs nothing to
50
+ * declare and is paid for only by the scenes that fill it.
51
+ *
52
+ * Sized against the work, not against a round number: a Unity stage arrives
53
+ * with a lamp per fixture. One rip of a resort brought thirty-three, a lit
54
+ * interior brings a hundred or more, and the ceilings of sixteen and then
55
+ * forty-eight each cut a real scene in half. Past this the extras are dropped.
56
+ *
57
+ * What it does NOT buy: a scene that genuinely lights a fragment from a hundred
58
+ * lamps pays for a hundred iterations of the loop. The distance test below
59
+ * rejects most of them in a few instructions, which is what makes a rig of this
60
+ * size affordable at all; clustering is what replaces the linear walk when a
61
+ * scene wants every one of them close enough to matter.
46
62
  */
47
- export const MAX_LIGHTS = 16
63
+ export const MAX_LIGHTS = 128
48
64
  /** Floats in the whole buffer. */
49
65
  export const LIGHTS_FLOATS = LIGHT_HEADER + MAX_LIGHTS * LIGHT_STRIDE
50
66
 
@@ -210,6 +226,19 @@ fn lightEmitMain(@builtin(global_invocation_id) gid: vec3u) {
210
226
  _rzLightsOut[b + 4u] = c.x;
211
227
  _rzLightsOut[b + 5u] = c.y;
212
228
  _rzLightsOut[b + 6u] = c.z;
229
+ // An effect emits POINT lights: RzLight carries no aim, and widening it would
230
+ // fail to compile every emitter already written against it. The rest of the
231
+ // record is written anyway — these slots are reused frame to frame, and a spot
232
+ // the document placed here last frame would otherwise keep its cone.
233
+ _rzLightsOut[b + 7u] = 0.0;
234
+ _rzLightsOut[b + 8u] = 0.0;
235
+ _rzLightsOut[b + 9u] = 0.0;
236
+ _rzLightsOut[b + 10u] = 0.0;
237
+ _rzLightsOut[b + 11u] = -1.0;
238
+ _rzLightsOut[b + 12u] = -1.0;
239
+ _rzLightsOut[b + 13u] = 0.0;
240
+ _rzLightsOut[b + 14u] = 0.0;
241
+ _rzLightsOut[b + 15u] = 0.0;
213
242
  }
214
243
  `
215
244
  }
@@ -240,6 +269,19 @@ fn rzLightColor(i: u32) -> vec3f {
240
269
  return vec3f(_rzLights[b], _rzLights[b + 1u], _rzLights[b + 2u]);
241
270
  }
242
271
 
272
+ /** Where light i points, away from itself. The zero vector for a point light. */
273
+ fn rzLightAim(i: u32) -> vec3f {
274
+ let b = ${LIGHT_HEADER}u + i * ${LIGHT_STRIDE}u + 8u;
275
+ return vec3f(_rzLights[b], _rzLights[b + 1u], _rzLights[b + 2u]);
276
+ }
277
+
278
+ /** The cosines a spot fades between: x its outer edge, y its inner one. A point
279
+ * light stores (-1, -1), which saturates the cone term to 1. */
280
+ fn rzLightCone(i: u32) -> vec2f {
281
+ let b = ${LIGHT_HEADER}u + i * ${LIGHT_STRIDE}u + 11u;
282
+ return vec2f(_rzLights[b], _rzLights[b + 1u]);
283
+ }
284
+
243
285
  /**
244
286
  * Every positional light's contribution at a surface point, as light — not as a
245
287
  * finished colour. Multiply by whatever the surface's albedo is.
@@ -269,14 +311,25 @@ fn rzLightsDiffuse(p: vec3f, n: vec3f) -> vec3f {
269
311
  for (var i = 0u; i < count; i = i + 1u) {
270
312
  let d = rzLightPos(i) - p;
271
313
  let dist = length(d);
314
+ // Out of reach before anything else is computed. The falloff is already
315
+ // exactly zero at the radius, so this changes no pixel — it is what keeps a
316
+ // stage rig's far lamps off the bill at every fragment they do not light.
317
+ if (dist >= rzLightRadius(i)) { continue; }
318
+ let toLight = d / max(dist, 1e-4);
272
319
  // Facing the light, and nothing behind it. No wrap or half-lambert: this
273
320
  // layer adds light, and a wrapped term would lift the shadow side, which is
274
321
  // the ramp's business and not this one's.
275
- let ndl = max(dot(n, d / max(dist, 1e-4)), 0.0);
322
+ let ndl = max(dot(n, toLight), 0.0);
276
323
  if (ndl <= 0.0) { continue; }
277
324
  let t = clamp(dist / max(rzLightRadius(i), 1e-4), 0.0, 1.0);
278
325
  let falloff = 1.0 - t * t;
279
- acc = acc + rzLightColor(i) * (ndl * falloff * falloff);
326
+ // How far inside the cone this point sits: 1 within the inner angle, 0 past
327
+ // the outer one, squared for the same soft edge the falloff has. A point
328
+ // light's (-1, -1) divides by the floor and clamps to 1, so it pays one
329
+ // dot product and no branch.
330
+ let cone = rzLightCone(i);
331
+ let aim = clamp((dot(-toLight, rzLightAim(i)) - cone.x) / max(cone.y - cone.x, 1e-4), 0.0, 1.0);
332
+ acc = acc + rzLightColor(i) * (ndl * falloff * falloff * aim * aim);
280
333
  }
281
334
  return acc;
282
335
  }
@@ -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 {
@@ -127,6 +136,13 @@ struct LightVP { viewProj: array<mat4x4f, ${SHADOW_CASCADES.length}>, };
127
136
  // keeps its shadows when the crisp near volume ends. Binding 7: 6 is the
128
137
  // positional lights, 9 the BRDF LUT.
129
138
  @group(0) @binding(7) var shadowMapFar: texture_depth_2d;
139
+ // The world's own sky, for a surface that REFLECTS it rather than merely being
140
+ // lit by it. Equirect, in the same layout and the same linear radiance the
141
+ // composite draws the dome from, and mipped — a rough surface reads a coarser
142
+ // level, which is what a prefiltered environment is for. A scene with no HDRI
143
+ // has the 1x1 fallback bound here; rzWorldSpecular checks the SH flag and
144
+ // answers with the flat ambient instead, so nothing samples the placeholder.
145
+ @group(0) @binding(8) var worldEnvTexture: texture_2d<f32>;
130
146
  // binding(9) brdfLut is declared inside NODES_WGSL (nodes.ts).
131
147
  @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
132
148
  @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
@@ -290,6 +290,47 @@ fn point_world_to_camera(p: vec3f) -> vec3f { return (camera.view * vec4f(p, 1.0
290
290
  // One function per slot because WGSL has no texture arrays-of-bindings here; the
291
291
  // slot is topology, exactly like a Math node's operation.
292
292
 
293
+ /**
294
+ * The world's radiance arriving from a direction — what a reflection sees.
295
+ *
296
+ * The engine's ambient is irradiance: the SH fit of the sky integrated over a
297
+ * hemisphere, evaluated along the NORMAL. That is the right answer for diffuse
298
+ * light and the wrong one for a mirror, which needs radiance along the
299
+ * REFLECTION vector and needs it sharp. A stage ripped from a game asks for
300
+ * this on nearly every surface — it is what a Unity material's DEFAULT_REFLECTION
301
+ * keyword means —
302
+ * and so does every metal the app has ever drawn.
303
+ *
304
+ * ROUGHNESS PICKS A MIP, which is the cheap prefilter: the chain is built by
305
+ * successive halving at upload, so a level is a box average of the sky over a
306
+ * widening solid angle. A cosine-lobe convolution would be more correct; the
307
+ * visible difference is confined to the middle of the roughness range, and the
308
+ * chain costs one downsample per level instead of a render pass per level.
309
+ *
310
+ * Without an HDRI there is nothing to reflect but the flat world colour, which
311
+ * is what the ambient already is — so the two agree and a scene without a sky
312
+ * texture sees no change at all.
313
+ */
314
+ fn rzWorldSpecular(dir: vec3f, roughness: f32) -> vec3f {
315
+ // A SKY TEXTURE, or the best the scene has. The flag is 2 when an HDRI is
316
+ // installed, 1 when the world is a gradient fitted to the same harmonics,
317
+ // and 0 when it is one flat colour — and in the last two cases the ambient
318
+ // already answers by direction, which is a blurred sky and exactly what a
319
+ // world without a picture can honestly reflect.
320
+ if (light.sh[0].w < 1.5) { return rzWorldAmbient(normalize(dir)); }
321
+ let d = normalize(dir);
322
+ // The composite's own convention, restated: LH world with +Z forward, which
323
+ // is what makes a reflection line up with the dome the viewer can see.
324
+ let u = 0.5 + atan2(d.x, d.z) * 0.15915494309;
325
+ let v = 0.5 - asin(clamp(d.y, -1.0, 1.0)) * 0.31830988618;
326
+ let levels = f32(textureNumLevels(worldEnvTexture) - 1u);
327
+ // sqrt, not linear: mip n covers roughly twice the angle of n-1, so a linear
328
+ // ramp spends most of its travel in the blurry end and a mirror-to-satin
329
+ // sweep happens entirely in the first tenth of the dial.
330
+ let lod = clamp(sqrt(clamp(roughness, 0.0, 1.0)) * levels, 0.0, levels);
331
+ return textureSampleLevel(worldEnvTexture, diffuseSampler, vec2f(u, v), lod).rgb * light.ambientColor.w;
332
+ }
333
+
293
334
  fn group_tex0(uv: vec2f) -> vec4f { return textureSample(groupTexture0, diffuseSampler, uv); }
294
335
  fn group_tex1(uv: vec2f) -> vec4f { return textureSample(groupTexture1, diffuseSampler, uv); }
295
336
  fn group_tex2(uv: vec2f) -> vec4f { return textureSample(groupTexture2, diffuseSampler, uv); }