reze-engine 0.2.11 → 0.2.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.
package/dist/engine.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Camera } from "./camera";
2
2
  import { Quat, Vec3 } from "./math";
3
+ import { Pool } from "./pool";
3
4
  import { PmxLoader } from "./pmx-loader";
4
5
  import { Physics } from "./physics";
5
6
  import { VMDLoader } from "./vmd-loader";
@@ -26,6 +27,7 @@ export class Engine {
26
27
  this.currentModel = null;
27
28
  this.modelDir = "";
28
29
  this.physics = null;
30
+ this.pool = null;
29
31
  this.textureCache = new Map();
30
32
  // Draw lists
31
33
  this.opaqueDraws = [];
@@ -99,121 +101,121 @@ export class Engine {
99
101
  });
100
102
  const shaderModule = this.device.createShaderModule({
101
103
  label: "model shaders",
102
- code: /* wgsl */ `
103
- struct CameraUniforms {
104
- view: mat4x4f,
105
- projection: mat4x4f,
106
- viewPos: vec3f,
107
- _padding: f32,
108
- };
109
-
110
- struct Light {
111
- direction: vec3f,
112
- _padding1: f32,
113
- color: vec3f,
114
- intensity: f32,
115
- };
116
-
117
- struct LightUniforms {
118
- ambient: f32,
119
- lightCount: f32,
120
- _padding1: f32,
121
- _padding2: f32,
122
- lights: array<Light, 4>,
123
- };
124
-
125
- struct MaterialUniforms {
126
- alpha: f32,
127
- alphaMultiplier: f32,
128
- rimIntensity: f32,
129
- _padding1: f32,
130
- rimColor: vec3f,
131
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
132
- };
133
-
134
- struct VertexOutput {
135
- @builtin(position) position: vec4f,
136
- @location(0) normal: vec3f,
137
- @location(1) uv: vec2f,
138
- @location(2) worldPos: vec3f,
139
- };
140
-
141
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
142
- @group(0) @binding(1) var<uniform> light: LightUniforms;
143
- @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
144
- @group(0) @binding(3) var diffuseSampler: sampler;
145
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
146
- @group(0) @binding(5) var toonTexture: texture_2d<f32>;
147
- @group(0) @binding(6) var toonSampler: sampler;
148
- @group(0) @binding(7) var<uniform> material: MaterialUniforms;
149
-
150
- @vertex fn vs(
151
- @location(0) position: vec3f,
152
- @location(1) normal: vec3f,
153
- @location(2) uv: vec2f,
154
- @location(3) joints0: vec4<u32>,
155
- @location(4) weights0: vec4<f32>
156
- ) -> VertexOutput {
157
- var output: VertexOutput;
158
- let pos4 = vec4f(position, 1.0);
159
-
160
- // Branchless weight normalization (avoids GPU branch divergence)
161
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
162
- let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
163
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
164
-
165
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
166
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
167
- for (var i = 0u; i < 4u; i++) {
168
- let j = joints0[i];
169
- let w = normalizedWeights[i];
170
- let m = skinMats[j];
171
- skinnedPos += (m * pos4) * w;
172
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
173
- skinnedNrm += (r3 * normal) * w;
174
- }
175
- let worldPos = skinnedPos.xyz;
176
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
177
- output.normal = normalize(skinnedNrm);
178
- output.uv = uv;
179
- output.worldPos = worldPos;
180
- return output;
181
- }
182
-
183
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
184
- // Early alpha test - discard before expensive calculations
185
- var finalAlpha = material.alpha * material.alphaMultiplier;
186
- if (material.isOverEyes > 0.5) {
187
- finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
188
- }
189
- if (finalAlpha < 0.001) {
190
- discard;
191
- }
192
-
193
- let n = normalize(input.normal);
194
- let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
195
-
196
- var lightAccum = vec3f(light.ambient);
197
- let numLights = u32(light.lightCount);
198
- for (var i = 0u; i < numLights; i++) {
199
- let l = -light.lights[i].direction;
200
- let nDotL = max(dot(n, l), 0.0);
201
- let toonUV = vec2f(nDotL, 0.5);
202
- let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
203
- let radiance = light.lights[i].color * light.lights[i].intensity;
204
- lightAccum += toonFactor * radiance * nDotL;
205
- }
206
-
207
- // Rim light calculation
208
- let viewDir = normalize(camera.viewPos - input.worldPos);
209
- var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
210
- rimFactor = rimFactor * rimFactor; // Optimized: direct multiply instead of pow(x, 2.0)
211
- let rimLight = material.rimColor * material.rimIntensity * rimFactor;
212
-
213
- let color = albedo * lightAccum + rimLight;
214
-
215
- return vec4f(color, finalAlpha);
216
- }
104
+ code: /* wgsl */ `
105
+ struct CameraUniforms {
106
+ view: mat4x4f,
107
+ projection: mat4x4f,
108
+ viewPos: vec3f,
109
+ _padding: f32,
110
+ };
111
+
112
+ struct Light {
113
+ direction: vec3f,
114
+ _padding1: f32,
115
+ color: vec3f,
116
+ intensity: f32,
117
+ };
118
+
119
+ struct LightUniforms {
120
+ ambient: f32,
121
+ lightCount: f32,
122
+ _padding1: f32,
123
+ _padding2: f32,
124
+ lights: array<Light, 4>,
125
+ };
126
+
127
+ struct MaterialUniforms {
128
+ alpha: f32,
129
+ alphaMultiplier: f32,
130
+ rimIntensity: f32,
131
+ _padding1: f32,
132
+ rimColor: vec3f,
133
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
134
+ };
135
+
136
+ struct VertexOutput {
137
+ @builtin(position) position: vec4f,
138
+ @location(0) normal: vec3f,
139
+ @location(1) uv: vec2f,
140
+ @location(2) worldPos: vec3f,
141
+ };
142
+
143
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
144
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
145
+ @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
146
+ @group(0) @binding(3) var diffuseSampler: sampler;
147
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
148
+ @group(0) @binding(5) var toonTexture: texture_2d<f32>;
149
+ @group(0) @binding(6) var toonSampler: sampler;
150
+ @group(0) @binding(7) var<uniform> material: MaterialUniforms;
151
+
152
+ @vertex fn vs(
153
+ @location(0) position: vec3f,
154
+ @location(1) normal: vec3f,
155
+ @location(2) uv: vec2f,
156
+ @location(3) joints0: vec4<u32>,
157
+ @location(4) weights0: vec4<f32>
158
+ ) -> VertexOutput {
159
+ var output: VertexOutput;
160
+ let pos4 = vec4f(position, 1.0);
161
+
162
+ // Branchless weight normalization (avoids GPU branch divergence)
163
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
164
+ let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
165
+ let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
166
+
167
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
168
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
169
+ for (var i = 0u; i < 4u; i++) {
170
+ let j = joints0[i];
171
+ let w = normalizedWeights[i];
172
+ let m = skinMats[j];
173
+ skinnedPos += (m * pos4) * w;
174
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
175
+ skinnedNrm += (r3 * normal) * w;
176
+ }
177
+ let worldPos = skinnedPos.xyz;
178
+ output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
179
+ output.normal = normalize(skinnedNrm);
180
+ output.uv = uv;
181
+ output.worldPos = worldPos;
182
+ return output;
183
+ }
184
+
185
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
186
+ // Early alpha test - discard before expensive calculations
187
+ var finalAlpha = material.alpha * material.alphaMultiplier;
188
+ if (material.isOverEyes > 0.5) {
189
+ finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
190
+ }
191
+ if (finalAlpha < 0.001) {
192
+ discard;
193
+ }
194
+
195
+ let n = normalize(input.normal);
196
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
197
+
198
+ var lightAccum = vec3f(light.ambient);
199
+ let numLights = u32(light.lightCount);
200
+ for (var i = 0u; i < numLights; i++) {
201
+ let l = -light.lights[i].direction;
202
+ let nDotL = max(dot(n, l), 0.0);
203
+ let toonUV = vec2f(nDotL, 0.5);
204
+ let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
205
+ let radiance = light.lights[i].color * light.lights[i].intensity;
206
+ lightAccum += toonFactor * radiance * nDotL;
207
+ }
208
+
209
+ // Rim light calculation
210
+ let viewDir = normalize(camera.viewPos - input.worldPos);
211
+ var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
212
+ rimFactor = rimFactor * rimFactor; // Optimized: direct multiply instead of pow(x, 2.0)
213
+ let rimLight = material.rimColor * material.rimIntensity * rimFactor;
214
+
215
+ let color = albedo * lightAccum + rimLight;
216
+
217
+ return vec4f(color, finalAlpha);
218
+ }
217
219
  `,
218
220
  });
219
221
  // Create explicit bind group layout for all pipelines using the main shader
@@ -303,73 +305,73 @@ export class Engine {
303
305
  });
304
306
  const outlineShaderModule = this.device.createShaderModule({
305
307
  label: "outline shaders",
306
- code: /* wgsl */ `
307
- struct CameraUniforms {
308
- view: mat4x4f,
309
- projection: mat4x4f,
310
- viewPos: vec3f,
311
- _padding: f32,
312
- };
313
-
314
- struct MaterialUniforms {
315
- edgeColor: vec4f,
316
- edgeSize: f32,
317
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
318
- _padding1: f32,
319
- _padding2: f32,
320
- };
321
-
322
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
323
- @group(0) @binding(1) var<uniform> material: MaterialUniforms;
324
- @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
325
-
326
- struct VertexOutput {
327
- @builtin(position) position: vec4f,
328
- };
329
-
330
- @vertex fn vs(
331
- @location(0) position: vec3f,
332
- @location(1) normal: vec3f,
333
- @location(3) joints0: vec4<u32>,
334
- @location(4) weights0: vec4<f32>
335
- ) -> VertexOutput {
336
- var output: VertexOutput;
337
- let pos4 = vec4f(position, 1.0);
338
-
339
- // Branchless weight normalization (avoids GPU branch divergence)
340
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
341
- let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
342
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
343
-
344
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
345
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
346
- for (var i = 0u; i < 4u; i++) {
347
- let j = joints0[i];
348
- let w = normalizedWeights[i];
349
- let m = skinMats[j];
350
- skinnedPos += (m * pos4) * w;
351
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
352
- skinnedNrm += (r3 * normal) * w;
353
- }
354
- let worldPos = skinnedPos.xyz;
355
- let worldNormal = normalize(skinnedNrm);
356
-
357
- // MMD invert hull: expand vertices outward along normals
358
- let scaleFactor = 0.01;
359
- let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
360
- output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
361
- return output;
362
- }
363
-
364
- @fragment fn fs() -> @location(0) vec4f {
365
- var color = material.edgeColor;
366
-
367
- if (material.isOverEyes > 0.5) {
368
- color.a *= 0.5; // Hair outlines over eyes get 50% alpha
369
- }
370
-
371
- return color;
372
- }
308
+ code: /* wgsl */ `
309
+ struct CameraUniforms {
310
+ view: mat4x4f,
311
+ projection: mat4x4f,
312
+ viewPos: vec3f,
313
+ _padding: f32,
314
+ };
315
+
316
+ struct MaterialUniforms {
317
+ edgeColor: vec4f,
318
+ edgeSize: f32,
319
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
320
+ _padding1: f32,
321
+ _padding2: f32,
322
+ };
323
+
324
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
325
+ @group(0) @binding(1) var<uniform> material: MaterialUniforms;
326
+ @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
327
+
328
+ struct VertexOutput {
329
+ @builtin(position) position: vec4f,
330
+ };
331
+
332
+ @vertex fn vs(
333
+ @location(0) position: vec3f,
334
+ @location(1) normal: vec3f,
335
+ @location(3) joints0: vec4<u32>,
336
+ @location(4) weights0: vec4<f32>
337
+ ) -> VertexOutput {
338
+ var output: VertexOutput;
339
+ let pos4 = vec4f(position, 1.0);
340
+
341
+ // Branchless weight normalization (avoids GPU branch divergence)
342
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
343
+ let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
344
+ let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
345
+
346
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
347
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
348
+ for (var i = 0u; i < 4u; i++) {
349
+ let j = joints0[i];
350
+ let w = normalizedWeights[i];
351
+ let m = skinMats[j];
352
+ skinnedPos += (m * pos4) * w;
353
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
354
+ skinnedNrm += (r3 * normal) * w;
355
+ }
356
+ let worldPos = skinnedPos.xyz;
357
+ let worldNormal = normalize(skinnedNrm);
358
+
359
+ // MMD invert hull: expand vertices outward along normals
360
+ let scaleFactor = 0.01;
361
+ let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
362
+ output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
363
+ return output;
364
+ }
365
+
366
+ @fragment fn fs() -> @location(0) vec4f {
367
+ var color = material.edgeColor;
368
+
369
+ if (material.isOverEyes > 0.5) {
370
+ color.a *= 0.5; // Hair outlines over eyes get 50% alpha
371
+ }
372
+
373
+ return color;
374
+ }
373
375
  `,
374
376
  });
375
377
  this.outlinePipeline = this.device.createRenderPipeline({
@@ -573,45 +575,45 @@ export class Engine {
573
575
  // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
574
576
  const depthOnlyShaderModule = this.device.createShaderModule({
575
577
  label: "depth only shader",
576
- code: /* wgsl */ `
577
- struct CameraUniforms {
578
- view: mat4x4f,
579
- projection: mat4x4f,
580
- viewPos: vec3f,
581
- _padding: f32,
582
- };
583
-
584
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
585
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
586
-
587
- @vertex fn vs(
588
- @location(0) position: vec3f,
589
- @location(1) normal: vec3f,
590
- @location(3) joints0: vec4<u32>,
591
- @location(4) weights0: vec4<f32>
592
- ) -> @builtin(position) vec4f {
593
- let pos4 = vec4f(position, 1.0);
594
-
595
- // Branchless weight normalization (avoids GPU branch divergence)
596
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
597
- let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
598
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
599
-
600
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
601
- for (var i = 0u; i < 4u; i++) {
602
- let j = joints0[i];
603
- let w = normalizedWeights[i];
604
- let m = skinMats[j];
605
- skinnedPos += (m * pos4) * w;
606
- }
607
- let worldPos = skinnedPos.xyz;
608
- let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
609
- return clipPos;
610
- }
611
-
612
- @fragment fn fs() -> @location(0) vec4f {
613
- return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
614
- }
578
+ code: /* wgsl */ `
579
+ struct CameraUniforms {
580
+ view: mat4x4f,
581
+ projection: mat4x4f,
582
+ viewPos: vec3f,
583
+ _padding: f32,
584
+ };
585
+
586
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
587
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
588
+
589
+ @vertex fn vs(
590
+ @location(0) position: vec3f,
591
+ @location(1) normal: vec3f,
592
+ @location(3) joints0: vec4<u32>,
593
+ @location(4) weights0: vec4<f32>
594
+ ) -> @builtin(position) vec4f {
595
+ let pos4 = vec4f(position, 1.0);
596
+
597
+ // Branchless weight normalization (avoids GPU branch divergence)
598
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
599
+ let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
600
+ let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
601
+
602
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
603
+ for (var i = 0u; i < 4u; i++) {
604
+ let j = joints0[i];
605
+ let w = normalizedWeights[i];
606
+ let m = skinMats[j];
607
+ skinnedPos += (m * pos4) * w;
608
+ }
609
+ let worldPos = skinnedPos.xyz;
610
+ let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
611
+ return clipPos;
612
+ }
613
+
614
+ @fragment fn fs() -> @location(0) vec4f {
615
+ return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
616
+ }
615
617
  `,
616
618
  });
617
619
  // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
@@ -794,30 +796,30 @@ export class Engine {
794
796
  createSkinMatrixComputePipeline() {
795
797
  const computeShader = this.device.createShaderModule({
796
798
  label: "skin matrix compute",
797
- code: /* wgsl */ `
798
- struct BoneCountUniform {
799
- count: u32,
800
- _padding1: u32,
801
- _padding2: u32,
802
- _padding3: u32,
803
- _padding4: vec4<u32>,
804
- };
805
-
806
- @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
807
- @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
808
- @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
809
- @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
810
-
811
- @compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
812
- fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
813
- let boneIndex = globalId.x;
814
- if (boneIndex >= boneCount.count) {
815
- return;
816
- }
817
- let worldMat = worldMatrices[boneIndex];
818
- let invBindMat = inverseBindMatrices[boneIndex];
819
- skinMatrices[boneIndex] = worldMat * invBindMat;
820
- }
799
+ code: /* wgsl */ `
800
+ struct BoneCountUniform {
801
+ count: u32,
802
+ _padding1: u32,
803
+ _padding2: u32,
804
+ _padding3: u32,
805
+ _padding4: vec4<u32>,
806
+ };
807
+
808
+ @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
809
+ @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
810
+ @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
811
+ @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
812
+
813
+ @compute @workgroup_size(64) // Must match COMPUTE_WORKGROUP_SIZE
814
+ fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
815
+ let boneIndex = globalId.x;
816
+ if (boneIndex >= boneCount.count) {
817
+ return;
818
+ }
819
+ let worldMat = worldMatrices[boneIndex];
820
+ let invBindMat = inverseBindMatrices[boneIndex];
821
+ skinMatrices[boneIndex] = worldMat * invBindMat;
822
+ }
821
823
  `,
822
824
  });
823
825
  this.skinMatrixComputePipeline = this.device.createComputePipeline({
@@ -871,140 +873,140 @@ export class Engine {
871
873
  // Bloom extraction shader (extracts bright areas)
872
874
  const bloomExtractShader = this.device.createShaderModule({
873
875
  label: "bloom extract",
874
- code: /* wgsl */ `
875
- struct VertexOutput {
876
- @builtin(position) position: vec4f,
877
- @location(0) uv: vec2f,
878
- };
879
-
880
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
881
- var output: VertexOutput;
882
- // Generate fullscreen quad from vertex index
883
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
884
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
885
- output.position = vec4f(x, y, 0.0, 1.0);
886
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
887
- return output;
888
- }
889
-
890
- struct BloomExtractUniforms {
891
- threshold: f32,
892
- _padding1: f32,
893
- _padding2: f32,
894
- _padding3: f32,
895
- _padding4: f32,
896
- _padding5: f32,
897
- _padding6: f32,
898
- _padding7: f32,
899
- };
900
-
901
- @group(0) @binding(0) var inputTexture: texture_2d<f32>;
902
- @group(0) @binding(1) var inputSampler: sampler;
903
- @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
904
-
905
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
906
- let color = textureSample(inputTexture, inputSampler, input.uv);
907
- // Extract bright areas above threshold
908
- let threshold = extractUniforms.threshold;
909
- let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
910
- return vec4f(bloom, color.a);
911
- }
876
+ code: /* wgsl */ `
877
+ struct VertexOutput {
878
+ @builtin(position) position: vec4f,
879
+ @location(0) uv: vec2f,
880
+ };
881
+
882
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
883
+ var output: VertexOutput;
884
+ // Generate fullscreen quad from vertex index
885
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
886
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
887
+ output.position = vec4f(x, y, 0.0, 1.0);
888
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
889
+ return output;
890
+ }
891
+
892
+ struct BloomExtractUniforms {
893
+ threshold: f32,
894
+ _padding1: f32,
895
+ _padding2: f32,
896
+ _padding3: f32,
897
+ _padding4: f32,
898
+ _padding5: f32,
899
+ _padding6: f32,
900
+ _padding7: f32,
901
+ };
902
+
903
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
904
+ @group(0) @binding(1) var inputSampler: sampler;
905
+ @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
906
+
907
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
908
+ let color = textureSample(inputTexture, inputSampler, input.uv);
909
+ // Extract bright areas above threshold
910
+ let threshold = extractUniforms.threshold;
911
+ let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
912
+ return vec4f(bloom, color.a);
913
+ }
912
914
  `,
913
915
  });
914
916
  // Bloom blur shader (gaussian blur - can be used for both horizontal and vertical)
915
917
  const bloomBlurShader = this.device.createShaderModule({
916
918
  label: "bloom blur",
917
- code: /* wgsl */ `
918
- struct VertexOutput {
919
- @builtin(position) position: vec4f,
920
- @location(0) uv: vec2f,
921
- };
922
-
923
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
924
- var output: VertexOutput;
925
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
926
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
927
- output.position = vec4f(x, y, 0.0, 1.0);
928
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
929
- return output;
930
- }
931
-
932
- struct BlurUniforms {
933
- direction: vec2f,
934
- _padding1: f32,
935
- _padding2: f32,
936
- _padding3: f32,
937
- _padding4: f32,
938
- _padding5: f32,
939
- _padding6: f32,
940
- };
941
-
942
- @group(0) @binding(0) var inputTexture: texture_2d<f32>;
943
- @group(0) @binding(1) var inputSampler: sampler;
944
- @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
945
-
946
- // 3-tap gaussian blur using bilinear filtering trick (40% fewer texture fetches!)
947
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
948
- let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
949
-
950
- // Bilinear optimization: leverage hardware filtering to sample between pixels
951
- // Original 5-tap: weights [0.06136, 0.24477, 0.38774, 0.24477, 0.06136] at offsets [-2, -1, 0, 1, 2]
952
- // Optimized 3-tap: combine adjacent samples using weighted offsets
953
- let weight0 = 0.38774; // Center sample
954
- let weight1 = 0.24477 + 0.06136; // Combined outer samples = 0.30613
955
- let offset1 = (0.24477 * 1.0 + 0.06136 * 2.0) / weight1; // Weighted position = 1.2
956
-
957
- var result = textureSample(inputTexture, inputSampler, input.uv) * weight0;
958
- let offsetVec = offset1 * texelSize * blurUniforms.direction;
959
- result += textureSample(inputTexture, inputSampler, input.uv + offsetVec) * weight1;
960
- result += textureSample(inputTexture, inputSampler, input.uv - offsetVec) * weight1;
961
-
962
- return result;
963
- }
919
+ code: /* wgsl */ `
920
+ struct VertexOutput {
921
+ @builtin(position) position: vec4f,
922
+ @location(0) uv: vec2f,
923
+ };
924
+
925
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
926
+ var output: VertexOutput;
927
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
928
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
929
+ output.position = vec4f(x, y, 0.0, 1.0);
930
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
931
+ return output;
932
+ }
933
+
934
+ struct BlurUniforms {
935
+ direction: vec2f,
936
+ _padding1: f32,
937
+ _padding2: f32,
938
+ _padding3: f32,
939
+ _padding4: f32,
940
+ _padding5: f32,
941
+ _padding6: f32,
942
+ };
943
+
944
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
945
+ @group(0) @binding(1) var inputSampler: sampler;
946
+ @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
947
+
948
+ // 3-tap gaussian blur using bilinear filtering trick (40% fewer texture fetches!)
949
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
950
+ let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
951
+
952
+ // Bilinear optimization: leverage hardware filtering to sample between pixels
953
+ // Original 5-tap: weights [0.06136, 0.24477, 0.38774, 0.24477, 0.06136] at offsets [-2, -1, 0, 1, 2]
954
+ // Optimized 3-tap: combine adjacent samples using weighted offsets
955
+ let weight0 = 0.38774; // Center sample
956
+ let weight1 = 0.24477 + 0.06136; // Combined outer samples = 0.30613
957
+ let offset1 = (0.24477 * 1.0 + 0.06136 * 2.0) / weight1; // Weighted position = 1.2
958
+
959
+ var result = textureSample(inputTexture, inputSampler, input.uv) * weight0;
960
+ let offsetVec = offset1 * texelSize * blurUniforms.direction;
961
+ result += textureSample(inputTexture, inputSampler, input.uv + offsetVec) * weight1;
962
+ result += textureSample(inputTexture, inputSampler, input.uv - offsetVec) * weight1;
963
+
964
+ return result;
965
+ }
964
966
  `,
965
967
  });
966
968
  // Bloom composition shader (combines original scene with bloom)
967
969
  const bloomComposeShader = this.device.createShaderModule({
968
970
  label: "bloom compose",
969
- code: /* wgsl */ `
970
- struct VertexOutput {
971
- @builtin(position) position: vec4f,
972
- @location(0) uv: vec2f,
973
- };
974
-
975
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
976
- var output: VertexOutput;
977
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
978
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
979
- output.position = vec4f(x, y, 0.0, 1.0);
980
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
981
- return output;
982
- }
983
-
984
- struct BloomComposeUniforms {
985
- intensity: f32,
986
- _padding1: f32,
987
- _padding2: f32,
988
- _padding3: f32,
989
- _padding4: f32,
990
- _padding5: f32,
991
- _padding6: f32,
992
- _padding7: f32,
993
- };
994
-
995
- @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
996
- @group(0) @binding(1) var sceneSampler: sampler;
997
- @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
998
- @group(0) @binding(3) var bloomSampler: sampler;
999
- @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
1000
-
1001
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1002
- let scene = textureSample(sceneTexture, sceneSampler, input.uv);
1003
- let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
1004
- // Additive blending with intensity control
1005
- let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
1006
- return vec4f(result, scene.a);
1007
- }
971
+ code: /* wgsl */ `
972
+ struct VertexOutput {
973
+ @builtin(position) position: vec4f,
974
+ @location(0) uv: vec2f,
975
+ };
976
+
977
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
978
+ var output: VertexOutput;
979
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
980
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
981
+ output.position = vec4f(x, y, 0.0, 1.0);
982
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
983
+ return output;
984
+ }
985
+
986
+ struct BloomComposeUniforms {
987
+ intensity: f32,
988
+ _padding1: f32,
989
+ _padding2: f32,
990
+ _padding3: f32,
991
+ _padding4: f32,
992
+ _padding5: f32,
993
+ _padding6: f32,
994
+ _padding7: f32,
995
+ };
996
+
997
+ @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
998
+ @group(0) @binding(1) var sceneSampler: sampler;
999
+ @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
1000
+ @group(0) @binding(3) var bloomSampler: sampler;
1001
+ @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
1002
+
1003
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1004
+ let scene = textureSample(sceneTexture, sceneSampler, input.uv);
1005
+ let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
1006
+ // Additive blending with intensity control
1007
+ let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
1008
+ return vec4f(result, scene.a);
1009
+ }
1008
1010
  `,
1009
1011
  });
1010
1012
  // Create uniform buffer for blur direction (minimum 32 bytes for WebGPU)
@@ -1229,6 +1231,36 @@ export class Engine {
1229
1231
  this.camera.aspect = this.canvas.width / this.canvas.height;
1230
1232
  this.camera.attachControl(this.canvas);
1231
1233
  }
1234
+ // Create camera bind group layout for pool (camera-only)
1235
+ createCameraBindGroupLayout() {
1236
+ return this.device.createBindGroupLayout({
1237
+ label: "camera bind group layout",
1238
+ entries: [
1239
+ {
1240
+ binding: 0,
1241
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
1242
+ buffer: {
1243
+ type: "uniform",
1244
+ },
1245
+ },
1246
+ ],
1247
+ });
1248
+ }
1249
+ // Create camera bind group for pool
1250
+ createCameraBindGroup(layout) {
1251
+ return this.device.createBindGroup({
1252
+ label: "camera bind group for pool",
1253
+ layout: layout,
1254
+ entries: [
1255
+ {
1256
+ binding: 0,
1257
+ resource: {
1258
+ buffer: this.cameraUniformBuffer,
1259
+ },
1260
+ },
1261
+ ],
1262
+ });
1263
+ }
1232
1264
  // Step 5: Create lighting buffers
1233
1265
  setupLighting() {
1234
1266
  this.lightUniformBuffer = this.device.createBuffer({
@@ -1417,6 +1449,14 @@ export class Engine {
1417
1449
  this.physics = new Physics(model.getRigidbodies(), model.getJoints());
1418
1450
  await this.setupModelBuffers(model);
1419
1451
  }
1452
+ async addPool(options) {
1453
+ if (!this.device) {
1454
+ throw new Error("Engine must be initialized before adding pool");
1455
+ }
1456
+ const cameraLayout = this.createCameraBindGroupLayout();
1457
+ this.pool = new Pool(this.device, cameraLayout, this.cameraUniformBuffer, options);
1458
+ await this.pool.init();
1459
+ }
1420
1460
  rotateBones(bones, rotations, durationMs) {
1421
1461
  this.currentModel?.rotateBones(bones, rotations, durationMs);
1422
1462
  }
@@ -1762,7 +1802,7 @@ export class Engine {
1762
1802
  }
1763
1803
  // Render strategy: 1) Opaque non-eye/hair 2) Eyes (stencil=1) 3) Hair (depth pre-pass + split by stencil) 4) Transparent 5) Bloom
1764
1804
  render() {
1765
- if (this.multisampleTexture && this.camera && this.device && this.currentModel) {
1805
+ if (this.multisampleTexture && this.camera && this.device) {
1766
1806
  const currentTime = performance.now();
1767
1807
  const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016;
1768
1808
  this.lastFrameTime = currentTime;
@@ -1779,91 +1819,108 @@ export class Engine {
1779
1819
  return;
1780
1820
  }
1781
1821
  const pass = encoder.beginRenderPass(this.renderPassDescriptor);
1782
- pass.setVertexBuffer(0, this.vertexBuffer);
1783
- pass.setVertexBuffer(1, this.jointsBuffer);
1784
- pass.setVertexBuffer(2, this.weightsBuffer);
1785
- pass.setIndexBuffer(this.indexBuffer, "uint32");
1786
1822
  this.drawCallCount = 0;
1787
- // Pass 1: Opaque
1788
- pass.setPipeline(this.modelPipeline);
1789
- for (const draw of this.opaqueDraws) {
1790
- if (draw.count > 0) {
1791
- pass.setBindGroup(0, draw.bindGroup);
1792
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1793
- this.drawCallCount++;
1794
- }
1795
- }
1796
- // Pass 2: Eyes (writes stencil value for hair to test against)
1797
- pass.setPipeline(this.eyePipeline);
1798
- pass.setStencilReference(this.STENCIL_EYE_VALUE);
1799
- for (const draw of this.eyeDraws) {
1800
- if (draw.count > 0) {
1801
- pass.setBindGroup(0, draw.bindGroup);
1802
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1803
- this.drawCallCount++;
1804
- }
1823
+ // Render pool first if no model
1824
+ if (this.pool && !this.currentModel) {
1825
+ this.pool.render(pass);
1826
+ this.drawCallCount++;
1805
1827
  }
1806
- // Pass 3: Hair rendering (depth pre-pass + shading + outlines)
1807
- this.drawOutlines(pass, false);
1808
- // 3a: Hair depth pre-pass (reduces overdraw via early depth rejection)
1809
- if (this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0) {
1810
- pass.setPipeline(this.hairDepthPipeline);
1811
- for (const draw of this.hairDrawsOverEyes) {
1828
+ if (this.currentModel) {
1829
+ pass.setVertexBuffer(0, this.vertexBuffer);
1830
+ pass.setVertexBuffer(1, this.jointsBuffer);
1831
+ pass.setVertexBuffer(2, this.weightsBuffer);
1832
+ pass.setIndexBuffer(this.indexBuffer, "uint32");
1833
+ // Pass 1: Opaque
1834
+ pass.setPipeline(this.modelPipeline);
1835
+ for (const draw of this.opaqueDraws) {
1812
1836
  if (draw.count > 0) {
1813
1837
  pass.setBindGroup(0, draw.bindGroup);
1814
1838
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1839
+ this.drawCallCount++;
1815
1840
  }
1816
1841
  }
1817
- for (const draw of this.hairDrawsOverNonEyes) {
1818
- if (draw.count > 0) {
1819
- pass.setBindGroup(0, draw.bindGroup);
1820
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1821
- }
1842
+ // Pass 1.5: Pool (water plane) - render after opaque, before eyes
1843
+ if (this.pool) {
1844
+ this.pool.render(pass, {
1845
+ vertexBuffer: this.vertexBuffer,
1846
+ jointsBuffer: this.jointsBuffer,
1847
+ weightsBuffer: this.weightsBuffer,
1848
+ indexBuffer: this.indexBuffer,
1849
+ });
1850
+ this.drawCallCount++;
1822
1851
  }
1823
- }
1824
- // 3b: Hair shading (split by stencil for transparency over eyes)
1825
- if (this.hairDrawsOverEyes.length > 0) {
1826
- pass.setPipeline(this.hairPipelineOverEyes);
1852
+ // Pass 2: Eyes (writes stencil value for hair to test against)
1853
+ pass.setPipeline(this.eyePipeline);
1827
1854
  pass.setStencilReference(this.STENCIL_EYE_VALUE);
1828
- for (const draw of this.hairDrawsOverEyes) {
1855
+ for (const draw of this.eyeDraws) {
1829
1856
  if (draw.count > 0) {
1830
1857
  pass.setBindGroup(0, draw.bindGroup);
1831
1858
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1832
1859
  this.drawCallCount++;
1833
1860
  }
1834
1861
  }
1835
- }
1836
- if (this.hairDrawsOverNonEyes.length > 0) {
1837
- pass.setPipeline(this.hairPipelineOverNonEyes);
1838
- pass.setStencilReference(this.STENCIL_EYE_VALUE);
1839
- for (const draw of this.hairDrawsOverNonEyes) {
1840
- if (draw.count > 0) {
1841
- pass.setBindGroup(0, draw.bindGroup);
1842
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1843
- this.drawCallCount++;
1862
+ // Pass 3: Hair rendering (depth pre-pass + shading + outlines)
1863
+ this.drawOutlines(pass, false);
1864
+ // 3a: Hair depth pre-pass (reduces overdraw via early depth rejection)
1865
+ if (this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0) {
1866
+ pass.setPipeline(this.hairDepthPipeline);
1867
+ for (const draw of this.hairDrawsOverEyes) {
1868
+ if (draw.count > 0) {
1869
+ pass.setBindGroup(0, draw.bindGroup);
1870
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1871
+ }
1872
+ }
1873
+ for (const draw of this.hairDrawsOverNonEyes) {
1874
+ if (draw.count > 0) {
1875
+ pass.setBindGroup(0, draw.bindGroup);
1876
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1877
+ }
1844
1878
  }
1845
1879
  }
1846
- }
1847
- // 3c: Hair outlines
1848
- if (this.hairOutlineDraws.length > 0) {
1849
- pass.setPipeline(this.hairOutlinePipeline);
1850
- for (const draw of this.hairOutlineDraws) {
1880
+ // 3b: Hair shading (split by stencil for transparency over eyes)
1881
+ if (this.hairDrawsOverEyes.length > 0) {
1882
+ pass.setPipeline(this.hairPipelineOverEyes);
1883
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1884
+ for (const draw of this.hairDrawsOverEyes) {
1885
+ if (draw.count > 0) {
1886
+ pass.setBindGroup(0, draw.bindGroup);
1887
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1888
+ this.drawCallCount++;
1889
+ }
1890
+ }
1891
+ }
1892
+ if (this.hairDrawsOverNonEyes.length > 0) {
1893
+ pass.setPipeline(this.hairPipelineOverNonEyes);
1894
+ pass.setStencilReference(this.STENCIL_EYE_VALUE);
1895
+ for (const draw of this.hairDrawsOverNonEyes) {
1896
+ if (draw.count > 0) {
1897
+ pass.setBindGroup(0, draw.bindGroup);
1898
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1899
+ this.drawCallCount++;
1900
+ }
1901
+ }
1902
+ }
1903
+ // 3c: Hair outlines
1904
+ if (this.hairOutlineDraws.length > 0) {
1905
+ pass.setPipeline(this.hairOutlinePipeline);
1906
+ for (const draw of this.hairOutlineDraws) {
1907
+ if (draw.count > 0) {
1908
+ pass.setBindGroup(0, draw.bindGroup);
1909
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1910
+ }
1911
+ }
1912
+ }
1913
+ // Pass 4: Transparent
1914
+ pass.setPipeline(this.modelPipeline);
1915
+ for (const draw of this.transparentDraws) {
1851
1916
  if (draw.count > 0) {
1852
1917
  pass.setBindGroup(0, draw.bindGroup);
1853
1918
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1919
+ this.drawCallCount++;
1854
1920
  }
1855
1921
  }
1922
+ this.drawOutlines(pass, true);
1856
1923
  }
1857
- // Pass 4: Transparent
1858
- pass.setPipeline(this.modelPipeline);
1859
- for (const draw of this.transparentDraws) {
1860
- if (draw.count > 0) {
1861
- pass.setBindGroup(0, draw.bindGroup);
1862
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1863
- this.drawCallCount++;
1864
- }
1865
- }
1866
- this.drawOutlines(pass, true);
1867
1924
  pass.end();
1868
1925
  this.device.queue.submit([encoder.finish()]);
1869
1926
  this.applyBloom();