reze-engine 0.1.15 → 0.2.0

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,7 +1,8 @@
1
1
  import { Camera } from "./camera";
2
- import { Vec3 } from "./math";
2
+ import { Quat, Vec3 } from "./math";
3
3
  import { PmxLoader } from "./pmx-loader";
4
4
  import { Physics } from "./physics";
5
+ import { VMDLoader } from "./vmd-loader";
5
6
  export class Engine {
6
7
  constructor(canvas) {
7
8
  this.cameraMatrixData = new Float32Array(36);
@@ -34,6 +35,8 @@ export class Engine {
34
35
  };
35
36
  this.animationFrameId = null;
36
37
  this.renderLoopCallback = null;
38
+ this.animationFrames = [];
39
+ this.animationTimeouts = [];
37
40
  this.opaqueNonEyeNonHairDraws = [];
38
41
  this.eyeDraws = [];
39
42
  this.hairDrawsOverEyes = [];
@@ -81,131 +84,128 @@ export class Engine {
81
84
  });
82
85
  const shaderModule = this.device.createShaderModule({
83
86
  label: "model shaders",
84
- code: /* wgsl */ `
85
- struct CameraUniforms {
86
- view: mat4x4f,
87
- projection: mat4x4f,
88
- viewPos: vec3f,
89
- _padding: f32,
90
- };
91
-
92
- struct Light {
93
- direction: vec3f,
94
- _padding1: f32,
95
- color: vec3f,
96
- intensity: f32,
97
- };
98
-
99
- struct LightUniforms {
100
- ambient: f32,
101
- lightCount: f32,
102
- _padding1: f32,
103
- _padding2: f32,
104
- lights: array<Light, 4>,
105
- };
106
-
107
- struct MaterialUniforms {
108
- alpha: f32,
109
- alphaMultiplier: f32,
110
- rimIntensity: f32,
111
- rimPower: f32,
112
- rimColor: vec3f,
113
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
114
- };
115
-
116
- struct VertexOutput {
117
- @builtin(position) position: vec4f,
118
- @location(0) normal: vec3f,
119
- @location(1) uv: vec2f,
120
- @location(2) worldPos: vec3f,
121
- };
122
-
123
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
124
- @group(0) @binding(1) var<uniform> light: LightUniforms;
125
- @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
126
- @group(0) @binding(3) var diffuseSampler: sampler;
127
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
128
- @group(0) @binding(5) var toonTexture: texture_2d<f32>;
129
- @group(0) @binding(6) var toonSampler: sampler;
130
- @group(0) @binding(7) var<uniform> material: MaterialUniforms;
131
-
132
- @vertex fn vs(
133
- @location(0) position: vec3f,
134
- @location(1) normal: vec3f,
135
- @location(2) uv: vec2f,
136
- @location(3) joints0: vec4<u32>,
137
- @location(4) weights0: vec4<f32>
138
- ) -> VertexOutput {
139
- var output: VertexOutput;
140
- let pos4 = vec4f(position, 1.0);
141
-
142
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
143
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
144
- var normalizedWeights: vec4f;
145
- if (weightSum > 0.0001) {
146
- normalizedWeights = weights0 / weightSum;
147
- } else {
148
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
149
- }
150
-
151
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
152
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
153
- for (var i = 0u; i < 4u; i++) {
154
- let j = joints0[i];
155
- let w = normalizedWeights[i];
156
- let m = skinMats[j];
157
- skinnedPos += (m * pos4) * w;
158
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
159
- skinnedNrm += (r3 * normal) * w;
160
- }
161
- let worldPos = skinnedPos.xyz;
162
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
163
- output.normal = normalize(skinnedNrm);
164
- output.uv = uv;
165
- output.worldPos = worldPos;
166
- return output;
167
- }
168
-
169
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
170
- let n = normalize(input.normal);
171
- let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
172
-
173
- var lightAccum = vec3f(light.ambient);
174
- let numLights = u32(light.lightCount);
175
- for (var i = 0u; i < numLights; i++) {
176
- let l = -light.lights[i].direction;
177
- let nDotL = max(dot(n, l), 0.0);
178
- let toonUV = vec2f(nDotL, 0.5);
179
- let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
180
- let radiance = light.lights[i].color * light.lights[i].intensity;
181
- lightAccum += toonFactor * radiance * nDotL;
182
- }
183
-
184
- // Rim light calculation
185
- let viewDir = normalize(camera.viewPos - input.worldPos);
186
- var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
187
- rimFactor = pow(rimFactor, material.rimPower);
188
- let rimLight = material.rimColor * material.rimIntensity * rimFactor;
189
-
190
- let color = albedo * lightAccum + rimLight;
191
-
192
- // Dynamic branching: adjust alpha based on whether we're over eyes
193
- // This allows single-pass hair rendering instead of two separate passes
194
- var finalAlpha = material.alpha * material.alphaMultiplier;
195
- if (material.isOverEyes > 0.5) {
196
- finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
197
- }
198
-
199
- if (finalAlpha < 0.001) {
200
- discard;
201
- }
202
-
203
- return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
204
- }
87
+ code: /* wgsl */ `
88
+ struct CameraUniforms {
89
+ view: mat4x4f,
90
+ projection: mat4x4f,
91
+ viewPos: vec3f,
92
+ _padding: f32,
93
+ };
94
+
95
+ struct Light {
96
+ direction: vec3f,
97
+ _padding1: f32,
98
+ color: vec3f,
99
+ intensity: f32,
100
+ };
101
+
102
+ struct LightUniforms {
103
+ ambient: f32,
104
+ lightCount: f32,
105
+ _padding1: f32,
106
+ _padding2: f32,
107
+ lights: array<Light, 4>,
108
+ };
109
+
110
+ struct MaterialUniforms {
111
+ alpha: f32,
112
+ alphaMultiplier: f32,
113
+ rimIntensity: f32,
114
+ rimPower: f32,
115
+ rimColor: vec3f,
116
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
117
+ };
118
+
119
+ struct VertexOutput {
120
+ @builtin(position) position: vec4f,
121
+ @location(0) normal: vec3f,
122
+ @location(1) uv: vec2f,
123
+ @location(2) worldPos: vec3f,
124
+ };
125
+
126
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
127
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
128
+ @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
129
+ @group(0) @binding(3) var diffuseSampler: sampler;
130
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
131
+ @group(0) @binding(5) var toonTexture: texture_2d<f32>;
132
+ @group(0) @binding(6) var toonSampler: sampler;
133
+ @group(0) @binding(7) var<uniform> material: MaterialUniforms;
134
+
135
+ @vertex fn vs(
136
+ @location(0) position: vec3f,
137
+ @location(1) normal: vec3f,
138
+ @location(2) uv: vec2f,
139
+ @location(3) joints0: vec4<u32>,
140
+ @location(4) weights0: vec4<f32>
141
+ ) -> VertexOutput {
142
+ var output: VertexOutput;
143
+ let pos4 = vec4f(position, 1.0);
144
+
145
+ // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
146
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
147
+ var normalizedWeights: vec4f;
148
+ if (weightSum > 0.0001) {
149
+ normalizedWeights = weights0 / weightSum;
150
+ } else {
151
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
152
+ }
153
+
154
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
155
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
156
+ for (var i = 0u; i < 4u; i++) {
157
+ let j = joints0[i];
158
+ let w = normalizedWeights[i];
159
+ let m = skinMats[j];
160
+ skinnedPos += (m * pos4) * w;
161
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
162
+ skinnedNrm += (r3 * normal) * w;
163
+ }
164
+ let worldPos = skinnedPos.xyz;
165
+ output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
166
+ output.normal = normalize(skinnedNrm);
167
+ output.uv = uv;
168
+ output.worldPos = worldPos;
169
+ return output;
170
+ }
171
+
172
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
173
+ let n = normalize(input.normal);
174
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
175
+
176
+ var lightAccum = vec3f(light.ambient);
177
+ let numLights = u32(light.lightCount);
178
+ for (var i = 0u; i < numLights; i++) {
179
+ let l = -light.lights[i].direction;
180
+ let nDotL = max(dot(n, l), 0.0);
181
+ let toonUV = vec2f(nDotL, 0.5);
182
+ let toonFactor = textureSample(toonTexture, toonSampler, toonUV).rgb;
183
+ let radiance = light.lights[i].color * light.lights[i].intensity;
184
+ lightAccum += toonFactor * radiance * nDotL;
185
+ }
186
+
187
+ // Rim light calculation
188
+ let viewDir = normalize(camera.viewPos - input.worldPos);
189
+ var rimFactor = 1.0 - max(dot(n, viewDir), 0.0);
190
+ rimFactor = pow(rimFactor, material.rimPower);
191
+ let rimLight = material.rimColor * material.rimIntensity * rimFactor;
192
+
193
+ let color = albedo * lightAccum + rimLight;
194
+
195
+ var finalAlpha = material.alpha * material.alphaMultiplier;
196
+ if (material.isOverEyes > 0.5) {
197
+ finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
198
+ }
199
+
200
+ if (finalAlpha < 0.001) {
201
+ discard;
202
+ }
203
+
204
+ return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), finalAlpha);
205
+ }
205
206
  `,
206
207
  });
207
208
  // Create explicit bind group layout for all pipelines using the main shader
208
- // This ensures compatibility across all pipelines (main, eye, hair multiply, hair opaque)
209
209
  this.hairBindGroupLayout = this.device.createBindGroupLayout({
210
210
  label: "shared material bind group layout",
211
211
  entries: [
@@ -293,79 +293,77 @@ export class Engine {
293
293
  });
294
294
  const outlineShaderModule = this.device.createShaderModule({
295
295
  label: "outline shaders",
296
- code: /* wgsl */ `
297
- struct CameraUniforms {
298
- view: mat4x4f,
299
- projection: mat4x4f,
300
- viewPos: vec3f,
301
- _padding: f32,
302
- };
303
-
304
- struct MaterialUniforms {
305
- edgeColor: vec4f,
306
- edgeSize: f32,
307
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
308
- _padding1: f32,
309
- _padding2: f32,
310
- };
311
-
312
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
313
- @group(0) @binding(1) var<uniform> material: MaterialUniforms;
314
- @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
315
-
316
- struct VertexOutput {
317
- @builtin(position) position: vec4f,
318
- };
319
-
320
- @vertex fn vs(
321
- @location(0) position: vec3f,
322
- @location(1) normal: vec3f,
323
- @location(3) joints0: vec4<u32>,
324
- @location(4) weights0: vec4<f32>
325
- ) -> VertexOutput {
326
- var output: VertexOutput;
327
- let pos4 = vec4f(position, 1.0);
328
-
329
- // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
330
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
331
- var normalizedWeights: vec4f;
332
- if (weightSum > 0.0001) {
333
- normalizedWeights = weights0 / weightSum;
334
- } else {
335
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
336
- }
337
-
338
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
339
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
340
- for (var i = 0u; i < 4u; i++) {
341
- let j = joints0[i];
342
- let w = normalizedWeights[i];
343
- let m = skinMats[j];
344
- skinnedPos += (m * pos4) * w;
345
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
346
- skinnedNrm += (r3 * normal) * w;
347
- }
348
- let worldPos = skinnedPos.xyz;
349
- let worldNormal = normalize(skinnedNrm);
350
-
351
- // MMD invert hull: expand vertices outward along normals
352
- let scaleFactor = 0.01;
353
- let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
354
- output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
355
- return output;
356
- }
357
-
358
- @fragment fn fs() -> @location(0) vec4f {
359
- var color = material.edgeColor;
360
-
361
- // Dynamic branching: adjust alpha for hair outlines over eyes
362
- // This allows single-pass outline rendering instead of two separate passes
363
- if (material.isOverEyes > 0.5) {
364
- color.a *= 0.5; // Hair outlines over eyes get 50% alpha
365
- }
366
-
367
- return color;
368
- }
296
+ code: /* wgsl */ `
297
+ struct CameraUniforms {
298
+ view: mat4x4f,
299
+ projection: mat4x4f,
300
+ viewPos: vec3f,
301
+ _padding: f32,
302
+ };
303
+
304
+ struct MaterialUniforms {
305
+ edgeColor: vec4f,
306
+ edgeSize: f32,
307
+ isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
308
+ _padding1: f32,
309
+ _padding2: f32,
310
+ };
311
+
312
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
313
+ @group(0) @binding(1) var<uniform> material: MaterialUniforms;
314
+ @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
315
+
316
+ struct VertexOutput {
317
+ @builtin(position) position: vec4f,
318
+ };
319
+
320
+ @vertex fn vs(
321
+ @location(0) position: vec3f,
322
+ @location(1) normal: vec3f,
323
+ @location(3) joints0: vec4<u32>,
324
+ @location(4) weights0: vec4<f32>
325
+ ) -> VertexOutput {
326
+ var output: VertexOutput;
327
+ let pos4 = vec4f(position, 1.0);
328
+
329
+ // Normalize weights to ensure they sum to 1.0 (handles floating-point precision issues)
330
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
331
+ var normalizedWeights: vec4f;
332
+ if (weightSum > 0.0001) {
333
+ normalizedWeights = weights0 / weightSum;
334
+ } else {
335
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
336
+ }
337
+
338
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
339
+ var skinnedNrm = vec3f(0.0, 0.0, 0.0);
340
+ for (var i = 0u; i < 4u; i++) {
341
+ let j = joints0[i];
342
+ let w = normalizedWeights[i];
343
+ let m = skinMats[j];
344
+ skinnedPos += (m * pos4) * w;
345
+ let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
346
+ skinnedNrm += (r3 * normal) * w;
347
+ }
348
+ let worldPos = skinnedPos.xyz;
349
+ let worldNormal = normalize(skinnedNrm);
350
+
351
+ // MMD invert hull: expand vertices outward along normals
352
+ let scaleFactor = 0.01;
353
+ let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
354
+ output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
355
+ return output;
356
+ }
357
+
358
+ @fragment fn fs() -> @location(0) vec4f {
359
+ var color = material.edgeColor;
360
+
361
+ if (material.isOverEyes > 0.5) {
362
+ color.a *= 0.5; // Hair outlines over eyes get 50% alpha
363
+ }
364
+
365
+ return color;
366
+ }
369
367
  `,
370
368
  });
371
369
  this.outlinePipeline = this.device.createRenderPipeline({
@@ -431,165 +429,7 @@ export class Engine {
431
429
  count: this.sampleCount,
432
430
  },
433
431
  });
434
- // Hair outline pipeline: draws hair outlines over non-eyes (stencil != 1) - Drawn after hair geometry, so depth testing ensures outlines only appear where hair exists
435
- this.hairOutlinePipeline = this.device.createRenderPipeline({
436
- label: "hair outline pipeline",
437
- layout: outlinePipelineLayout,
438
- vertex: {
439
- module: outlineShaderModule,
440
- buffers: [
441
- {
442
- arrayStride: 8 * 4,
443
- attributes: [
444
- {
445
- shaderLocation: 0,
446
- offset: 0,
447
- format: "float32x3",
448
- },
449
- {
450
- shaderLocation: 1,
451
- offset: 3 * 4,
452
- format: "float32x3",
453
- },
454
- ],
455
- },
456
- {
457
- arrayStride: 4 * 2,
458
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
459
- },
460
- {
461
- arrayStride: 4,
462
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
463
- },
464
- ],
465
- },
466
- fragment: {
467
- module: outlineShaderModule,
468
- targets: [
469
- {
470
- format: this.presentationFormat,
471
- blend: {
472
- color: {
473
- srcFactor: "src-alpha",
474
- dstFactor: "one-minus-src-alpha",
475
- operation: "add",
476
- },
477
- alpha: {
478
- srcFactor: "one",
479
- dstFactor: "one-minus-src-alpha",
480
- operation: "add",
481
- },
482
- },
483
- },
484
- ],
485
- },
486
- primitive: {
487
- cullMode: "back",
488
- },
489
- depthStencil: {
490
- format: "depth24plus-stencil8",
491
- depthWriteEnabled: false, // Don't write depth - let hair geometry control depth
492
- depthCompare: "less-equal", // Only draw where hair depth exists
493
- stencilFront: {
494
- compare: "not-equal", // Only render where stencil != 1 (not over eyes)
495
- failOp: "keep",
496
- depthFailOp: "keep",
497
- passOp: "keep",
498
- },
499
- stencilBack: {
500
- compare: "not-equal",
501
- failOp: "keep",
502
- depthFailOp: "keep",
503
- passOp: "keep",
504
- },
505
- },
506
- multisample: {
507
- count: this.sampleCount,
508
- },
509
- });
510
- // Hair outline pipeline for over eyes: draws where stencil == 1, but only where hair depth exists - Uses depth compare "equal" with a small bias to only appear where hair geometry exists
511
- this.hairOutlineOverEyesPipeline = this.device.createRenderPipeline({
512
- label: "hair outline over eyes pipeline",
513
- layout: outlinePipelineLayout,
514
- vertex: {
515
- module: outlineShaderModule,
516
- buffers: [
517
- {
518
- arrayStride: 8 * 4,
519
- attributes: [
520
- {
521
- shaderLocation: 0,
522
- offset: 0,
523
- format: "float32x3",
524
- },
525
- {
526
- shaderLocation: 1,
527
- offset: 3 * 4,
528
- format: "float32x3",
529
- },
530
- ],
531
- },
532
- {
533
- arrayStride: 4 * 2,
534
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
535
- },
536
- {
537
- arrayStride: 4,
538
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
539
- },
540
- ],
541
- },
542
- fragment: {
543
- module: outlineShaderModule,
544
- targets: [
545
- {
546
- format: this.presentationFormat,
547
- blend: {
548
- color: {
549
- srcFactor: "src-alpha",
550
- dstFactor: "one-minus-src-alpha",
551
- operation: "add",
552
- },
553
- alpha: {
554
- srcFactor: "one",
555
- dstFactor: "one-minus-src-alpha",
556
- operation: "add",
557
- },
558
- },
559
- },
560
- ],
561
- },
562
- primitive: {
563
- cullMode: "back",
564
- },
565
- depthStencil: {
566
- format: "depth24plus-stencil8",
567
- depthWriteEnabled: false, // Don't write depth
568
- depthCompare: "less-equal", // Draw where outline depth <= existing depth (hair depth)
569
- depthBias: -0.0001, // Small negative bias to bring outline slightly closer for depth test
570
- depthBiasSlopeScale: 0.0,
571
- depthBiasClamp: 0.0,
572
- stencilFront: {
573
- compare: "equal", // Only render where stencil == 1 (over eyes)
574
- failOp: "keep",
575
- depthFailOp: "keep",
576
- passOp: "keep",
577
- },
578
- stencilBack: {
579
- compare: "equal",
580
- failOp: "keep",
581
- depthFailOp: "keep",
582
- passOp: "keep",
583
- },
584
- },
585
- multisample: {
586
- count: this.sampleCount,
587
- },
588
- });
589
- // Unified hair outline pipeline: single pass without stencil testing
590
- // Uses depth test "less-equal" to draw everywhere hair exists
591
- // Shader branches on isOverEyes uniform to adjust alpha dynamically
592
- // This eliminates the need for two separate outline passes
432
+ // Unified hair outline pipeline: single pass without stencil testing, uses depth test "less-equal" to draw everywhere hair exists
593
433
  this.hairUnifiedOutlinePipeline = this.device.createRenderPipeline({
594
434
  label: "unified hair outline pipeline",
595
435
  layout: outlinePipelineLayout,
@@ -656,137 +496,6 @@ export class Engine {
656
496
  count: this.sampleCount,
657
497
  },
658
498
  });
659
- // Unified hair pipeline - can be used for both over-eyes and over-non-eyes
660
- // The difference is controlled by stencil state and alpha multiplier in material uniform
661
- this.hairMultiplyPipeline = this.device.createRenderPipeline({
662
- label: "hair pipeline (over eyes)",
663
- layout: sharedPipelineLayout,
664
- vertex: {
665
- module: shaderModule,
666
- buffers: [
667
- {
668
- arrayStride: 8 * 4,
669
- attributes: [
670
- { shaderLocation: 0, offset: 0, format: "float32x3" },
671
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
672
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
673
- ],
674
- },
675
- {
676
- arrayStride: 4 * 2,
677
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
678
- },
679
- {
680
- arrayStride: 4,
681
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
682
- },
683
- ],
684
- },
685
- fragment: {
686
- module: shaderModule,
687
- targets: [
688
- {
689
- format: this.presentationFormat,
690
- blend: {
691
- color: {
692
- srcFactor: "src-alpha",
693
- dstFactor: "one-minus-src-alpha",
694
- operation: "add",
695
- },
696
- alpha: {
697
- srcFactor: "one",
698
- dstFactor: "one-minus-src-alpha",
699
- operation: "add",
700
- },
701
- },
702
- },
703
- ],
704
- },
705
- primitive: { cullMode: "none" },
706
- depthStencil: {
707
- format: "depth24plus-stencil8",
708
- depthWriteEnabled: true, // Write depth so outlines can test against it
709
- depthCompare: "less",
710
- stencilFront: {
711
- compare: "equal", // Only render where stencil == 1
712
- failOp: "keep",
713
- depthFailOp: "keep",
714
- passOp: "keep",
715
- },
716
- stencilBack: {
717
- compare: "equal",
718
- failOp: "keep",
719
- depthFailOp: "keep",
720
- passOp: "keep",
721
- },
722
- },
723
- multisample: { count: this.sampleCount },
724
- });
725
- // Hair pipeline for opaque rendering (hair over non-eyes) - uses same shader, different stencil state
726
- this.hairOpaquePipeline = this.device.createRenderPipeline({
727
- label: "hair pipeline (over non-eyes)",
728
- layout: sharedPipelineLayout,
729
- vertex: {
730
- module: shaderModule,
731
- buffers: [
732
- {
733
- arrayStride: 8 * 4,
734
- attributes: [
735
- { shaderLocation: 0, offset: 0, format: "float32x3" },
736
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
737
- { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
738
- ],
739
- },
740
- {
741
- arrayStride: 4 * 2,
742
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
743
- },
744
- {
745
- arrayStride: 4,
746
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
747
- },
748
- ],
749
- },
750
- fragment: {
751
- module: shaderModule,
752
- targets: [
753
- {
754
- format: this.presentationFormat,
755
- blend: {
756
- color: {
757
- srcFactor: "src-alpha",
758
- dstFactor: "one-minus-src-alpha",
759
- operation: "add",
760
- },
761
- alpha: {
762
- srcFactor: "one",
763
- dstFactor: "one-minus-src-alpha",
764
- operation: "add",
765
- },
766
- },
767
- },
768
- ],
769
- },
770
- primitive: { cullMode: "none" },
771
- depthStencil: {
772
- format: "depth24plus-stencil8",
773
- depthWriteEnabled: true,
774
- depthCompare: "less",
775
- stencilFront: {
776
- compare: "not-equal", // Only render where stencil != 1
777
- failOp: "keep",
778
- depthFailOp: "keep",
779
- passOp: "keep",
780
- },
781
- stencilBack: {
782
- compare: "not-equal",
783
- failOp: "keep",
784
- depthFailOp: "keep",
785
- passOp: "keep",
786
- },
787
- },
788
- multisample: { count: this.sampleCount },
789
- });
790
499
  // Eye overlay pipeline (renders after opaque, writes stencil)
791
500
  this.eyePipeline = this.device.createRenderPipeline({
792
501
  label: "eye overlay pipeline",
@@ -855,57 +564,52 @@ export class Engine {
855
564
  // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
856
565
  const depthOnlyShaderModule = this.device.createShaderModule({
857
566
  label: "depth only shader",
858
- code: /* wgsl */ `
859
- struct CameraUniforms {
860
- view: mat4x4f,
861
- projection: mat4x4f,
862
- viewPos: vec3f,
863
- _padding: f32,
864
- };
865
-
866
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
867
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
868
-
869
- @vertex fn vs(
870
- @location(0) position: vec3f,
871
- @location(1) normal: vec3f,
872
- @location(3) joints0: vec4<u32>,
873
- @location(4) weights0: vec4<f32>
874
- ) -> @builtin(position) vec4f {
875
- let pos4 = vec4f(position, 1.0);
876
-
877
- // Normalize weights
878
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
879
- var normalizedWeights: vec4f;
880
- if (weightSum > 0.0001) {
881
- normalizedWeights = weights0 / weightSum;
882
- } else {
883
- normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
884
- }
885
-
886
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
887
- for (var i = 0u; i < 4u; i++) {
888
- let j = joints0[i];
889
- let w = normalizedWeights[i];
890
- let m = skinMats[j];
891
- skinnedPos += (m * pos4) * w;
892
- }
893
- let worldPos = skinnedPos.xyz;
894
- let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
895
- return clipPos;
896
- }
897
-
898
- // Minimal fragment shader - returns transparent, no color writes (writeMask: 0)
899
- // Required because render pass has color attachments
900
- // Depth is still written even though we don't write color
901
- @fragment fn fs() -> @location(0) vec4f {
902
- return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
903
- }
567
+ code: /* wgsl */ `
568
+ struct CameraUniforms {
569
+ view: mat4x4f,
570
+ projection: mat4x4f,
571
+ viewPos: vec3f,
572
+ _padding: f32,
573
+ };
574
+
575
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
576
+ @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
577
+
578
+ @vertex fn vs(
579
+ @location(0) position: vec3f,
580
+ @location(1) normal: vec3f,
581
+ @location(3) joints0: vec4<u32>,
582
+ @location(4) weights0: vec4<f32>
583
+ ) -> @builtin(position) vec4f {
584
+ let pos4 = vec4f(position, 1.0);
585
+
586
+ // Normalize weights
587
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
588
+ var normalizedWeights: vec4f;
589
+ if (weightSum > 0.0001) {
590
+ normalizedWeights = weights0 / weightSum;
591
+ } else {
592
+ normalizedWeights = vec4f(1.0, 0.0, 0.0, 0.0);
593
+ }
594
+
595
+ var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
596
+ for (var i = 0u; i < 4u; i++) {
597
+ let j = joints0[i];
598
+ let w = normalizedWeights[i];
599
+ let m = skinMats[j];
600
+ skinnedPos += (m * pos4) * w;
601
+ }
602
+ let worldPos = skinnedPos.xyz;
603
+ let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
604
+ return clipPos;
605
+ }
606
+
607
+ @fragment fn fs() -> @location(0) vec4f {
608
+ return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
609
+ }
904
610
  `,
905
611
  });
906
- // Hair depth pre-pass pipeline (depth-only, no color writes)
907
- // This eliminates most overdraw by rejecting fragments early before expensive shading
908
- // Note: Must have a color target to match render pass, but we disable all color writes
612
+ // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
909
613
  this.hairDepthPipeline = this.device.createRenderPipeline({
910
614
  label: "hair depth pre-pass",
911
615
  layout: sharedPipelineLayout,
@@ -947,11 +651,7 @@ export class Engine {
947
651
  },
948
652
  multisample: { count: this.sampleCount },
949
653
  });
950
- // Unified hair pipeline: single pass with dynamic branching in shader
951
- // Uses stencil testing to filter fragments, then shader branches on isOverEyes uniform
952
- // This eliminates the need for separate pipelines - same shader, different stencil states
953
- // We create two variants: one for over-eyes (stencil == 1) and one for over-non-eyes (stencil != 1)
954
- // Unified pipeline for hair over eyes (stencil == 1)
654
+ // Unified hair pipeline for over-eyes (stencil == 1): single pass with dynamic branching
955
655
  this.hairUnifiedPipelineOverEyes = this.device.createRenderPipeline({
956
656
  label: "unified hair pipeline (over eyes)",
957
657
  layout: sharedPipelineLayout,
@@ -1086,31 +786,31 @@ export class Engine {
1086
786
  createSkinMatrixComputePipeline() {
1087
787
  const computeShader = this.device.createShaderModule({
1088
788
  label: "skin matrix compute",
1089
- code: /* wgsl */ `
1090
- struct BoneCountUniform {
1091
- count: u32,
1092
- _padding1: u32,
1093
- _padding2: u32,
1094
- _padding3: u32,
1095
- _padding4: vec4<u32>,
1096
- };
1097
-
1098
- @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
1099
- @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
1100
- @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
1101
- @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
1102
-
1103
- @compute @workgroup_size(64)
1104
- fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
1105
- let boneIndex = globalId.x;
1106
- // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
1107
- if (boneIndex >= boneCount.count) {
1108
- return;
1109
- }
1110
- let worldMat = worldMatrices[boneIndex];
1111
- let invBindMat = inverseBindMatrices[boneIndex];
1112
- skinMatrices[boneIndex] = worldMat * invBindMat;
1113
- }
789
+ code: /* wgsl */ `
790
+ struct BoneCountUniform {
791
+ count: u32,
792
+ _padding1: u32,
793
+ _padding2: u32,
794
+ _padding3: u32,
795
+ _padding4: vec4<u32>,
796
+ };
797
+
798
+ @group(0) @binding(0) var<uniform> boneCount: BoneCountUniform;
799
+ @group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
800
+ @group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
801
+ @group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
802
+
803
+ @compute @workgroup_size(64)
804
+ fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
805
+ let boneIndex = globalId.x;
806
+ // Bounds check: we dispatch workgroups (64 threads each), so some threads may be out of range
807
+ if (boneIndex >= boneCount.count) {
808
+ return;
809
+ }
810
+ let worldMat = worldMatrices[boneIndex];
811
+ let invBindMat = inverseBindMatrices[boneIndex];
812
+ skinMatrices[boneIndex] = worldMat * invBindMat;
813
+ }
1114
814
  `,
1115
815
  });
1116
816
  this.skinMatrixComputePipeline = this.device.createComputePipeline({
@@ -1164,143 +864,143 @@ export class Engine {
1164
864
  // Bloom extraction shader (extracts bright areas)
1165
865
  const bloomExtractShader = this.device.createShaderModule({
1166
866
  label: "bloom extract",
1167
- code: /* wgsl */ `
1168
- struct VertexOutput {
1169
- @builtin(position) position: vec4f,
1170
- @location(0) uv: vec2f,
1171
- };
1172
-
1173
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
1174
- var output: VertexOutput;
1175
- // Generate fullscreen quad from vertex index
1176
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
1177
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
1178
- output.position = vec4f(x, y, 0.0, 1.0);
1179
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
1180
- return output;
1181
- }
1182
-
1183
- struct BloomExtractUniforms {
1184
- threshold: f32,
1185
- _padding1: f32,
1186
- _padding2: f32,
1187
- _padding3: f32,
1188
- _padding4: f32,
1189
- _padding5: f32,
1190
- _padding6: f32,
1191
- _padding7: f32,
1192
- };
1193
-
1194
- @group(0) @binding(0) var inputTexture: texture_2d<f32>;
1195
- @group(0) @binding(1) var inputSampler: sampler;
1196
- @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
1197
-
1198
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1199
- let color = textureSample(inputTexture, inputSampler, input.uv);
1200
- // Extract bright areas above threshold
1201
- let threshold = extractUniforms.threshold;
1202
- let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
1203
- return vec4f(bloom, color.a);
1204
- }
867
+ code: /* wgsl */ `
868
+ struct VertexOutput {
869
+ @builtin(position) position: vec4f,
870
+ @location(0) uv: vec2f,
871
+ };
872
+
873
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
874
+ var output: VertexOutput;
875
+ // Generate fullscreen quad from vertex index
876
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
877
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
878
+ output.position = vec4f(x, y, 0.0, 1.0);
879
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
880
+ return output;
881
+ }
882
+
883
+ struct BloomExtractUniforms {
884
+ threshold: f32,
885
+ _padding1: f32,
886
+ _padding2: f32,
887
+ _padding3: f32,
888
+ _padding4: f32,
889
+ _padding5: f32,
890
+ _padding6: f32,
891
+ _padding7: f32,
892
+ };
893
+
894
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
895
+ @group(0) @binding(1) var inputSampler: sampler;
896
+ @group(0) @binding(2) var<uniform> extractUniforms: BloomExtractUniforms;
897
+
898
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
899
+ let color = textureSample(inputTexture, inputSampler, input.uv);
900
+ // Extract bright areas above threshold
901
+ let threshold = extractUniforms.threshold;
902
+ let bloom = max(vec3f(0.0), color.rgb - vec3f(threshold)) / max(0.001, 1.0 - threshold);
903
+ return vec4f(bloom, color.a);
904
+ }
1205
905
  `,
1206
906
  });
1207
907
  // Bloom blur shader (gaussian blur - can be used for both horizontal and vertical)
1208
908
  const bloomBlurShader = this.device.createShaderModule({
1209
909
  label: "bloom blur",
1210
- code: /* wgsl */ `
1211
- struct VertexOutput {
1212
- @builtin(position) position: vec4f,
1213
- @location(0) uv: vec2f,
1214
- };
1215
-
1216
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
1217
- var output: VertexOutput;
1218
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
1219
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
1220
- output.position = vec4f(x, y, 0.0, 1.0);
1221
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
1222
- return output;
1223
- }
1224
-
1225
- struct BlurUniforms {
1226
- direction: vec2f,
1227
- _padding1: f32,
1228
- _padding2: f32,
1229
- _padding3: f32,
1230
- _padding4: f32,
1231
- _padding5: f32,
1232
- _padding6: f32,
1233
- };
1234
-
1235
- @group(0) @binding(0) var inputTexture: texture_2d<f32>;
1236
- @group(0) @binding(1) var inputSampler: sampler;
1237
- @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
1238
-
1239
- // 9-tap gaussian blur
1240
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1241
- let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
1242
- var result = vec4f(0.0);
1243
-
1244
- // Gaussian weights for 9-tap filter
1245
- let weights = array<f32, 9>(
1246
- 0.01621622, 0.05405405, 0.12162162,
1247
- 0.19459459, 0.22702703,
1248
- 0.19459459, 0.12162162, 0.05405405, 0.01621622
1249
- );
1250
-
1251
- let offsets = array<f32, 9>(-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0);
1252
-
1253
- for (var i = 0u; i < 9u; i++) {
1254
- let offset = offsets[i] * texelSize * blurUniforms.direction;
1255
- result += textureSample(inputTexture, inputSampler, input.uv + offset) * weights[i];
1256
- }
1257
-
1258
- return result;
1259
- }
910
+ code: /* wgsl */ `
911
+ struct VertexOutput {
912
+ @builtin(position) position: vec4f,
913
+ @location(0) uv: vec2f,
914
+ };
915
+
916
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
917
+ var output: VertexOutput;
918
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
919
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
920
+ output.position = vec4f(x, y, 0.0, 1.0);
921
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
922
+ return output;
923
+ }
924
+
925
+ struct BlurUniforms {
926
+ direction: vec2f,
927
+ _padding1: f32,
928
+ _padding2: f32,
929
+ _padding3: f32,
930
+ _padding4: f32,
931
+ _padding5: f32,
932
+ _padding6: f32,
933
+ };
934
+
935
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
936
+ @group(0) @binding(1) var inputSampler: sampler;
937
+ @group(0) @binding(2) var<uniform> blurUniforms: BlurUniforms;
938
+
939
+ // 9-tap gaussian blur
940
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
941
+ let texelSize = 1.0 / vec2f(textureDimensions(inputTexture));
942
+ var result = vec4f(0.0);
943
+
944
+ // Gaussian weights for 9-tap filter
945
+ let weights = array<f32, 9>(
946
+ 0.01621622, 0.05405405, 0.12162162,
947
+ 0.19459459, 0.22702703,
948
+ 0.19459459, 0.12162162, 0.05405405, 0.01621622
949
+ );
950
+
951
+ let offsets = array<f32, 9>(-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0);
952
+
953
+ for (var i = 0u; i < 9u; i++) {
954
+ let offset = offsets[i] * texelSize * blurUniforms.direction;
955
+ result += textureSample(inputTexture, inputSampler, input.uv + offset) * weights[i];
956
+ }
957
+
958
+ return result;
959
+ }
1260
960
  `,
1261
961
  });
1262
962
  // Bloom composition shader (combines original scene with bloom)
1263
963
  const bloomComposeShader = this.device.createShaderModule({
1264
964
  label: "bloom compose",
1265
- code: /* wgsl */ `
1266
- struct VertexOutput {
1267
- @builtin(position) position: vec4f,
1268
- @location(0) uv: vec2f,
1269
- };
1270
-
1271
- @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
1272
- var output: VertexOutput;
1273
- let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
1274
- let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
1275
- output.position = vec4f(x, y, 0.0, 1.0);
1276
- output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
1277
- return output;
1278
- }
1279
-
1280
- struct BloomComposeUniforms {
1281
- intensity: f32,
1282
- _padding1: f32,
1283
- _padding2: f32,
1284
- _padding3: f32,
1285
- _padding4: f32,
1286
- _padding5: f32,
1287
- _padding6: f32,
1288
- _padding7: f32,
1289
- };
1290
-
1291
- @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
1292
- @group(0) @binding(1) var sceneSampler: sampler;
1293
- @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
1294
- @group(0) @binding(3) var bloomSampler: sampler;
1295
- @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
1296
-
1297
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
1298
- let scene = textureSample(sceneTexture, sceneSampler, input.uv);
1299
- let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
1300
- // Additive blending with intensity control
1301
- let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
1302
- return vec4f(result, scene.a);
1303
- }
965
+ code: /* wgsl */ `
966
+ struct VertexOutput {
967
+ @builtin(position) position: vec4f,
968
+ @location(0) uv: vec2f,
969
+ };
970
+
971
+ @vertex fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
972
+ var output: VertexOutput;
973
+ let x = f32((vertexIndex << 1u) & 2u) * 2.0 - 1.0;
974
+ let y = f32(vertexIndex & 2u) * 2.0 - 1.0;
975
+ output.position = vec4f(x, y, 0.0, 1.0);
976
+ output.uv = vec2f(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
977
+ return output;
978
+ }
979
+
980
+ struct BloomComposeUniforms {
981
+ intensity: f32,
982
+ _padding1: f32,
983
+ _padding2: f32,
984
+ _padding3: f32,
985
+ _padding4: f32,
986
+ _padding5: f32,
987
+ _padding6: f32,
988
+ _padding7: f32,
989
+ };
990
+
991
+ @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
992
+ @group(0) @binding(1) var sceneSampler: sampler;
993
+ @group(0) @binding(2) var bloomTexture: texture_2d<f32>;
994
+ @group(0) @binding(3) var bloomSampler: sampler;
995
+ @group(0) @binding(4) var<uniform> composeUniforms: BloomComposeUniforms;
996
+
997
+ @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
998
+ let scene = textureSample(sceneTexture, sceneSampler, input.uv);
999
+ let bloom = textureSample(bloomTexture, bloomSampler, input.uv);
1000
+ // Additive blending with intensity control
1001
+ let result = scene.rgb + bloom.rgb * composeUniforms.intensity;
1002
+ return vec4f(result, scene.a);
1003
+ }
1304
1004
  `,
1305
1005
  });
1306
1006
  // Create uniform buffer for blur direction (minimum 32 bytes for WebGPU)
@@ -1508,9 +1208,9 @@ export class Engine {
1508
1208
  depthClearValue: 1.0,
1509
1209
  depthLoadOp: "clear",
1510
1210
  depthStoreOp: "store",
1511
- stencilClearValue: 0, // New: clear stencil to 0
1512
- stencilLoadOp: "clear", // New: clear stencil each frame
1513
- stencilStoreOp: "store", // New: store stencil
1211
+ stencilClearValue: 0,
1212
+ stencilLoadOp: "clear",
1213
+ stencilStoreOp: "discard", // Discard stencil after frame to save bandwidth (we only use it during rendering)
1514
1214
  },
1515
1215
  };
1516
1216
  this.camera.aspect = width / height;
@@ -1561,6 +1261,107 @@ export class Engine {
1561
1261
  setAmbient(intensity) {
1562
1262
  this.lightData[0] = intensity;
1563
1263
  }
1264
+ async loadAnimation(url) {
1265
+ const frames = await VMDLoader.load(url);
1266
+ this.animationFrames = frames;
1267
+ console.log(this.animationFrames);
1268
+ }
1269
+ playAnimation() {
1270
+ if (this.animationFrames.length === 0)
1271
+ return;
1272
+ this.stopAnimation();
1273
+ const allBoneKeyFrames = [];
1274
+ for (const keyFrame of this.animationFrames) {
1275
+ for (const boneFrame of keyFrame.boneFrames) {
1276
+ allBoneKeyFrames.push({
1277
+ boneName: boneFrame.boneName,
1278
+ time: keyFrame.time,
1279
+ rotation: boneFrame.rotation,
1280
+ });
1281
+ }
1282
+ }
1283
+ const boneKeyFramesByBone = new Map();
1284
+ for (const boneKeyFrame of allBoneKeyFrames) {
1285
+ if (!boneKeyFramesByBone.has(boneKeyFrame.boneName)) {
1286
+ boneKeyFramesByBone.set(boneKeyFrame.boneName, []);
1287
+ }
1288
+ boneKeyFramesByBone.get(boneKeyFrame.boneName).push(boneKeyFrame);
1289
+ }
1290
+ for (const keyFrames of boneKeyFramesByBone.values()) {
1291
+ keyFrames.sort((a, b) => a.time - b.time);
1292
+ }
1293
+ const time0Rotations = [];
1294
+ const bonesWithTime0 = new Set();
1295
+ for (const [boneName, keyFrames] of boneKeyFramesByBone.entries()) {
1296
+ if (keyFrames.length > 0 && keyFrames[0].time === 0) {
1297
+ time0Rotations.push({
1298
+ boneName: boneName,
1299
+ rotation: keyFrames[0].rotation,
1300
+ });
1301
+ bonesWithTime0.add(boneName);
1302
+ }
1303
+ }
1304
+ if (this.currentModel) {
1305
+ if (time0Rotations.length > 0) {
1306
+ const boneNames = time0Rotations.map((r) => r.boneName);
1307
+ const rotations = time0Rotations.map((r) => r.rotation);
1308
+ this.rotateBones(boneNames, rotations, 0);
1309
+ }
1310
+ const skeleton = this.currentModel.getSkeleton();
1311
+ const bonesToReset = [];
1312
+ for (const bone of skeleton.bones) {
1313
+ if (!bonesWithTime0.has(bone.name)) {
1314
+ bonesToReset.push(bone.name);
1315
+ }
1316
+ }
1317
+ if (bonesToReset.length > 0) {
1318
+ const identityQuat = new Quat(0, 0, 0, 1);
1319
+ const identityQuats = new Array(bonesToReset.length).fill(identityQuat);
1320
+ this.rotateBones(bonesToReset, identityQuats, 0);
1321
+ }
1322
+ this.currentModel.evaluatePose();
1323
+ // Reset physics immediately and upload matrices to prevent A-pose flash
1324
+ if (this.physics) {
1325
+ const worldMats = this.currentModel.getBoneWorldMatrices();
1326
+ this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices());
1327
+ // Upload matrices immediately so next frame shows correct pose
1328
+ this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
1329
+ this.computeSkinMatrices();
1330
+ }
1331
+ }
1332
+ for (const [_, keyFrames] of boneKeyFramesByBone.entries()) {
1333
+ for (let i = 0; i < keyFrames.length; i++) {
1334
+ const boneKeyFrame = keyFrames[i];
1335
+ const previousBoneKeyFrame = i > 0 ? keyFrames[i - 1] : null;
1336
+ if (boneKeyFrame.time === 0)
1337
+ continue;
1338
+ let durationMs = 0;
1339
+ if (i === 0) {
1340
+ durationMs = boneKeyFrame.time * 1000;
1341
+ }
1342
+ else if (previousBoneKeyFrame) {
1343
+ durationMs = (boneKeyFrame.time - previousBoneKeyFrame.time) * 1000;
1344
+ }
1345
+ const scheduleTime = i > 0 && previousBoneKeyFrame ? previousBoneKeyFrame.time : 0;
1346
+ const delayMs = scheduleTime * 1000;
1347
+ if (delayMs <= 0) {
1348
+ this.rotateBones([boneKeyFrame.boneName], [boneKeyFrame.rotation], durationMs);
1349
+ }
1350
+ else {
1351
+ const timeoutId = window.setTimeout(() => {
1352
+ this.rotateBones([boneKeyFrame.boneName], [boneKeyFrame.rotation], durationMs);
1353
+ }, delayMs);
1354
+ this.animationTimeouts.push(timeoutId);
1355
+ }
1356
+ }
1357
+ }
1358
+ }
1359
+ stopAnimation() {
1360
+ for (const timeoutId of this.animationTimeouts) {
1361
+ clearTimeout(timeoutId);
1362
+ }
1363
+ this.animationTimeouts = [];
1364
+ }
1564
1365
  getStats() {
1565
1366
  return { ...this.stats };
1566
1367
  }
@@ -1584,6 +1385,7 @@ export class Engine {
1584
1385
  }
1585
1386
  dispose() {
1586
1387
  this.stopRenderLoop();
1388
+ this.stopAnimation();
1587
1389
  if (this.camera)
1588
1390
  this.camera.detachControl();
1589
1391
  if (this.resizeObserver) {
@@ -1758,7 +1560,7 @@ export class Engine {
1758
1560
  materialUniformData[4] = this.rimLightColor[0]; // rimColor.r
1759
1561
  materialUniformData[5] = this.rimLightColor[1]; // rimColor.g
1760
1562
  materialUniformData[6] = this.rimLightColor[2]; // rimColor.b
1761
- materialUniformData[7] = 0.0; // isOverEyes: 0.0 for non-hair materials
1563
+ materialUniformData[7] = 0.0;
1762
1564
  const materialUniformBuffer = this.device.createBuffer({
1763
1565
  label: `material uniform: ${mat.name}`,
1764
1566
  size: materialUniformData.byteLength,
@@ -1790,9 +1592,7 @@ export class Engine {
1790
1592
  });
1791
1593
  }
1792
1594
  else if (mat.isHair) {
1793
- // For hair materials, create a single bind group that will be used with the unified pipeline
1794
- // The shader will dynamically branch based on isOverEyes uniform
1795
- // We still need two uniform buffers (one for each render mode) but can reuse the same bind group structure
1595
+ // Hair materials: create bind groups for unified pipeline with dynamic branching
1796
1596
  const materialUniformDataHair = new Float32Array(8);
1797
1597
  materialUniformDataHair[0] = materialAlpha;
1798
1598
  materialUniformDataHair[1] = 1.0; // alphaMultiplier: base value, shader will adjust
@@ -1801,15 +1601,15 @@ export class Engine {
1801
1601
  materialUniformDataHair[4] = this.rimLightColor[0]; // rimColor.r
1802
1602
  materialUniformDataHair[5] = this.rimLightColor[1]; // rimColor.g
1803
1603
  materialUniformDataHair[6] = this.rimLightColor[2]; // rimColor.b
1804
- materialUniformDataHair[7] = 0.0; // isOverEyes: will be set per draw call
1805
- // Create uniform buffers for both modes (we'll update them per frame)
1604
+ materialUniformDataHair[7] = 0.0;
1605
+ // Create uniform buffers for both modes
1806
1606
  const materialUniformBufferOverEyes = this.device.createBuffer({
1807
1607
  label: `material uniform (over eyes): ${mat.name}`,
1808
1608
  size: materialUniformDataHair.byteLength,
1809
1609
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1810
1610
  });
1811
1611
  const materialUniformDataOverEyes = new Float32Array(materialUniformDataHair);
1812
- materialUniformDataOverEyes[7] = 1.0; // isOverEyes = 1.0
1612
+ materialUniformDataOverEyes[7] = 1.0;
1813
1613
  this.device.queue.writeBuffer(materialUniformBufferOverEyes, 0, materialUniformDataOverEyes);
1814
1614
  const materialUniformBufferOverNonEyes = this.device.createBuffer({
1815
1615
  label: `material uniform (over non-eyes): ${mat.name}`,
@@ -1817,9 +1617,9 @@ export class Engine {
1817
1617
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1818
1618
  });
1819
1619
  const materialUniformDataOverNonEyes = new Float32Array(materialUniformDataHair);
1820
- materialUniformDataOverNonEyes[7] = 0.0; // isOverEyes = 0.0
1620
+ materialUniformDataOverNonEyes[7] = 0.0;
1821
1621
  this.device.queue.writeBuffer(materialUniformBufferOverNonEyes, 0, materialUniformDataOverNonEyes);
1822
- // Create bind groups for both modes (they share everything except the uniform buffer)
1622
+ // Create bind groups for both modes
1823
1623
  const bindGroupOverEyes = this.device.createBindGroup({
1824
1624
  label: `material bind group (over eyes): ${mat.name}`,
1825
1625
  layout: this.hairBindGroupLayout,
@@ -1848,7 +1648,7 @@ export class Engine {
1848
1648
  { binding: 7, resource: { buffer: materialUniformBufferOverNonEyes } },
1849
1649
  ],
1850
1650
  });
1851
- // Store both bind groups - we'll use them with the unified pipeline
1651
+ // Store both bind groups for unified pipeline
1852
1652
  this.hairDrawsOverEyes.push({
1853
1653
  count: matCount,
1854
1654
  firstIndex: runningFirstIndex,
@@ -1886,7 +1686,7 @@ export class Engine {
1886
1686
  materialUniformData[2] = mat.edgeColor[2]; // edgeColor.b
1887
1687
  materialUniformData[3] = mat.edgeColor[3]; // edgeColor.a
1888
1688
  materialUniformData[4] = mat.edgeSize;
1889
- materialUniformData[5] = mat.isHair ? 0.0 : 0.0; // isOverEyes: 0.0 for all (unified pipeline doesn't use stencil)
1689
+ materialUniformData[5] = 0.0; // isOverEyes: 0.0 for all (unified pipeline doesn't use stencil)
1890
1690
  materialUniformData[6] = 0.0; // _padding1
1891
1691
  materialUniformData[7] = 0.0; // _padding2
1892
1692
  const materialUniformBuffer = this.device.createBuffer({
@@ -2002,8 +1802,7 @@ export class Engine {
2002
1802
  pass.setVertexBuffer(2, this.weightsBuffer);
2003
1803
  pass.setIndexBuffer(this.indexBuffer, "uint32");
2004
1804
  this.drawCallCount = 0;
2005
- // PASS 1: Opaque non-eye, non-hair (face, body, etc)
2006
- // this.drawOutlines(pass, false) // Opaque outlines
1805
+ // PASS 1: Opaque non-eye, non-hair
2007
1806
  pass.setPipeline(this.pipeline);
2008
1807
  for (const draw of this.opaqueNonEyeNonHairDraws) {
2009
1808
  if (draw.count > 0) {
@@ -2022,18 +1821,13 @@ export class Engine {
2022
1821
  this.drawCallCount++;
2023
1822
  }
2024
1823
  }
2025
- // PASS 3: Hair rendering - optimized with depth pre-pass and unified pipeline
2026
- // Depth pre-pass: render hair depth-only to eliminate overdraw early
2027
- // Then render shaded hair once with depth test "equal" to only shade visible fragments
2028
- this.drawOutlines(pass, false); // Opaque outlines
2029
- // 3a: Hair depth pre-pass (depth-only, no color writes)
2030
- // This eliminates most overdraw by rejecting fragments early before expensive shading
1824
+ // PASS 3: Hair rendering with depth pre-pass and unified pipeline
1825
+ this.drawOutlines(pass, false);
1826
+ // 3a: Hair depth pre-pass (eliminates overdraw by rejecting fragments early)
2031
1827
  if (this.hairDrawsOverEyes.length > 0 || this.hairDrawsOverNonEyes.length > 0) {
2032
1828
  pass.setPipeline(this.hairDepthPipeline);
2033
- // Render all hair materials for depth (no stencil test needed for depth pass)
2034
1829
  for (const draw of this.hairDrawsOverEyes) {
2035
1830
  if (draw.count > 0) {
2036
- // Use the same bind group structure (camera, skin matrices) for depth pass
2037
1831
  pass.setBindGroup(0, draw.bindGroup);
2038
1832
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
2039
1833
  }
@@ -2045,10 +1839,7 @@ export class Engine {
2045
1839
  }
2046
1840
  }
2047
1841
  }
2048
- // 3b: Hair shading pass - unified pipeline with dynamic branching
2049
- // Uses depth test "equal" to only render where depth was written in pre-pass
2050
- // Shader branches on isOverEyes uniform to adjust alpha dynamically
2051
- // This eliminates one full geometry pass compared to the old approach
1842
+ // 3b: Hair shading pass with unified pipeline and dynamic branching
2052
1843
  if (this.hairDrawsOverEyes.length > 0) {
2053
1844
  pass.setPipeline(this.hairUnifiedPipelineOverEyes);
2054
1845
  pass.setStencilReference(1);
@@ -2072,9 +1863,6 @@ export class Engine {
2072
1863
  }
2073
1864
  }
2074
1865
  // 3c: Hair outlines - unified single pass without stencil testing
2075
- // Uses depth test "less-equal" to draw everywhere hair exists
2076
- // Shader branches on isOverEyes uniform to adjust alpha dynamically (currently always 0.0)
2077
- // This eliminates the need for two separate outline passes
2078
1866
  if (this.hairOutlineDraws.length > 0) {
2079
1867
  pass.setPipeline(this.hairUnifiedOutlinePipeline);
2080
1868
  for (const draw of this.hairOutlineDraws) {
@@ -2093,7 +1881,7 @@ export class Engine {
2093
1881
  this.drawCallCount++;
2094
1882
  }
2095
1883
  }
2096
- this.drawOutlines(pass, true); // Transparent outlines
1884
+ this.drawOutlines(pass, true);
2097
1885
  pass.end();
2098
1886
  this.device.queue.submit([encoder.finish()]);
2099
1887
  // Apply bloom post-processing
@@ -2216,19 +2004,13 @@ export class Engine {
2216
2004
  colorAttachment.view = this.sceneRenderTextureView;
2217
2005
  }
2218
2006
  }
2219
- // Update model pose and physics
2220
2007
  updateModelPose(deltaTime) {
2221
- // Step 1: Animation evaluation (computes matrices to CPU memory, no upload yet)
2222
2008
  this.currentModel.evaluatePose();
2223
- // Step 2: Get world matrices (still in CPU memory)
2224
2009
  const worldMats = this.currentModel.getBoneWorldMatrices();
2225
- // Step 3: Physics modifies matrices in-place
2226
2010
  if (this.physics) {
2227
2011
  this.physics.step(deltaTime, worldMats, this.currentModel.getBoneInverseBindMatrices());
2228
2012
  }
2229
- // Step 4: Upload ONCE with final result (animation + physics)
2230
2013
  this.device.queue.writeBuffer(this.worldMatrixBuffer, 0, worldMats.buffer, worldMats.byteOffset, worldMats.byteLength);
2231
- // Step 5: GPU skinning
2232
2014
  this.computeSkinMatrices();
2233
2015
  }
2234
2016
  // Compute skin matrices on GPU