reze-engine 0.8.4 → 0.9.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
@@ -24,17 +24,15 @@ export class Engine {
24
24
  this.lightData = new Float32Array(64);
25
25
  this.lightCount = 0;
26
26
  this.resizeObserver = null;
27
- // Post-alpha eye: eyes write stencil, hair-over-eyes reads it for see-through bangs (MMD-style).
28
- this.STENCIL_EYE_VALUE = 1;
29
27
  this.hasGround = false;
30
28
  this.shadowLightVPMatrix = new Float32Array(16);
31
29
  this.groundDrawCall = null;
32
30
  this.shadowVPLightX = Number.NaN;
33
31
  this.shadowVPLightY = Number.NaN;
34
32
  this.shadowVPLightZ = Number.NaN;
35
- // Double-tap detection
36
33
  this.lastTouchTime = 0;
37
- this.DOUBLE_TAP_DELAY = 300; // ms
34
+ this.DOUBLE_TAP_DELAY = 300;
35
+ this.pendingPick = null;
38
36
  this.modelInstances = new Map();
39
37
  this.textureCache = new Map();
40
38
  this._nextDefaultModelId = 0;
@@ -188,23 +186,6 @@ export class Engine {
188
186
  attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
189
187
  },
190
188
  ];
191
- const depthOnlyVertexBuffers = [
192
- {
193
- arrayStride: 8 * 4,
194
- attributes: [
195
- { shaderLocation: 0, offset: 0, format: "float32x3" },
196
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
197
- ],
198
- },
199
- {
200
- arrayStride: 4 * 2,
201
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
202
- },
203
- {
204
- arrayStride: 4,
205
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
206
- },
207
- ];
208
189
  const standardBlend = {
209
190
  format: this.presentationFormat,
210
191
  blend: {
@@ -242,17 +223,17 @@ export class Engine {
242
223
 
243
224
  struct MaterialUniforms {
244
225
  alpha: f32,
245
- alphaMultiplier: f32,
246
226
  rimIntensity: f32,
247
227
  shininess: f32,
228
+ _padding1: f32,
248
229
  rimColor: vec3f,
249
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
250
- diffuseColor: vec3f,
251
230
  _padding2: f32,
252
- ambientColor: vec3f,
231
+ diffuseColor: vec3f,
253
232
  _padding3: f32,
254
- specularColor: vec3f,
233
+ ambientColor: vec3f,
255
234
  _padding4: f32,
235
+ specularColor: vec3f,
236
+ _padding5: f32,
256
237
  };
257
238
 
258
239
  struct VertexOutput {
@@ -262,12 +243,15 @@ export class Engine {
262
243
  @location(2) worldPos: vec3f,
263
244
  };
264
245
 
246
+ // group 0: per-frame (bound once per pass)
265
247
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
266
248
  @group(0) @binding(1) var<uniform> light: LightUniforms;
267
- @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
268
- @group(0) @binding(3) var diffuseSampler: sampler;
269
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
270
- @group(0) @binding(5) var<uniform> material: MaterialUniforms;
249
+ @group(0) @binding(2) var diffuseSampler: sampler;
250
+ // group 1: per-instance (bound once per model)
251
+ @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
252
+ // group 2: per-material (bound per draw call)
253
+ @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
254
+ @group(2) @binding(1) var<uniform> material: MaterialUniforms;
271
255
 
272
256
  @vertex fn vs(
273
257
  @location(0) position: vec3f,
@@ -279,7 +263,6 @@ export class Engine {
279
263
  var output: VertexOutput;
280
264
  let pos4 = vec4f(position, 1.0);
281
265
 
282
- // Branchless weight normalization (avoids GPU branch divergence)
283
266
  let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
284
267
  let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
285
268
  let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
@@ -303,11 +286,7 @@ export class Engine {
303
286
  }
304
287
 
305
288
  @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
306
- // Early alpha test - discard before expensive calculations
307
- var finalAlpha = material.alpha * material.alphaMultiplier;
308
- if (material.isOverEyes > 0.5) {
309
- finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
310
- }
289
+ let finalAlpha = material.alpha;
311
290
  if (finalAlpha < 0.001) {
312
291
  discard;
313
292
  }
@@ -315,18 +294,14 @@ export class Engine {
315
294
  let n = normalize(input.normal);
316
295
  let textureColor = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
317
296
 
318
- // View direction for specular and rim
319
297
  let viewDir = normalize(camera.viewPos - input.worldPos);
320
298
 
321
- // Simple lighting: global ambient + diffuse lighting
322
299
  let albedo = textureColor * material.diffuseColor;
323
300
 
324
- // Precompute material values
325
301
  let minSpec = light.ambientColor.w;
326
302
  let effectiveSpecular = max(material.specularColor, vec3f(minSpec));
327
303
  let specPower = max(material.shininess, 1.0);
328
304
 
329
- // Single directional light
330
305
  let l = -light.lights[0].direction.xyz;
331
306
  let nDotL = max(dot(n, l), 0.0);
332
307
  let intensity = light.lights[0].color.w;
@@ -334,7 +309,6 @@ export class Engine {
334
309
 
335
310
  let lightAccum = light.ambientColor.xyz + radiance * nDotL;
336
311
 
337
- // Blinn-Phong specular
338
312
  let h = normalize(l + viewDir);
339
313
  let nDotH = max(dot(n, h), 0.0);
340
314
  let specFactor = pow(nDotH, specPower);
@@ -342,9 +316,8 @@ export class Engine {
342
316
 
343
317
  let litColor = albedo * lightAccum;
344
318
 
345
- // Rim light calculation - proper Fresnel for edge-only highlights
346
319
  let fresnel = 1.0 - abs(dot(n, viewDir));
347
- let rimFactor = pow(fresnel, 4.0); // Higher power for sharper edge-only effect
320
+ let rimFactor = pow(fresnel, 4.0);
348
321
  let rimLight = material.rimColor * material.rimIntensity * rimFactor;
349
322
 
350
323
  let color = litColor + specularAccum + rimLight;
@@ -353,21 +326,42 @@ export class Engine {
353
326
  }
354
327
  `,
355
328
  });
356
- // Create explicit bind group layout for all pipelines using the main shader
357
- this.mainBindGroupLayout = this.device.createBindGroupLayout({
358
- label: "main material bind group layout",
329
+ // group 0: per-frame (camera + light + sampler) bound once per pass
330
+ this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
331
+ label: "main per-frame bind group layout",
359
332
  entries: [
360
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
361
- { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
362
- { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // diffuseTexture
363
- { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // diffuseSampler
364
- { binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
365
- { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
333
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
334
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
335
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
336
+ ],
337
+ });
338
+ // group 1: per-instance (skinMats) bound once per model
339
+ this.mainPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
340
+ label: "main per-instance bind group layout",
341
+ entries: [
342
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
343
+ ],
344
+ });
345
+ // group 2: per-material (texture + material uniforms) — bound per draw call
346
+ this.mainPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
347
+ label: "main per-material bind group layout",
348
+ entries: [
349
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
350
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
366
351
  ],
367
352
  });
368
353
  const mainPipelineLayout = this.device.createPipelineLayout({
369
354
  label: "main pipeline layout",
370
- bindGroupLayouts: [this.mainBindGroupLayout],
355
+ bindGroupLayouts: [this.mainPerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout, this.mainPerMaterialBindGroupLayout],
356
+ });
357
+ this.perFrameBindGroup = this.device.createBindGroup({
358
+ label: "main per-frame bind group",
359
+ layout: this.mainPerFrameBindGroupLayout,
360
+ entries: [
361
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
362
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
363
+ { binding: 2, resource: this.materialSampler },
364
+ ],
371
365
  });
372
366
  this.modelPipeline = this.createRenderPipeline({
373
367
  label: "model pipeline",
@@ -497,18 +491,30 @@ export class Engine {
497
491
  cullMode: "back",
498
492
  depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
499
493
  });
500
- // Create bind group layout for outline pipelines
501
- this.outlineBindGroupLayout = this.device.createBindGroupLayout({
502
- label: "outline bind group layout",
494
+ // Outline: group 0 = per-frame (camera), group 1 = per-instance (skinMats), group 2 = per-material (edge uniforms)
495
+ this.outlinePerFrameBindGroupLayout = this.device.createBindGroupLayout({
496
+ label: "outline per-frame bind group layout",
497
+ entries: [
498
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
499
+ ],
500
+ });
501
+ // Outline per-instance reuses mainPerInstanceBindGroupLayout (same skinMats binding)
502
+ this.outlinePerMaterialBindGroupLayout = this.device.createBindGroupLayout({
503
+ label: "outline per-material bind group layout",
503
504
  entries: [
504
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
505
- { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
506
- { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
505
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
507
506
  ],
508
507
  });
509
508
  const outlinePipelineLayout = this.device.createPipelineLayout({
510
509
  label: "outline pipeline layout",
511
- bindGroupLayouts: [this.outlineBindGroupLayout],
510
+ bindGroupLayouts: [this.outlinePerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout, this.outlinePerMaterialBindGroupLayout],
511
+ });
512
+ this.outlinePerFrameBindGroup = this.device.createBindGroup({
513
+ label: "outline per-frame bind group",
514
+ layout: this.outlinePerFrameBindGroupLayout,
515
+ entries: [
516
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
517
+ ],
512
518
  });
513
519
  const outlineShaderModule = this.device.createShaderModule({
514
520
  label: "outline shaders",
@@ -523,14 +529,17 @@ export class Engine {
523
529
  struct MaterialUniforms {
524
530
  edgeColor: vec4f,
525
531
  edgeSize: f32,
526
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
527
532
  _padding1: f32,
528
533
  _padding2: f32,
534
+ _padding3: f32,
529
535
  };
530
536
 
537
+ // group 0: per-frame
531
538
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
532
- @group(0) @binding(1) var<uniform> material: MaterialUniforms;
533
- @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
539
+ // group 1: per-instance
540
+ @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
541
+ // group 2: per-material
542
+ @group(2) @binding(0) var<uniform> material: MaterialUniforms;
534
543
 
535
544
  struct VertexOutput {
536
545
  @builtin(position) position: vec4f,
@@ -545,7 +554,6 @@ export class Engine {
545
554
  var output: VertexOutput;
546
555
  let pos4 = vec4f(position, 1.0);
547
556
 
548
- // Branchless weight normalization (avoids GPU branch divergence)
549
557
  let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
550
558
  let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
551
559
  let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
@@ -563,7 +571,6 @@ export class Engine {
563
571
  let worldPos = skinnedPos.xyz;
564
572
  let worldNormal = normalize(skinnedNrm);
565
573
 
566
- // MMD invert hull: expand vertices outward along normals
567
574
  let scaleFactor = 0.01;
568
575
  let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
569
576
  output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
@@ -571,13 +578,7 @@ export class Engine {
571
578
  }
572
579
 
573
580
  @fragment fn fs() -> @location(0) vec4f {
574
- var color = material.edgeColor;
575
-
576
- if (material.isOverEyes > 0.5) {
577
- color.a *= 0.5; // Hair outlines over eyes get 50% alpha
578
- }
579
-
580
- return color;
581
+ return material.edgeColor;
581
582
  }
582
583
  `,
583
584
  });
@@ -594,55 +595,9 @@ export class Engine {
594
595
  depthCompare: "less-equal",
595
596
  },
596
597
  });
597
- // Hair outline pipeline
598
- this.hairOutlinePipeline = this.createRenderPipeline({
599
- label: "hair outline pipeline",
600
- layout: outlinePipelineLayout,
601
- shaderModule: outlineShaderModule,
602
- vertexBuffers: outlineVertexBuffers,
603
- fragmentTarget: standardBlend,
604
- cullMode: "back",
605
- depthStencil: {
606
- format: "depth24plus-stencil8",
607
- depthWriteEnabled: false,
608
- depthCompare: "less-equal",
609
- depthBias: -0.0001,
610
- depthBiasSlopeScale: 0.0,
611
- depthBiasClamp: 0.0,
612
- },
613
- });
614
- // Eye overlay pipeline (renders after opaque, writes stencil)
615
- this.eyePipeline = this.createRenderPipeline({
616
- label: "eye overlay pipeline",
617
- layout: mainPipelineLayout,
618
- shaderModule,
619
- vertexBuffers: fullVertexBuffers,
620
- fragmentTarget: standardBlend,
621
- cullMode: "front",
622
- depthStencil: {
623
- format: "depth24plus-stencil8",
624
- depthWriteEnabled: true,
625
- depthCompare: "less-equal",
626
- depthBias: -0.00005,
627
- depthBiasSlopeScale: 0.0,
628
- depthBiasClamp: 0.0,
629
- stencilFront: {
630
- compare: "always",
631
- failOp: "keep",
632
- depthFailOp: "keep",
633
- passOp: "replace",
634
- },
635
- stencilBack: {
636
- compare: "always",
637
- failOp: "keep",
638
- depthFailOp: "keep",
639
- passOp: "replace",
640
- },
641
- },
642
- });
643
- // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
644
- const depthOnlyShaderModule = this.device.createShaderModule({
645
- label: "depth only shader",
598
+ // GPU picking: encode (modelIndex, materialIndex) as color
599
+ const pickShaderModule = this.device.createShaderModule({
600
+ label: "pick shader",
646
601
  code: /* wgsl */ `
647
602
  struct CameraUniforms {
648
603
  view: mat4x4f,
@@ -650,91 +605,87 @@ export class Engine {
650
605
  viewPos: vec3f,
651
606
  _padding: f32,
652
607
  };
608
+ struct PickId {
609
+ modelId: f32,
610
+ materialId: f32,
611
+ _p1: f32,
612
+ _p2: f32,
613
+ };
653
614
 
654
615
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
655
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
616
+ @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
617
+ @group(2) @binding(0) var<uniform> pickId: PickId;
656
618
 
657
619
  @vertex fn vs(
658
620
  @location(0) position: vec3f,
659
621
  @location(1) normal: vec3f,
622
+ @location(2) uv: vec2f,
660
623
  @location(3) joints0: vec4<u32>,
661
624
  @location(4) weights0: vec4<f32>
662
625
  ) -> @builtin(position) vec4f {
663
626
  let pos4 = vec4f(position, 1.0);
664
-
665
- // Branchless weight normalization (avoids GPU branch divergence)
666
627
  let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
667
628
  let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
668
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
669
-
670
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
671
- for (var i = 0u; i < 4u; i++) {
672
- let j = joints0[i];
673
- let w = normalizedWeights[i];
674
- let m = skinMats[j];
675
- skinnedPos += (m * pos4) * w;
676
- }
677
- let worldPos = skinnedPos.xyz;
678
- let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
679
- return clipPos;
629
+ let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
630
+ var sp = vec4f(0.0);
631
+ for (var i = 0u; i < 4u; i++) { sp += (skinMats[joints0[i]] * pos4) * nw[i]; }
632
+ return camera.projection * camera.view * vec4f(sp.xyz, 1.0);
680
633
  }
681
634
 
682
635
  @fragment fn fs() -> @location(0) vec4f {
683
- return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
636
+ return vec4f(pickId.modelId / 255.0, pickId.materialId / 255.0, 0.0, 1.0);
684
637
  }
685
638
  `,
686
639
  });
687
- // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
688
- this.hairDepthPipeline = this.createRenderPipeline({
689
- label: "hair depth pre-pass",
690
- layout: mainPipelineLayout,
691
- shaderModule: depthOnlyShaderModule,
692
- vertexBuffers: depthOnlyVertexBuffers,
693
- fragmentTarget: {
694
- format: this.presentationFormat,
695
- writeMask: 0,
640
+ this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
641
+ label: "pick per-frame layout",
642
+ entries: [
643
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
644
+ ],
645
+ });
646
+ this.pickPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
647
+ label: "pick per-instance layout",
648
+ entries: [
649
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
650
+ ],
651
+ });
652
+ this.pickPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
653
+ label: "pick per-material layout",
654
+ entries: [
655
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
656
+ ],
657
+ });
658
+ const pickPipelineLayout = this.device.createPipelineLayout({
659
+ label: "pick pipeline layout",
660
+ bindGroupLayouts: [this.pickPerFrameBindGroupLayout, this.pickPerInstanceBindGroupLayout, this.pickPerMaterialBindGroupLayout],
661
+ });
662
+ this.pickPerFrameBindGroup = this.device.createBindGroup({
663
+ label: "pick per-frame bind group",
664
+ layout: this.pickPerFrameBindGroupLayout,
665
+ entries: [
666
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
667
+ ],
668
+ });
669
+ this.pickPipeline = this.device.createRenderPipeline({
670
+ label: "pick pipeline",
671
+ layout: pickPipelineLayout,
672
+ vertex: { module: pickShaderModule, buffers: fullVertexBuffers },
673
+ fragment: {
674
+ module: pickShaderModule,
675
+ targets: [{ format: "rgba8unorm" }],
696
676
  },
697
- fragmentEntryPoint: "fs",
698
- cullMode: "none",
677
+ primitive: { cullMode: "none" },
699
678
  depthStencil: {
700
- format: "depth24plus-stencil8",
679
+ format: "depth24plus",
701
680
  depthWriteEnabled: true,
702
681
  depthCompare: "less-equal",
703
- depthBias: 0.0,
704
- depthBiasSlopeScale: 0.0,
705
- depthBiasClamp: 0.0,
706
682
  },
707
683
  });
708
- // Hair pipelines for rendering over eyes vs non-eyes (only differ in stencil compare mode)
709
- const createHairPipeline = (isOverEyes) => {
710
- return this.createRenderPipeline({
711
- label: `hair pipeline (${isOverEyes ? "over eyes" : "over non-eyes"})`,
712
- layout: mainPipelineLayout,
713
- shaderModule,
714
- vertexBuffers: fullVertexBuffers,
715
- fragmentTarget: standardBlend,
716
- cullMode: "none",
717
- depthStencil: {
718
- format: "depth24plus-stencil8",
719
- depthWriteEnabled: false,
720
- depthCompare: "less-equal",
721
- stencilFront: {
722
- compare: isOverEyes ? "equal" : "not-equal",
723
- failOp: "keep",
724
- depthFailOp: "keep",
725
- passOp: "keep",
726
- },
727
- stencilBack: {
728
- compare: isOverEyes ? "equal" : "not-equal",
729
- failOp: "keep",
730
- depthFailOp: "keep",
731
- passOp: "keep",
732
- },
733
- },
734
- });
735
- };
736
- this.hairPipelineOverEyes = createHairPipeline(true);
737
- this.hairPipelineOverNonEyes = createHairPipeline(false);
684
+ this.pickReadbackBuffer = this.device.createBuffer({
685
+ label: "pick readback",
686
+ size: 256,
687
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
688
+ });
738
689
  }
739
690
  // Step 3: Setup canvas resize handling
740
691
  setupResize() {
@@ -788,10 +739,24 @@ export class Engine {
788
739
  depthStoreOp: "store",
789
740
  stencilClearValue: 0,
790
741
  stencilLoadOp: "clear",
791
- stencilStoreOp: "discard", // Discard stencil after frame to save bandwidth (we only use it during rendering)
742
+ stencilStoreOp: "discard",
792
743
  },
793
744
  };
794
745
  this.camera.aspect = width / height;
746
+ if (this.onRaycast) {
747
+ this.pickTexture = this.device.createTexture({
748
+ label: "pick render target",
749
+ size: [width, height],
750
+ format: "rgba8unorm",
751
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
752
+ });
753
+ this.pickDepthTexture = this.device.createTexture({
754
+ label: "pick depth",
755
+ size: [width, height],
756
+ format: "depth24plus",
757
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
758
+ });
759
+ }
795
760
  }
796
761
  }
797
762
  // Step 4: Create camera and uniform buffer
@@ -823,6 +788,24 @@ export class Engine {
823
788
  this.cameraTargetOffset.y = offset?.y ?? 0;
824
789
  this.cameraTargetOffset.z = offset?.z ?? 0;
825
790
  }
791
+ /** Souls-style follow cam: orbit center tracks a model bone each frame. Shorthand for setCameraTarget(model, boneName, offset). */
792
+ setCameraFollow(model, boneName, offset) {
793
+ if (model === null) {
794
+ this.cameraTargetModel = null;
795
+ return;
796
+ }
797
+ this.cameraTargetModel = model;
798
+ this.cameraTargetBoneName = boneName ?? "全ての親";
799
+ this.cameraTargetOffset.x = offset?.x ?? 0;
800
+ this.cameraTargetOffset.y = offset?.y ?? 0;
801
+ this.cameraTargetOffset.z = offset?.z ?? 0;
802
+ }
803
+ getCameraDistance() { return this.camera.radius; }
804
+ setCameraDistance(d) { this.camera.radius = d; }
805
+ getCameraAlpha() { return this.camera.alpha; }
806
+ setCameraAlpha(a) { this.camera.alpha = a; }
807
+ getCameraBeta() { return this.camera.beta; }
808
+ setCameraBeta(b) { this.camera.beta = b; }
826
809
  // Step 5: Create lighting buffers
827
810
  setupLighting() {
828
811
  this.lightUniformBuffer = this.device.createBuffer({
@@ -954,9 +937,6 @@ export class Engine {
954
937
  await this.setupModelInstance(key, model, basePath);
955
938
  return key;
956
939
  }
957
- async registerModel(model, pmxPath) {
958
- return this.addModel(model, pmxPath);
959
- }
960
940
  removeModel(name) {
961
941
  this.modelInstances.delete(name);
962
942
  }
@@ -1024,11 +1004,8 @@ export class Engine {
1024
1004
  inst.physics.reset(inst.model.getWorldMatrices(), inst.model.getBoneInverseBindMatrices());
1025
1005
  });
1026
1006
  }
1027
- instances() {
1028
- return this.modelInstances.values();
1029
- }
1030
1007
  forEachInstance(fn) {
1031
- for (const inst of this.instances())
1008
+ for (const inst of this.modelInstances.values())
1032
1009
  fn(inst);
1033
1010
  }
1034
1011
  updateInstances(deltaTime) {
@@ -1098,6 +1075,20 @@ export class Engine {
1098
1075
  { binding: 1, resource: { buffer: skinMatrixBuffer } },
1099
1076
  ],
1100
1077
  });
1078
+ const mainPerInstanceBindGroup = this.device.createBindGroup({
1079
+ label: `${name}: main per-instance bind group`,
1080
+ layout: this.mainPerInstanceBindGroupLayout,
1081
+ entries: [
1082
+ { binding: 0, resource: { buffer: skinMatrixBuffer } },
1083
+ ],
1084
+ });
1085
+ const pickPerInstanceBindGroup = this.device.createBindGroup({
1086
+ label: `${name}: pick per-instance bind group`,
1087
+ layout: this.pickPerInstanceBindGroupLayout,
1088
+ entries: [
1089
+ { binding: 0, resource: { buffer: skinMatrixBuffer } },
1090
+ ],
1091
+ });
1101
1092
  const inst = {
1102
1093
  name,
1103
1094
  model,
@@ -1110,6 +1101,9 @@ export class Engine {
1110
1101
  drawCalls: [],
1111
1102
  shadowDrawCalls: [],
1112
1103
  shadowBindGroup,
1104
+ mainPerInstanceBindGroup,
1105
+ pickPerInstanceBindGroup,
1106
+ pickDrawCalls: [],
1113
1107
  hiddenMaterials: new Set(),
1114
1108
  physics,
1115
1109
  vertexBufferNeedsUpdate: false,
@@ -1245,6 +1239,8 @@ export class Engine {
1245
1239
  throw new Error("Model has no materials");
1246
1240
  const textures = model.getTextures();
1247
1241
  const prefix = `${inst.name}: `;
1242
+ // 1-based so that (0,0) = clear color = "no hit"
1243
+ const modelId = this.modelInstances.size + 1;
1248
1244
  const loadTextureByIndex = async (texIndex) => {
1249
1245
  if (texIndex < 0 || texIndex >= textures.length)
1250
1246
  return null;
@@ -1252,70 +1248,29 @@ export class Engine {
1252
1248
  return this.createTextureFromPath(path);
1253
1249
  };
1254
1250
  let currentIndexOffset = 0;
1251
+ let materialId = 0;
1255
1252
  for (const mat of materials) {
1256
1253
  const indexCount = mat.vertexCount;
1257
1254
  if (indexCount === 0)
1258
1255
  continue;
1256
+ materialId++;
1259
1257
  const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex);
1260
1258
  if (!diffuseTexture)
1261
1259
  throw new Error(`Material "${mat.name}" has no diffuse texture`);
1262
1260
  const materialAlpha = mat.diffuse[3];
1263
1261
  const isTransparent = materialAlpha < 1.0 - 0.001;
1264
- const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, 0.0, [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]], mat.ambient, mat.specular, mat.shininess);
1262
+ const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]], mat.ambient, mat.specular, mat.shininess);
1263
+ const textureView = diffuseTexture.createView();
1265
1264
  const bindGroup = this.device.createBindGroup({
1266
1265
  label: `${prefix}material: ${mat.name}`,
1267
- layout: this.mainBindGroupLayout,
1266
+ layout: this.mainPerMaterialBindGroupLayout,
1268
1267
  entries: [
1269
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1270
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1271
- { binding: 2, resource: diffuseTexture.createView() },
1272
- { binding: 3, resource: this.materialSampler },
1273
- { binding: 4, resource: { buffer: inst.skinMatrixBuffer } },
1274
- { binding: 5, resource: { buffer: materialUniformBuffer } },
1268
+ { binding: 0, resource: textureView },
1269
+ { binding: 1, resource: { buffer: materialUniformBuffer } },
1275
1270
  ],
1276
1271
  });
1277
- if (indexCount > 0) {
1278
- if (mat.isEye) {
1279
- inst.drawCalls.push({ type: "eye", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name });
1280
- }
1281
- else if (mat.isHair) {
1282
- const createHairBindGroup = (isOverEyes) => {
1283
- const buf = this.createMaterialUniformBuffer(`${prefix}${mat.name} (${isOverEyes ? "over eyes" : "over non-eyes"})`, materialAlpha, isOverEyes ? 1.0 : 0.0, [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]], mat.ambient, mat.specular, mat.shininess);
1284
- return this.device.createBindGroup({
1285
- label: `${prefix}hair ${isOverEyes ? "over eyes" : "over non-eyes"}: ${mat.name}`,
1286
- layout: this.mainBindGroupLayout,
1287
- entries: [
1288
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1289
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1290
- { binding: 2, resource: diffuseTexture.createView() },
1291
- { binding: 3, resource: this.materialSampler },
1292
- { binding: 4, resource: { buffer: inst.skinMatrixBuffer } },
1293
- { binding: 5, resource: { buffer: buf } },
1294
- ],
1295
- });
1296
- };
1297
- inst.drawCalls.push({
1298
- type: "hair-over-eyes",
1299
- count: indexCount,
1300
- firstIndex: currentIndexOffset,
1301
- bindGroup: createHairBindGroup(true),
1302
- materialName: mat.name,
1303
- });
1304
- inst.drawCalls.push({
1305
- type: "hair-over-non-eyes",
1306
- count: indexCount,
1307
- firstIndex: currentIndexOffset,
1308
- bindGroup: createHairBindGroup(false),
1309
- materialName: mat.name,
1310
- });
1311
- }
1312
- else if (isTransparent) {
1313
- inst.drawCalls.push({ type: "transparent", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name });
1314
- }
1315
- else {
1316
- inst.drawCalls.push({ type: "opaque", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name });
1317
- }
1318
- }
1272
+ const type = isTransparent ? "transparent" : "opaque";
1273
+ inst.drawCalls.push({ type, count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name });
1319
1274
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1320
1275
  const materialUniformData = new Float32Array([
1321
1276
  mat.edgeColor[0], mat.edgeColor[1], mat.edgeColor[2], mat.edgeColor[3],
@@ -1324,48 +1279,42 @@ export class Engine {
1324
1279
  const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData);
1325
1280
  const outlineBindGroup = this.device.createBindGroup({
1326
1281
  label: `${prefix}outline: ${mat.name}`,
1327
- layout: this.outlineBindGroupLayout,
1282
+ layout: this.outlinePerMaterialBindGroupLayout,
1328
1283
  entries: [
1329
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1330
- { binding: 1, resource: { buffer: outlineUniformBuffer } },
1331
- { binding: 2, resource: { buffer: inst.skinMatrixBuffer } },
1284
+ { binding: 0, resource: { buffer: outlineUniformBuffer } },
1332
1285
  ],
1333
1286
  });
1334
- if (indexCount > 0) {
1335
- const outlineType = mat.isEye ? "eye-outline" : mat.isHair ? "hair-outline" : isTransparent ? "transparent-outline" : "opaque-outline";
1336
- inst.drawCalls.push({ type: outlineType, count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup, materialName: mat.name });
1337
- }
1287
+ const outlineType = isTransparent ? "transparent-outline" : "opaque-outline";
1288
+ inst.drawCalls.push({ type: outlineType, count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup, materialName: mat.name });
1289
+ }
1290
+ if (this.onRaycast) {
1291
+ const pickIdData = new Float32Array([modelId, materialId, 0, 0]);
1292
+ const pickIdBuffer = this.createUniformBuffer(`${prefix}pick: ${mat.name}`, pickIdData);
1293
+ const pickBindGroup = this.device.createBindGroup({
1294
+ label: `${prefix}pick: ${mat.name}`,
1295
+ layout: this.pickPerMaterialBindGroupLayout,
1296
+ entries: [{ binding: 0, resource: { buffer: pickIdBuffer } }],
1297
+ });
1298
+ inst.pickDrawCalls.push({ count: indexCount, firstIndex: currentIndexOffset, bindGroup: pickBindGroup });
1338
1299
  }
1339
1300
  currentIndexOffset += indexCount;
1340
1301
  }
1341
1302
  for (const d of inst.drawCalls) {
1342
- if (d.type === "opaque" || d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes")
1303
+ if (d.type === "opaque")
1343
1304
  inst.shadowDrawCalls.push(d);
1344
1305
  }
1345
1306
  }
1346
- createMaterialUniformBuffer(label, alpha, isOverEyes, diffuseColor, ambientColor, specularColor, shininess) {
1307
+ createMaterialUniformBuffer(label, alpha, diffuseColor, ambientColor, specularColor, shininess) {
1347
1308
  const data = new Float32Array(20);
1348
1309
  data.set([
1349
1310
  alpha,
1350
- 1.0,
1351
1311
  this.rimLightIntensity,
1352
- shininess, // alpha, alphaMultiplier, rimIntensity, shininess
1353
- 1.0,
1354
- 1.0,
1355
- 1.0,
1356
- isOverEyes, // rimColor (vec3), isOverEyes
1357
- diffuseColor[0],
1358
- diffuseColor[1],
1359
- diffuseColor[2],
1360
- 0.0, // diffuseColor (vec3), _padding2
1361
- ambientColor[0],
1362
- ambientColor[1],
1363
- ambientColor[2],
1364
- 0.0, // ambientColor (vec3), _padding3
1365
- specularColor[0],
1366
- specularColor[1],
1367
- specularColor[2],
1368
- 0.0, // specularColor (vec3), _padding4
1312
+ shininess,
1313
+ 0.0,
1314
+ 1.0, 1.0, 1.0, 0.0, // rimColor (vec3), _padding2
1315
+ diffuseColor[0], diffuseColor[1], diffuseColor[2], 0.0,
1316
+ ambientColor[0], ambientColor[1], ambientColor[2], 0.0,
1317
+ specularColor[0], specularColor[1], specularColor[2], 0.0,
1369
1318
  ]);
1370
1319
  return this.createUniformBuffer(`material uniform: ${label}`, data);
1371
1320
  }
@@ -1412,17 +1361,6 @@ export class Engine {
1412
1361
  return null;
1413
1362
  }
1414
1363
  }
1415
- // Post-alpha eye: render eye draws; main pass writes stencil so hair-over-eyes can use it for see-through bangs.
1416
- renderEyes(pass, inst) {
1417
- pass.setPipeline(this.eyePipeline);
1418
- pass.setStencilReference(this.STENCIL_EYE_VALUE);
1419
- for (const draw of inst.drawCalls) {
1420
- if (draw.type === "eye" && this.shouldRenderDrawCall(inst, draw)) {
1421
- pass.setBindGroup(0, draw.bindGroup);
1422
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1423
- }
1424
- }
1425
- }
1426
1364
  renderGround(pass) {
1427
1365
  if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall)
1428
1366
  return;
@@ -1432,149 +1370,91 @@ export class Engine {
1432
1370
  pass.setBindGroup(0, this.groundDrawCall.bindGroup);
1433
1371
  pass.drawIndexed(this.groundDrawCall.count, 1, this.groundDrawCall.firstIndex, 0, 0);
1434
1372
  }
1435
- // Post-alpha eye: hair-over-eyes uses stencil (from renderEyes) for 50% alpha; hair-over-non-eyes uses inverse stencil.
1436
- renderHair(pass, inst) {
1437
- const hasHair = inst.drawCalls.some((d) => (d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(inst, d));
1438
- if (hasHair) {
1439
- pass.setPipeline(this.hairDepthPipeline);
1440
- for (const draw of inst.drawCalls) {
1441
- if ((draw.type === "hair-over-eyes" || draw.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(inst, draw)) {
1442
- pass.setBindGroup(0, draw.bindGroup);
1443
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1444
- }
1445
- }
1446
- }
1447
- const hairOverEyes = inst.drawCalls.filter((d) => d.type === "hair-over-eyes" && this.shouldRenderDrawCall(inst, d));
1448
- if (hairOverEyes.length > 0) {
1449
- pass.setPipeline(this.hairPipelineOverEyes);
1450
- pass.setStencilReference(this.STENCIL_EYE_VALUE);
1451
- for (const draw of hairOverEyes) {
1452
- pass.setBindGroup(0, draw.bindGroup);
1453
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1454
- }
1455
- }
1456
- const hairOverNonEyes = inst.drawCalls.filter((d) => d.type === "hair-over-non-eyes" && this.shouldRenderDrawCall(inst, d));
1457
- if (hairOverNonEyes.length > 0) {
1458
- pass.setPipeline(this.hairPipelineOverNonEyes);
1459
- pass.setStencilReference(this.STENCIL_EYE_VALUE);
1460
- for (const draw of hairOverNonEyes) {
1461
- pass.setBindGroup(0, draw.bindGroup);
1462
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1463
- }
1464
- }
1465
- const hairOutlines = inst.drawCalls.filter((d) => d.type === "hair-outline" && this.shouldRenderDrawCall(inst, d));
1466
- if (hairOutlines.length > 0) {
1467
- pass.setPipeline(this.hairOutlinePipeline);
1468
- for (const draw of hairOutlines) {
1469
- pass.setBindGroup(0, draw.bindGroup);
1470
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1471
- }
1472
- }
1473
- }
1474
1373
  performRaycast(screenX, screenY) {
1475
1374
  if (!this.onRaycast || this.modelInstances.size === 0) {
1476
1375
  this.onRaycast?.("", null, screenX, screenY);
1477
1376
  return;
1478
1377
  }
1479
- const viewMatrix = this.camera.getViewMatrix();
1480
- const projectionMatrix = this.camera.getProjectionMatrix();
1481
- const rect = this.canvas.getBoundingClientRect();
1482
- const clipX = (screenX / rect.width) * 2 - 1;
1483
- const clipY = 1 - (screenY / rect.height) * 2;
1484
- const viewProjMatrix = projectionMatrix.multiply(viewMatrix);
1485
- const inverseViewProj = viewProjMatrix.inverse();
1486
- const transformPoint = (matrix, point) => {
1487
- const m = matrix.values;
1488
- const x = point.x, y = point.y, z = point.z;
1489
- const result = new Vec3(m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14]);
1490
- const w = m[3] * x + m[7] * y + m[11] * z + m[15];
1491
- return result.scale(w !== 0 ? 1 / w : 1);
1492
- };
1493
- const worldNear = transformPoint(inverseViewProj, new Vec3(clipX, clipY, -1));
1494
- const worldFar = transformPoint(inverseViewProj, new Vec3(clipX, clipY, 1));
1495
- const rayOrigin = this.camera.getPosition();
1496
- const rayDirection = worldFar.subtract(worldNear).normalize();
1497
- const transformByMatrix = (matrix, offset, point) => {
1498
- const m = matrix, x = point.x, y = point.y, z = point.z;
1499
- return new Vec3(m[offset + 0] * x + m[offset + 4] * y + m[offset + 8] * z + m[offset + 12], m[offset + 1] * x + m[offset + 5] * y + m[offset + 9] * z + m[offset + 13], m[offset + 2] * x + m[offset + 6] * y + m[offset + 10] * z + m[offset + 14]);
1500
- };
1501
- let closest = null;
1502
- const maxDistance = 1000;
1378
+ const dpr = window.devicePixelRatio || 1;
1379
+ this.pendingPick = { x: Math.floor(screenX * dpr), y: Math.floor(screenY * dpr) };
1380
+ }
1381
+ renderPickPass(encoder) {
1382
+ if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture)
1383
+ return;
1384
+ const pass = encoder.beginRenderPass({
1385
+ colorAttachments: [{
1386
+ view: this.pickTexture.createView(),
1387
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1388
+ loadOp: "clear",
1389
+ storeOp: "store",
1390
+ }],
1391
+ depthStencilAttachment: {
1392
+ view: this.pickDepthTexture.createView(),
1393
+ depthClearValue: 1.0,
1394
+ depthLoadOp: "clear",
1395
+ depthStoreOp: "store",
1396
+ },
1397
+ });
1398
+ pass.setPipeline(this.pickPipeline);
1399
+ pass.setBindGroup(0, this.pickPerFrameBindGroup);
1503
1400
  this.forEachInstance((inst) => {
1504
- const model = inst.model;
1505
- const materials = model.getMaterials();
1506
- if (materials.length === 0)
1507
- return;
1508
- const baseVertices = model.getVertices();
1509
- const indices = model.getIndices();
1510
- const skinning = model.getSkinning();
1511
- if (!baseVertices?.length || !indices || !skinning)
1512
- return;
1513
- const vertices = new Float32Array(baseVertices.length);
1514
- const skinMatrices = model.getSkinMatrices();
1515
- for (let i = 0; i < baseVertices.length; i += 8) {
1516
- const vertexIndex = i / 8;
1517
- const position = new Vec3(baseVertices[i], baseVertices[i + 1], baseVertices[i + 2]);
1518
- const j0 = skinning.joints[vertexIndex * 4], j1 = skinning.joints[vertexIndex * 4 + 1], j2 = skinning.joints[vertexIndex * 4 + 2], j3 = skinning.joints[vertexIndex * 4 + 3];
1519
- const w0 = skinning.weights[vertexIndex * 4] / 255, w1 = skinning.weights[vertexIndex * 4 + 1] / 255, w2 = skinning.weights[vertexIndex * 4 + 2] / 255, w3 = skinning.weights[vertexIndex * 4 + 3] / 255;
1520
- const ws = w0 + w1 + w2 + w3;
1521
- const nw = ws > 0.0001 ? [w0 / ws, w1 / ws, w2 / ws, w3 / ws] : [1, 0, 0, 0];
1522
- let sp = new Vec3(0, 0, 0);
1523
- for (let j = 0; j < 4; j++) {
1524
- if (nw[j] <= 0)
1525
- continue;
1526
- const transformed = transformByMatrix(skinMatrices, [j0, j1, j2, j3][j] * 16, position);
1527
- sp = sp.add(transformed.scale(nw[j]));
1528
- }
1529
- vertices[i] = sp.x;
1530
- vertices[i + 1] = sp.y;
1531
- vertices[i + 2] = sp.z;
1532
- vertices[i + 3] = baseVertices[i + 3];
1533
- vertices[i + 4] = baseVertices[i + 4];
1534
- vertices[i + 5] = baseVertices[i + 5];
1535
- vertices[i + 6] = baseVertices[i + 6];
1536
- vertices[i + 7] = baseVertices[i + 7];
1401
+ pass.setVertexBuffer(0, inst.vertexBuffer);
1402
+ pass.setVertexBuffer(1, inst.jointsBuffer);
1403
+ pass.setVertexBuffer(2, inst.weightsBuffer);
1404
+ pass.setIndexBuffer(inst.indexBuffer, "uint32");
1405
+ pass.setBindGroup(1, inst.pickPerInstanceBindGroup);
1406
+ for (const draw of inst.pickDrawCalls) {
1407
+ pass.setBindGroup(2, draw.bindGroup);
1408
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1409
+ }
1410
+ });
1411
+ pass.end();
1412
+ // Copy the single pixel under cursor to readback buffer
1413
+ const px = Math.min(this.pendingPick.x, this.pickTexture.width - 1);
1414
+ const py = Math.min(this.pendingPick.y, this.pickTexture.height - 1);
1415
+ encoder.copyTextureToBuffer({ texture: this.pickTexture, origin: { x: Math.max(0, px), y: Math.max(0, py) } }, { buffer: this.pickReadbackBuffer, bytesPerRow: 256 }, { width: 1, height: 1 });
1416
+ }
1417
+ async resolvePickResult(screenX, screenY) {
1418
+ if (!this.onRaycast)
1419
+ return;
1420
+ await this.pickReadbackBuffer.mapAsync(GPUMapMode.READ);
1421
+ const data = new Uint8Array(this.pickReadbackBuffer.getMappedRange());
1422
+ const modelId = data[0];
1423
+ const materialId = data[1];
1424
+ this.pickReadbackBuffer.unmap();
1425
+ if (modelId === 0) {
1426
+ this.onRaycast("", null, screenX, screenY);
1427
+ return;
1428
+ }
1429
+ // Find model by 1-based index
1430
+ let idx = 1;
1431
+ let hitModel = "";
1432
+ for (const [name] of this.modelInstances) {
1433
+ if (idx === modelId) {
1434
+ hitModel = name;
1435
+ break;
1537
1436
  }
1538
- for (let i = 0; i < indices.length; i += 3) {
1539
- const idx0 = indices[i] * 8, idx1 = indices[i + 1] * 8, idx2 = indices[i + 2] * 8;
1540
- const v0 = new Vec3(vertices[idx0], vertices[idx0 + 1], vertices[idx0 + 2]);
1541
- const v1 = new Vec3(vertices[idx1], vertices[idx1 + 1], vertices[idx1 + 2]);
1542
- const v2 = new Vec3(vertices[idx2], vertices[idx2 + 1], vertices[idx2 + 2]);
1543
- let triangleMaterialIndex = -1;
1544
- let indexOffset = 0;
1545
- for (let matIdx = 0; matIdx < materials.length; matIdx++) {
1546
- if (i >= indexOffset && i < indexOffset + materials[matIdx].vertexCount) {
1547
- triangleMaterialIndex = matIdx;
1437
+ idx++;
1438
+ }
1439
+ // Find material by 1-based index (skipping zero-vertex materials)
1440
+ let hitMaterial = null;
1441
+ if (hitModel) {
1442
+ const inst = this.modelInstances.get(hitModel);
1443
+ if (inst) {
1444
+ const materials = inst.model.getMaterials();
1445
+ let matIdx = 0;
1446
+ for (const mat of materials) {
1447
+ if (mat.vertexCount === 0)
1448
+ continue;
1449
+ matIdx++;
1450
+ if (matIdx === materialId) {
1451
+ hitMaterial = mat.name;
1548
1452
  break;
1549
1453
  }
1550
- indexOffset += materials[matIdx].vertexCount;
1551
- }
1552
- if (triangleMaterialIndex === -1)
1553
- continue;
1554
- const edge1 = v1.subtract(v0), edge2 = v2.subtract(v0), h = rayDirection.cross(edge2), a = edge1.dot(h);
1555
- if (Math.abs(a) < 0.0001)
1556
- continue;
1557
- const f = 1 / a, s = rayOrigin.subtract(v0), u = f * s.dot(h);
1558
- if (u < 0 || u > 1)
1559
- continue;
1560
- const q = s.cross(edge1), v = f * rayDirection.dot(q);
1561
- if (v < 0 || u + v > 1)
1562
- continue;
1563
- const t = f * edge2.dot(q);
1564
- if (t <= 0.0001 || t >= maxDistance)
1565
- continue;
1566
- const triangleNormal = edge1.cross(edge2).normalize();
1567
- if (triangleNormal.dot(rayDirection) >= 0)
1568
- continue;
1569
- if (!closest || t < closest.distance) {
1570
- closest = { modelName: inst.name, materialName: materials[triangleMaterialIndex].name, distance: t };
1571
1454
  }
1572
1455
  }
1573
- });
1574
- if (this.onRaycast) {
1575
- const hit = closest;
1576
- this.onRaycast(hit?.modelName ?? "", hit?.materialName ?? null, screenX, screenY);
1577
1456
  }
1457
+ this.onRaycast(hitModel, hitMaterial, screenX, screenY);
1578
1458
  }
1579
1459
  render() {
1580
1460
  if (!this.multisampleTexture || !this.camera || !this.device)
@@ -1622,9 +1502,21 @@ export class Engine {
1622
1502
  if (this.hasGround)
1623
1503
  this.renderGround(pass);
1624
1504
  pass.end();
1505
+ const pick = this.pendingPick;
1506
+ if (pick && hasModels)
1507
+ this.renderPickPass(encoder);
1625
1508
  this.device.queue.submit([encoder.finish()]);
1509
+ if (pick) {
1510
+ this.pendingPick = null;
1511
+ const dpr = window.devicePixelRatio || 1;
1512
+ this.resolvePickResult(pick.x / dpr, pick.y / dpr);
1513
+ }
1626
1514
  this.updateStats(performance.now() - currentTime);
1627
1515
  }
1516
+ updateRenderTarget() {
1517
+ const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
1518
+ colorAttachment.resolveTarget = this.context.getCurrentTexture().createView();
1519
+ }
1628
1520
  drawInstanceShadow(sp, inst) {
1629
1521
  sp.setBindGroup(0, inst.shadowBindGroup);
1630
1522
  sp.setVertexBuffer(0, inst.vertexBuffer);
@@ -1640,7 +1532,7 @@ export class Engine {
1640
1532
  pass.setPipeline(pipeline);
1641
1533
  for (const draw of inst.drawCalls) {
1642
1534
  if (draw.type === "opaque" && this.shouldRenderDrawCall(inst, draw)) {
1643
- pass.setBindGroup(0, draw.bindGroup);
1535
+ pass.setBindGroup(2, draw.bindGroup);
1644
1536
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1645
1537
  }
1646
1538
  }
@@ -1649,20 +1541,24 @@ export class Engine {
1649
1541
  pass.setPipeline(pipeline);
1650
1542
  for (const draw of inst.drawCalls) {
1651
1543
  if (draw.type === "transparent" && this.shouldRenderDrawCall(inst, draw)) {
1652
- pass.setBindGroup(0, draw.bindGroup);
1544
+ pass.setBindGroup(2, draw.bindGroup);
1653
1545
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1654
1546
  }
1655
1547
  }
1656
1548
  }
1549
+ bindMainGroups(pass, inst) {
1550
+ pass.setBindGroup(0, this.perFrameBindGroup);
1551
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
1552
+ }
1657
1553
  renderOneModel(pass, inst) {
1658
1554
  pass.setVertexBuffer(0, inst.vertexBuffer);
1659
1555
  pass.setVertexBuffer(1, inst.jointsBuffer);
1660
1556
  pass.setVertexBuffer(2, inst.weightsBuffer);
1661
1557
  pass.setIndexBuffer(inst.indexBuffer, "uint32");
1558
+ this.bindMainGroups(pass, inst);
1662
1559
  this.drawOpaque(pass, inst, this.modelPipeline);
1663
- this.renderEyes(pass, inst);
1664
1560
  this.drawOutlines(pass, inst, false);
1665
- this.renderHair(pass, inst);
1561
+ this.bindMainGroups(pass, inst);
1666
1562
  this.drawTransparent(pass, inst, this.modelPipeline);
1667
1563
  this.drawOutlines(pass, inst, true);
1668
1564
  }
@@ -1677,10 +1573,6 @@ export class Engine {
1677
1573
  this.cameraMatrixData[34] = cameraPos.z;
1678
1574
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData);
1679
1575
  }
1680
- updateRenderTarget() {
1681
- const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
1682
- colorAttachment.resolveTarget = this.context.getCurrentTexture().createView();
1683
- }
1684
1576
  updateSkinMatrices() {
1685
1577
  this.forEachInstance((inst) => {
1686
1578
  const skinMatrices = inst.model.getSkinMatrices();
@@ -1689,10 +1581,12 @@ export class Engine {
1689
1581
  }
1690
1582
  drawOutlines(pass, inst, transparent) {
1691
1583
  pass.setPipeline(this.outlinePipeline);
1584
+ pass.setBindGroup(0, this.outlinePerFrameBindGroup);
1585
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
1692
1586
  const outlineType = transparent ? "transparent-outline" : "opaque-outline";
1693
1587
  for (const draw of inst.drawCalls) {
1694
1588
  if (draw.type === outlineType && this.shouldRenderDrawCall(inst, draw)) {
1695
- pass.setBindGroup(0, draw.bindGroup);
1589
+ pass.setBindGroup(2, draw.bindGroup);
1696
1590
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1697
1591
  }
1698
1592
  }