reze-engine 0.58.2 → 0.58.4

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.
package/dist/engine.js CHANGED
@@ -42,7 +42,7 @@ import { boneOverlay, boneMarkerPositions, buildOverlayShapes, jointOverlay, lin
42
42
  import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
43
43
  import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut";
44
44
  import { buildCompositeShader, EFFECT_SCENE_API, buildFieldShader, EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES, } from "./shaders/passes/composite";
45
- import { buildParticleComputeShader, buildParticleRenderShader, PARTICLE_LIGHT_BINDING, PARTICLE_POINTS_BINDING, particleEntryPoints, PARTICLE_STRIDE, } from "./shaders/passes/particles";
45
+ import { buildParticleComputeShader, buildParticleRenderShader, PARTICLE_LIGHT_BINDING, PARTICLE_POINTS_BINDING, particleEntryPoints, PARTICLE_INDIRECT_BINDING, PARTICLE_INDIRECT_BYTES, PARTICLE_INDIRECT_DRAW_OFFSET, PARTICLE_STRIDE, } from "./shaders/passes/particles";
46
46
  import { MAX_EFFECT_POINTS, POINTS_FLOATS } from "./shaders/points-api";
47
47
  import { bonesWithPrefix, pointsData, writeBonePoint } from "./effect-points";
48
48
  import { SIM_FORMAT, GRID_MAX, buildSimShader, gridEntryPoint, } from "./shaders/passes/grid";
@@ -1163,7 +1163,9 @@ export class Engine {
1163
1163
  // GPU texture can't be read back cheaply, and PMX diffuse alpha is usually 1.0
1164
1164
  // even for see-through cloth: the translucency lives in the texture).
1165
1165
  this.textureAlphaCache = new Map();
1166
- this.mipBlitPipeline = null;
1166
+ /** One per format: a render pipeline is bound to its target's format, and
1167
+ * material textures are sRGB while a group's data maps are not. */
1168
+ this.mipBlitPipelines = new Map();
1167
1169
  this.mipBlitSampler = null;
1168
1170
  this._nextDefaultModelId = 0;
1169
1171
  // IK and physics enabled at engine level (same for all models)
@@ -3056,6 +3058,8 @@ export class Engine {
3056
3058
  e.particles.computeBinds = b.computeBinds;
3057
3059
  e.particles.renderBinds = b.renderBinds;
3058
3060
  e.particles.mirrorRenderBinds = b.mirrorRenderBinds;
3061
+ if (e.particles.live && b.countBinds)
3062
+ e.particles.live.binds = b.countBinds;
3059
3063
  }
3060
3064
  if (e.grid)
3061
3065
  e.grid.binds = [this.gridBindGroup(e.grid, 0), this.gridBindGroup(e.grid, 1)];
@@ -3581,6 +3585,7 @@ export class Engine {
3581
3585
  particles?.buffer.destroy();
3582
3586
  particles?.uniform.destroy();
3583
3587
  particles?.points?.buffer.destroy();
3588
+ particles?.live?.indirect.destroy();
3584
3589
  grid?.textures[0].destroy();
3585
3590
  grid?.textures[1].destroy();
3586
3591
  grid?.uniform.destroy();
@@ -3934,6 +3939,7 @@ export class Engine {
3934
3939
  paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING),
3935
3940
  gridSize: grid?.size ?? 0,
3936
3941
  cover: particleEntryPoints(wgsl).cover,
3942
+ live: particleEntryPoints(wgsl).count,
3937
3943
  };
3938
3944
  const prepass = src.blend === "cutout" && src.cover;
3939
3945
  const points = d.points
@@ -3997,13 +4003,23 @@ export class Engine {
3997
4003
  });
3998
4004
  const uniformBytes = new ArrayBuffer(32);
3999
4005
  const uniformView = { floats: new Float32Array(uniformBytes), uints: new Uint32Array(uniformBytes) };
4006
+ // The live count's arguments — written by rzCount, read by the step's
4007
+ // dispatch and the quads' draws. Zero until the first step, so a pool
4008
+ // drawn before it has stepped draws nothing rather than everything.
4009
+ const indirect = src.live
4010
+ ? this.device.createBuffer({
4011
+ label: "particle indirect",
4012
+ size: PARTICLE_INDIRECT_BYTES,
4013
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT,
4014
+ })
4015
+ : null;
4000
4016
  // Visibility is per LAYOUT, not shared: a read_write storage buffer may not be
4001
4017
  // visible to the vertex stage at all (WebGPU forbids it — a vertex shader
4002
4018
  // that could write memory has no defined ordering against the rasteriser).
4003
4019
  // Declaring one set of flags for both layouts is what made the pipeline
4004
4020
  // layout invalid, and the error surfaces later and unhelpfully as "invalid
4005
4021
  // due to a previous error".
4006
- const layoutFor = (storage, visibility, shadow) => this.device.createBindGroupLayout({
4022
+ const layoutFor = (storage, visibility, shadow, withIndirect) => this.device.createBindGroupLayout({
4007
4023
  entries: [
4008
4024
  { binding: 0, visibility, buffer: { type: storage } },
4009
4025
  { binding: 1, visibility, buffer: { type: "uniform" } },
@@ -4037,13 +4053,21 @@ export class Engine {
4037
4053
  { binding: PARTICLE_LIGHT_BINDING + 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
4038
4054
  ]
4039
4055
  : []),
4056
+ // The live count's arguments, for the COUNT kernel's layout alone. The
4057
+ // step's layout must not carry them: a bound group's entries count
4058
+ // toward a pass's usage whether the shader reads them or not, and a
4059
+ // buffer may not be writable storage and indirect arguments in one
4060
+ // pass.
4061
+ ...(withIndirect
4062
+ ? [{ binding: PARTICLE_INDIRECT_BINDING, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } }]
4063
+ : []),
4040
4064
  ],
4041
4065
  });
4042
4066
  /** One per GRID PARITY, for the reason rebuildFieldBindGroup gives: the grid
4043
4067
  * alternates which texture is current, and rebuilding a single group every
4044
4068
  * frame is waste for a change that only ever toggles between two known
4045
4069
  * states. Both entries are the same view when there is no grid. */
4046
- const bindFor = (layout, camera, shadow) => [0, 1].map((parity) => this.device.createBindGroup({
4070
+ const bindFor = (layout, camera, shadow, withIndirect) => [0, 1].map((parity) => this.device.createBindGroup({
4047
4071
  layout,
4048
4072
  entries: [
4049
4073
  { binding: 0, resource: { buffer } },
@@ -4066,10 +4090,12 @@ export class Engine {
4066
4090
  { binding: PARTICLE_LIGHT_BINDING + 4, resource: this.shadowComparisonSampler },
4067
4091
  ]
4068
4092
  : []),
4093
+ ...(withIndirect && indirect ? [{ binding: PARTICLE_INDIRECT_BINDING, resource: { buffer: indirect } }] : []),
4069
4094
  ],
4070
4095
  }));
4071
- const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE, false);
4072
- const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, true);
4096
+ const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE, false, false);
4097
+ const countLayout = indirect ? layoutFor("storage", GPUShaderStage.COMPUTE, false, true) : null;
4098
+ const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, true, false);
4073
4099
  // Additive keeps the destination and adds to it, and leaves alpha alone, so
4074
4100
  // a glow does not claim coverage it never occluded. The MASK sums with it —
4075
4101
  // otherwise an additive effect could never reach the bloom gate. Both live
@@ -4082,11 +4108,23 @@ export class Engine {
4082
4108
  const cutout = src.blend === "cutout";
4083
4109
  this.device.pushErrorScope("validation");
4084
4110
  try {
4111
+ const computePipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] });
4085
4112
  const compute = await this.device.createComputePipelineAsync({
4086
4113
  label: "particle compute pipeline",
4087
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
4114
+ layout: computePipelineLayout,
4088
4115
  compute: { module: computeModule, entryPoint: "main" },
4089
4116
  });
4117
+ const live = indirect && countLayout
4118
+ ? {
4119
+ pipeline: await this.device.createComputePipelineAsync({
4120
+ label: "particle count pipeline",
4121
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [countLayout] }),
4122
+ compute: { module: computeModule, entryPoint: "rzCount" },
4123
+ }),
4124
+ indirect,
4125
+ binds: bindFor(countLayout, this.cameraUniformBuffer, false, true),
4126
+ }
4127
+ : null;
4090
4128
  const renderLayoutObj = this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] });
4091
4129
  // The prepass, when the effect gave the cutout a cheap shape: coverage to
4092
4130
  // depth, colour targets at writeMask 0 (the depth-prepass class), and
@@ -4129,6 +4167,7 @@ export class Engine {
4129
4167
  buffer.destroy();
4130
4168
  uniform.destroy();
4131
4169
  points?.buffer.destroy();
4170
+ indirect?.destroy();
4132
4171
  return { ok: false, diagnostics: [scoped.message] };
4133
4172
  }
4134
4173
  return {
@@ -4144,18 +4183,20 @@ export class Engine {
4144
4183
  counts: uniformView.uints,
4145
4184
  compute,
4146
4185
  computeLayout,
4147
- computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
4186
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false, false),
4148
4187
  depth,
4149
4188
  render,
4150
4189
  renderLayout,
4151
- renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
4152
- mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
4190
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true, false),
4191
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true, false),
4153
4192
  rebind: () => ({
4154
- computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
4155
- renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
4156
- mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
4193
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false, false),
4194
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true, false),
4195
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true, false),
4196
+ countBinds: countLayout ? bindFor(countLayout, this.cameraUniformBuffer, false, true) : null,
4157
4197
  }),
4158
4198
  points,
4199
+ live,
4159
4200
  },
4160
4201
  };
4161
4202
  }
@@ -4164,6 +4205,7 @@ export class Engine {
4164
4205
  buffer.destroy();
4165
4206
  uniform.destroy();
4166
4207
  points?.buffer.destroy();
4208
+ indirect?.destroy();
4167
4209
  return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] };
4168
4210
  }
4169
4211
  }
@@ -4349,10 +4391,24 @@ export class Engine {
4349
4391
  this.device.queue.writeBuffer(p.uniform, 0, p.data.buffer);
4350
4392
  if (p.points)
4351
4393
  this.writePoints(p.points);
4394
+ const bind = p.computeBinds[e.grid?.parity ?? 0];
4395
+ if (p.live) {
4396
+ // The frame's live count first, in a pass of its own: a buffer may
4397
+ // not be written as storage and read as indirect arguments inside one
4398
+ // pass, and a pass is the synchronization scope.
4399
+ const count = encoder.beginComputePass({ label: "particle count" });
4400
+ count.setPipeline(p.live.pipeline);
4401
+ count.setBindGroup(0, p.live.binds[e.grid?.parity ?? 0]);
4402
+ count.dispatchWorkgroups(1);
4403
+ count.end();
4404
+ }
4352
4405
  const cp = encoder.beginComputePass({ label: "particles" });
4353
4406
  cp.setPipeline(p.compute);
4354
- cp.setBindGroup(0, p.computeBinds[e.grid?.parity ?? 0]);
4355
- cp.dispatchWorkgroups(Math.ceil(p.count / 64));
4407
+ cp.setBindGroup(0, bind);
4408
+ if (p.live)
4409
+ cp.dispatchWorkgroupsIndirect(p.live.indirect, 0);
4410
+ else
4411
+ cp.dispatchWorkgroups(Math.ceil(p.count / 64));
4356
4412
  cp.end();
4357
4413
  }
4358
4414
  }
@@ -4397,14 +4453,21 @@ export class Engine {
4397
4453
  continue;
4398
4454
  const parity = e.grid?.parity ?? 0;
4399
4455
  const bind = view === "mirror" ? p.mirrorRenderBinds[parity] : p.renderBinds[parity];
4456
+ // The live count's quads when the effect declared one, else the pool's.
4457
+ const draw = () => {
4458
+ if (p.live)
4459
+ pass.drawIndirect(p.live.indirect, PARTICLE_INDIRECT_DRAW_OFFSET);
4460
+ else
4461
+ pass.draw(6, p.count);
4462
+ };
4400
4463
  if (p.depth) {
4401
4464
  pass.setPipeline(p.depth);
4402
4465
  pass.setBindGroup(0, bind);
4403
- pass.draw(6, p.count);
4466
+ draw();
4404
4467
  }
4405
4468
  pass.setPipeline(p.render);
4406
4469
  pass.setBindGroup(0, bind);
4407
- pass.draw(6, p.count);
4470
+ draw();
4408
4471
  }
4409
4472
  }
4410
4473
  /**
@@ -4771,6 +4834,7 @@ export class Engine {
4771
4834
  e.particles?.buffer.destroy();
4772
4835
  e.particles?.uniform.destroy();
4773
4836
  e.particles?.points?.buffer.destroy();
4837
+ e.particles?.live?.indirect.destroy();
4774
4838
  e.particles = null;
4775
4839
  }
4776
4840
  }
@@ -5816,6 +5880,11 @@ export class Engine {
5816
5880
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
5817
5881
  });
5818
5882
  this.device.queue.writeTexture({ texture: this.fallbackMaterialTexture }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
5883
+ this.noFillBuffer = this.device.createBuffer({
5884
+ label: "model fill (none)",
5885
+ size: 16,
5886
+ usage: GPUBufferUsage.UNIFORM,
5887
+ });
5819
5888
  // Generic shared-toon ramp: lit white down to a soft cool shadow tone with
5820
5889
  // a tight terminator around the midpoint, approximating MMD's toon ramps.
5821
5890
  const TOON_H = 64;
@@ -5922,6 +5991,8 @@ export class Engine {
5922
5991
  visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
5923
5992
  buffer: { type: "read-only-storage" },
5924
5993
  },
5994
+ // The model's own fill — see setModelFill.
5995
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
5925
5996
  ],
5926
5997
  });
5927
5998
  // group 2: per-material (textures + material uniforms) — bound per draw call.
@@ -8927,6 +8998,54 @@ export class Engine {
8927
8998
  inst.model.setName(newName);
8928
8999
  this.modelInstances.set(newName, inst);
8929
9000
  }
9001
+ /**
9002
+ * Light one model with more than the world gives it: a FILL, linear RGB added
9003
+ * to its ambient everywhere on it. Null takes it away again.
9004
+ *
9005
+ * A game lights its stage and its characters apart. Aether Gazer's rooms are
9006
+ * lit by their lamps and a near-black ambient, while its characters take a
9007
+ * flat base light of their own — so a stage whose World is right for the room
9008
+ * leaves a face turned from the lamps in the dark. This is that second light,
9009
+ * for the models the host counts as cast.
9010
+ */
9011
+ setModelFill(name, fill) {
9012
+ const inst = this.modelInstances.get(name);
9013
+ if (!inst || !this.device)
9014
+ return false;
9015
+ if (!fill) {
9016
+ if (!inst.fillBuffer)
9017
+ return true;
9018
+ const retired = inst.fillBuffer;
9019
+ inst.fillBuffer = null;
9020
+ inst.mainPerInstanceBindGroup = this.perInstanceBindGroup(name, inst.skinMatrixBuffer, null);
9021
+ this.bundlesDirty = true;
9022
+ // Retired once the GPU is done with it: an encoded frame may still name it.
9023
+ void this.device.queue.onSubmittedWorkDone().then(() => retired.destroy());
9024
+ return true;
9025
+ }
9026
+ if (!inst.fillBuffer) {
9027
+ inst.fillBuffer = this.device.createBuffer({
9028
+ label: `${name}: fill`,
9029
+ size: 16,
9030
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
9031
+ });
9032
+ inst.mainPerInstanceBindGroup = this.perInstanceBindGroup(name, inst.skinMatrixBuffer, inst.fillBuffer);
9033
+ this.bundlesDirty = true;
9034
+ }
9035
+ this.device.queue.writeBuffer(inst.fillBuffer, 0, new Float32Array([fill.x, fill.y, fill.z, 0]));
9036
+ return true;
9037
+ }
9038
+ /** The per-model group: its skinning matrices and its fill, or the zero stand-in. */
9039
+ perInstanceBindGroup(name, skinMatrixBuffer, fill) {
9040
+ return this.device.createBindGroup({
9041
+ label: `${name}: main per-instance bind group`,
9042
+ layout: this.mainPerInstanceBindGroupLayout,
9043
+ entries: [
9044
+ { binding: 0, resource: { buffer: skinMatrixBuffer } },
9045
+ { binding: 1, resource: { buffer: fill ?? this.noFillBuffer } },
9046
+ ],
9047
+ });
9048
+ }
8930
9049
  removeModel(name) {
8931
9050
  const inst = this.modelInstances.get(name);
8932
9051
  if (!inst)
@@ -8958,6 +9077,7 @@ export class Engine {
8958
9077
  for (const buf of inst.gpuBuffers) {
8959
9078
  buf.destroy();
8960
9079
  }
9080
+ inst.fillBuffer?.destroy();
8961
9081
  // Per-group StyleUniforms buffers aren't in gpuBuffers (allocated post-load).
8962
9082
  for (const install of inst.styleGroups.values())
8963
9083
  this.destroyInstall(install);
@@ -9940,15 +10060,19 @@ export class Engine {
9940
10060
  const src = wrapped ? entry.source : entry;
9941
10061
  const width = Math.max(1, "naturalWidth" in src ? src.naturalWidth : src.width);
9942
10062
  const height = Math.max(1, "naturalHeight" in src ? src.naturalHeight : src.height);
10063
+ const mipLevelCount = wrapped && entry.mipmaps ? Math.floor(Math.log2(Math.max(width, height))) + 1 : 1;
9943
10064
  const tex = this.device.createTexture({
9944
10065
  label: `group map: ${group.id}`,
9945
10066
  size: [width, height],
10067
+ mipLevelCount,
9946
10068
  // Colour maps decode to linear on sample, the way material textures do;
9947
10069
  // data maps must not, or every threshold packed in their channels moves.
9948
10070
  format: wrapped && entry.srgb ? "rgba8unorm-srgb" : "rgba8unorm",
9949
10071
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
9950
10072
  });
9951
10073
  this.device.queue.copyExternalImageToTexture({ source: src }, { texture: tex, premultipliedAlpha: wrapped && entry.premultiplied === true }, [width, height]);
10074
+ if (mipLevelCount > 1)
10075
+ this.generateMipmaps(tex, mipLevelCount);
9952
10076
  return tex;
9953
10077
  });
9954
10078
  }
@@ -11296,11 +11420,7 @@ export class Engine {
11296
11420
  { binding: 2, resource: this.materialSampler },
11297
11421
  ],
11298
11422
  }));
11299
- const mainPerInstanceBindGroup = this.device.createBindGroup({
11300
- label: `${name}: main per-instance bind group`,
11301
- layout: this.mainPerInstanceBindGroupLayout,
11302
- entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
11303
- });
11423
+ const mainPerInstanceBindGroup = this.perInstanceBindGroup(name, skinMatrixBuffer, null);
11304
11424
  const pickPerInstanceBindGroup = this.device.createBindGroup({
11305
11425
  label: `${name}: pick per-instance bind group`,
11306
11426
  layout: this.pickPerInstanceBindGroupLayout,
@@ -11333,6 +11453,7 @@ export class Engine {
11333
11453
  shadowDrawCalls: [],
11334
11454
  shadowBindGroups,
11335
11455
  mainPerInstanceBindGroup,
11456
+ fillBuffer: null,
11336
11457
  pickPerInstanceBindGroup,
11337
11458
  pickDrawCalls: [],
11338
11459
  isStage,
@@ -12218,31 +12339,35 @@ export class Engine {
12218
12339
  // Bilinear box-filter downsample per level. Reads srgb view (hardware linearizes on sample,
12219
12340
  // re-encodes on write), so intensities are filtered in linear space — matching EEVEE/Blender.
12220
12341
  generateMipmaps(texture, mipLevelCount) {
12221
- if (!this.mipBlitPipeline || !this.mipBlitSampler) {
12342
+ if (!this.mipBlitSampler) {
12222
12343
  this.mipBlitSampler = this.device.createSampler({
12223
12344
  magFilter: "linear",
12224
12345
  minFilter: "linear",
12225
12346
  addressModeU: "clamp-to-edge",
12226
12347
  addressModeV: "clamp-to-edge",
12227
12348
  });
12349
+ }
12350
+ let pipeline = this.mipBlitPipelines.get(texture.format);
12351
+ if (!pipeline) {
12228
12352
  const module = this.device.createShaderModule({
12229
12353
  label: "mipmap blit",
12230
12354
  code: MIPMAP_BLIT_SHADER_WGSL,
12231
12355
  });
12232
- this.mipBlitPipeline = this.device.createRenderPipeline({
12233
- label: "mipmap blit pipeline",
12356
+ pipeline = this.device.createRenderPipeline({
12357
+ label: `mipmap blit pipeline (${texture.format})`,
12234
12358
  layout: "auto",
12235
12359
  vertex: { module, entryPoint: "vs" },
12236
- fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm-srgb" }] },
12360
+ fragment: { module, entryPoint: "fs", targets: [{ format: texture.format }] },
12237
12361
  primitive: { topology: "triangle-list" },
12238
12362
  });
12363
+ this.mipBlitPipelines.set(texture.format, pipeline);
12239
12364
  }
12240
12365
  const encoder = this.device.createCommandEncoder({ label: "mipgen" });
12241
12366
  for (let level = 1; level < mipLevelCount; level++) {
12242
12367
  const srcView = texture.createView({ baseMipLevel: level - 1, mipLevelCount: 1 });
12243
12368
  const dstView = texture.createView({ baseMipLevel: level, mipLevelCount: 1 });
12244
12369
  const bindGroup = this.device.createBindGroup({
12245
- layout: this.mipBlitPipeline.getBindGroupLayout(0),
12370
+ layout: pipeline.getBindGroupLayout(0),
12246
12371
  entries: [
12247
12372
  { binding: 0, resource: srcView },
12248
12373
  { binding: 1, resource: this.mipBlitSampler },
@@ -12253,7 +12378,7 @@ export class Engine {
12253
12378
  { view: dstView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
12254
12379
  ],
12255
12380
  });
12256
- pass.setPipeline(this.mipBlitPipeline);
12381
+ pass.setPipeline(pipeline);
12257
12382
  pass.setBindGroup(0, bindGroup);
12258
12383
  pass.draw(3);
12259
12384
  pass.end();
@@ -15078,12 +15203,13 @@ Engine.JIGGLE_DAMPING_SCALE = 0.5;
15078
15203
  * prefix sum and readback a compacted draw list would need every frame.
15079
15204
  */
15080
15205
  /** Ceiling for `#particles`. Past this an author is asking for a stall. */
15081
- // Raised for the lawn: 65536 blades at real density is a 40-unit patch and
15082
- // an 80-unit one wants four hundred thousand. A cutout pool's cost is
15206
+ // Raised for the lawn: an 80-unit field at 36 blades a unit wants seven
15207
+ // hundred thousand, and a turf one three million. A cutout pool's cost is
15083
15208
  // bounded by the pixels it covers, not by the count — the step is one
15084
- // compute invocation per slot, the pool buffer is 48 bytes each (24 MB at
15085
- // the cap), and a culled blade is a degenerate quad the rasteriser drops.
15086
- Engine.MAX_PARTICLES = 524288;
15209
+ // compute invocation per slot, the pool buffer is 48 bytes each (96 MiB at
15210
+ // the cap, under the 128 MiB a storage binding may be without asking for
15211
+ // more), and a culled blade is a degenerate quad the rasteriser drops.
15212
+ Engine.MAX_PARTICLES = 2097152;
15087
15213
  /**
15088
15214
  * The field layer: user background/foreground mounts, ONE TARGET PAIR PER
15089
15215
  * RESOLUTION. Index 0 is full, index 1 is half — coarsest last, so the
@@ -121,7 +121,7 @@ ${gate}
121
121
  let sun = light.lights[0].color.xyz * light.lights[0].color.w;
122
122
  // The world: flat colour, or the HDRI's irradiance at this normal — which
123
123
  // is what makes a loaded sky actually light her instead of only backing her.
124
- let amb = rzWorldAmbient(n);
124
+ let amb = rzWorldAmbient(n) + modelFill.rgb;
125
125
  let shadow = sampleShadow(input.worldPos, n);
126
126
  let tex_color = tex_s.rgb;
127
127
 
@@ -75,6 +75,13 @@ export type GroupImage = {
75
75
  source: GroupImageSource;
76
76
  srgb?: boolean;
77
77
  premultiplied?: boolean;
78
+ /**
79
+ * Give it a mip chain. For a map TILED across a surface — a relief map, a
80
+ * water's ripples — whose far tiles otherwise shrink to a pixel each and
81
+ * sample as noise that sparkles with every camera move. Off by default: a
82
+ * ramp read at a computed coordinate would blur at its own terminator.
83
+ */
84
+ mipmaps?: boolean;
78
85
  };
79
86
  export type GroupDiagnostic = {
80
87
  groupId: string;
@@ -1 +1 @@
1
- {"version":3,"file":"style-group.d.ts","sourceRoot":"","sources":["../../src/graph/style-group.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AACvD,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC5D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAE1C,MAAM,MAAM,UAAU,GAAG;IACvB,wFAAwF;IACxF,EAAE,EAAE,MAAM,CAAA;IACV;2CACuC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,2EAA2E;IAC3E,SAAS,EAAE,MAAM,EAAE,CAAA;IACnB,sFAAsF;IACtF,KAAK,EAAE,WAAW,CAAA;IAClB,uEAAuE;IACvE,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,wEAAwE;IACxE,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,CAAC,UAAU,GAAG,gBAAgB,GAAG,IAAI,CAAC,EAAE,CAAA;IACjD;;;;;;;;;;;;OAYG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,UAAU,GAAG,gBAAgB,GAAG,IAAI,CAAC,EAAE,CAAC,CAAA;CAC5E,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG,WAAW,GAAG,gBAAgB,GAAG,iBAAiB,CAAA;AAEjF;;;;;;;;;;;;GAYG;AACH;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,UAAU,GAAG;IAAE,MAAM,EAAE,gBAAgB,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,CAAA;AAE9F,MAAM,MAAM,eAAe,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,UAAU,EAAE,CAAC;IAAC,EAAE,EAAE,OAAO,CAAA;CAAE,CAAA;AAEzF,MAAM,MAAM,sBAAsB,GAAG;IACnC,sCAAsC;IACtC,EAAE,EAAE,OAAO,CAAA;IACX,MAAM,EAAE,eAAe,EAAE,CAAA;IACzB,iEAAiE;IACjE,gBAAgB,EAAE,MAAM,EAAE,CAAA;IAC1B,kFAAkF;IAClF,SAAS,EAAE,MAAM,EAAE,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,WAAW,EAAE,UAAU,EAAE,CAAC;IAAC,OAAO,EAAE,SAAS,EAAE,CAAA;CAAE,CAAA"}
1
+ {"version":3,"file":"style-group.d.ts","sourceRoot":"","sources":["../../src/graph/style-group.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AACvD,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC5D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAE1C,MAAM,MAAM,UAAU,GAAG;IACvB,wFAAwF;IACxF,EAAE,EAAE,MAAM,CAAA;IACV;2CACuC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,2EAA2E;IAC3E,SAAS,EAAE,MAAM,EAAE,CAAA;IACnB,sFAAsF;IACtF,KAAK,EAAE,WAAW,CAAA;IAClB,uEAAuE;IACvE,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,wEAAwE;IACxE,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,CAAC,UAAU,GAAG,gBAAgB,GAAG,IAAI,CAAC,EAAE,CAAA;IACjD;;;;;;;;;;;;OAYG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,UAAU,GAAG,gBAAgB,GAAG,IAAI,CAAC,EAAE,CAAC,CAAA;CAC5E,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG,WAAW,GAAG,gBAAgB,GAAG,iBAAiB,CAAA;AAEjF;;;;;;;;;;;;GAYG;AACH;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,MAAM,EAAE,gBAAgB,CAAA;IACxB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,UAAU,EAAE,CAAC;IAAC,EAAE,EAAE,OAAO,CAAA;CAAE,CAAA;AAEzF,MAAM,MAAM,sBAAsB,GAAG;IACnC,sCAAsC;IACtC,EAAE,EAAE,OAAO,CAAA;IACX,MAAM,EAAE,eAAe,EAAE,CAAA;IACzB,iEAAiE;IACjE,gBAAgB,EAAE,MAAM,EAAE,CAAA;IAC1B,kFAAkF;IAClF,SAAS,EAAE,MAAM,EAAE,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,WAAW,EAAE,UAAU,EAAE,CAAC;IAAC,OAAO,EAAE,SAAS,EAAE,CAAA;CAAE,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/common.ts"],"names":[],"mappings":"AA4BA,eAAO,MAAM,oBAAoB,QAmJhC,CAAC;AA8HF,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":"AA4BA,eAAO,MAAM,oBAAoB,QAsJhC,CAAC;AA8HF,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAGD;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,aAAa,orMAgHzB,CAAA;AAQD,eAAO,MAAM,4BAA4B,QAC0D,CAAA"}
@@ -143,6 +143,9 @@ struct LightVP { viewProj: array<mat4x4f, ${SHADOW_CASCADES.length}>, };
143
143
  @group(0) @binding(8) var worldEnvTexture: texture_2d<f32>;
144
144
  // binding(9) brdfLut is declared inside NODES_WGSL (nodes.ts).
145
145
  @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
146
+ // Light this model receives beyond the world's — see Engine.setModelFill. Zero
147
+ // for a model nobody gave one, which leaves its ambient exactly the world's.
148
+ @group(1) @binding(1) var<uniform> modelFill: vec4f;
146
149
  @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
147
150
  @group(2) @binding(1) var<uniform> material: MaterialUniforms;
148
151
  // Reserved for future sphere/toon graph nodes; graphs that don't read them get the
@@ -36,6 +36,15 @@ type ParticleSource = {
36
36
  * where every stacked fragment is otherwise shaded in full; the prepass is
37
37
  * the shape of that rejection, done by hand and cheaply. */
38
38
  cover: boolean;
39
+ /** The effect defines `particleCount() -> u32`: how many of the pool are
40
+ * live this frame, as a function of its dials. The step is dispatched and
41
+ * the quads drawn for that many slots and no more, through indirect
42
+ * arguments a one-thread kernel writes first. A pool sized for its densest
43
+ * setting then costs its current setting: a lawn's spare 1.3M slots were
44
+ * read, written and issued as degenerate quads every frame, and that
45
+ * traffic alone was a third of the frame. The live slots are always the
46
+ * first N — an author whose live set is not a prefix has no use for this. */
47
+ live: boolean;
39
48
  /** The side of this effect's grid, or 0 when it declared none. Particles READ
40
49
  * the grid; the kernel that steps it lives in the grid pass. A blade of grass
41
50
  * bending where a foot pressed is the whole case: the bend has to persist and
@@ -46,15 +55,23 @@ type ParticleSource = {
46
55
  export declare const PARTICLE_LIGHT_BINDING = 10;
47
56
  /** The effect's named points (`#points`), in both stages — see points-api.ts. */
48
57
  export declare const PARTICLE_POINTS_BINDING = 15;
58
+ /** The indirect arguments a `particleCount` effect writes — compute stage only.
59
+ * Eight u32: the step's dispatch (groups, 1, 1, pad) then the quads' draw
60
+ * (6, count, 0, 0), so the draw reads at byte 16. */
61
+ export declare const PARTICLE_INDIRECT_BINDING = 16;
62
+ export declare const PARTICLE_INDIRECT_BYTES = 32;
63
+ export declare const PARTICLE_INDIRECT_DRAW_OFFSET = 16;
49
64
  /** Bytes per particle. Explicitly padded — see the struct below. */
50
65
  export declare const PARTICLE_STRIDE = 48;
51
66
  /** Does the source define the particle contract? All three are required
52
- * together; `cover` is optional and only means anything to a cutout. */
67
+ * together; `cover` is optional and only means anything to a cutout, and
68
+ * `count` is optional and bounds the frame's work — see particleCount. */
53
69
  export declare function particleEntryPoints(wgsl: string): {
54
70
  init: boolean;
55
71
  step: boolean;
56
72
  shade: boolean;
57
73
  cover: boolean;
74
+ count: boolean;
58
75
  };
59
76
  /**
60
77
  * Spawn, age, recycle.
@@ -1 +1 @@
1
- {"version":3,"file":"particles.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/particles.ts"],"names":[],"mappings":"AA+BA,sEAAsE;AACtE,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,iFAAiF;IACjF,KAAK,EAAE,MAAM,CAAA;IACb;;mFAE+E;IAC/E,UAAU,EAAE,MAAM,CAAA;IAClB,+EAA+E;IAC/E,KAAK,EAAE,MAAM,EAAE,CAAA;CAChB,CAAA;AAkCD,qDAAqD;AACrD;0EAC0E;AAC1E,KAAK,aAAa,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,CAAA;AAEpD,KAAK,cAAc,GAAG;IACpB,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,qFAAqF;IACrF,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,aAAa,CAAA;IACpB,qEAAqE;IACrE,KAAK,EAAE,OAAO,CAAA;IACd;;+BAE2B;IAC3B,UAAU,EAAE,MAAM,CAAA;IAClB;;;;;iEAK6D;IAC7D,KAAK,EAAE,OAAO,CAAA;IACd;;;wEAGoE;IACpE,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,0FAA0F;AAC1F,eAAO,MAAM,sBAAsB,KAAK,CAAA;AACxC,iFAAiF;AACjF,eAAO,MAAM,uBAAuB,KAAK,CAAA;AAEzC,oEAAoE;AACpE,eAAO,MAAM,eAAe,KAAK,CAAA;AAwDjC;yEACyE;AACzE,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAOlH;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CAqDxF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CA2LvF"}
1
+ {"version":3,"file":"particles.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/particles.ts"],"names":[],"mappings":"AA+BA,sEAAsE;AACtE,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,iFAAiF;IACjF,KAAK,EAAE,MAAM,CAAA;IACb;;mFAE+E;IAC/E,UAAU,EAAE,MAAM,CAAA;IAClB,+EAA+E;IAC/E,KAAK,EAAE,MAAM,EAAE,CAAA;CAChB,CAAA;AAkCD,qDAAqD;AACrD;0EAC0E;AAC1E,KAAK,aAAa,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,CAAA;AAEpD,KAAK,cAAc,GAAG;IACpB,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,qFAAqF;IACrF,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,aAAa,CAAA;IACpB,qEAAqE;IACrE,KAAK,EAAE,OAAO,CAAA;IACd;;+BAE2B;IAC3B,UAAU,EAAE,MAAM,CAAA;IAClB;;;;;iEAK6D;IAC7D,KAAK,EAAE,OAAO,CAAA;IACd;;;;;;;kFAO8E;IAC9E,IAAI,EAAE,OAAO,CAAA;IACb;;;wEAGoE;IACpE,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,0FAA0F;AAC1F,eAAO,MAAM,sBAAsB,KAAK,CAAA;AACxC,iFAAiF;AACjF,eAAO,MAAM,uBAAuB,KAAK,CAAA;AACzC;;sDAEsD;AACtD,eAAO,MAAM,yBAAyB,KAAK,CAAA;AAC3C,eAAO,MAAM,uBAAuB,KAAK,CAAA;AACzC,eAAO,MAAM,6BAA6B,KAAK,CAAA;AAE/C,oEAAoE;AACpE,eAAO,MAAM,eAAe,KAAK,CAAA;AAwDjC;;2EAE2E;AAC3E,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAQlI;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CA0ExF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CA2LvF"}
@@ -46,6 +46,12 @@ fn _rzCastLive() -> i32 {
46
46
  export const PARTICLE_LIGHT_BINDING = 10;
47
47
  /** The effect's named points (`#points`), in both stages — see points-api.ts. */
48
48
  export const PARTICLE_POINTS_BINDING = 15;
49
+ /** The indirect arguments a `particleCount` effect writes — compute stage only.
50
+ * Eight u32: the step's dispatch (groups, 1, 1, pad) then the quads' draw
51
+ * (6, count, 0, 0), so the draw reads at byte 16. */
52
+ export const PARTICLE_INDIRECT_BINDING = 16;
53
+ export const PARTICLE_INDIRECT_BYTES = 32;
54
+ export const PARTICLE_INDIRECT_DRAW_OFFSET = 16;
49
55
  /** Bytes per particle. Explicitly padded — see the struct below. */
50
56
  export const PARTICLE_STRIDE = 48;
51
57
  const CAMERA_STRUCT = /* wgsl */ `
@@ -99,13 +105,15 @@ fn rzProject(p: vec3f) -> vec3f {
99
105
  fn rzCamPos() -> vec3f { return cam.camPos; }
100
106
  `;
101
107
  /** Does the source define the particle contract? All three are required
102
- * together; `cover` is optional and only means anything to a cutout. */
108
+ * together; `cover` is optional and only means anything to a cutout, and
109
+ * `count` is optional and bounds the frame's work — see particleCount. */
103
110
  export function particleEntryPoints(wgsl) {
104
111
  return {
105
112
  init: /\bfn\s+particleInit\s*\(/.test(wgsl),
106
113
  step: /\bfn\s+particleStep\s*\(/.test(wgsl),
107
114
  shade: /\bfn\s+particleShade\s*\(/.test(wgsl),
108
115
  cover: /\bfn\s+particleCover\s*\(/.test(wgsl),
116
+ count: /\bfn\s+particleCount\s*\(/.test(wgsl),
109
117
  };
110
118
  }
111
119
  /**
@@ -143,8 +151,29 @@ ${src.paramsDecl}
143
151
  idApi(false, 0, 0) + castDistanceStub() + sceneLightApi(false, 0, 0) +
144
152
  // The named points, for real: particleInit is where a flame is put on a wick.
145
153
  pointsApi(true, 0, PARTICLE_POINTS_BINDING) +
154
+ (src.live
155
+ ? `@group(0) @binding(${PARTICLE_INDIRECT_BINDING}) var<storage, read_write> _rzIndirect: array<u32, 8>;\n`
156
+ : "") +
146
157
  "\n// ── user effect ──\n" +
147
158
  src.wgsl +
159
+ (src.live
160
+ ? /* wgsl */ `
161
+ // The frame's live count, from the author's dials, into the arguments the
162
+ // step dispatch and both draws read. One thread; runs before the step.
163
+ @compute @workgroup_size(1)
164
+ fn rzCount() {
165
+ let n = min(particleCount(), pu.count);
166
+ _rzIndirect[0] = (n + 63u) / 64u;
167
+ _rzIndirect[1] = 1u;
168
+ _rzIndirect[2] = 1u;
169
+ _rzIndirect[3] = 0u;
170
+ _rzIndirect[4] = 6u;
171
+ _rzIndirect[5] = n;
172
+ _rzIndirect[6] = 0u;
173
+ _rzIndirect[7] = 0u;
174
+ }
175
+ `
176
+ : "") +
148
177
  /* wgsl */ `
149
178
  @compute @workgroup_size(64)
150
179
  fn main(@builtin(global_invocation_id) gid: vec3u) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.58.2",
3
+ "version": "0.58.4",
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",