reze-engine 0.8.4 → 0.9.1

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/src/engine.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Camera } from "./camera"
2
- import { Mat4, Quat, Vec3 } from "./math"
2
+ import { Mat4, Vec3 } from "./math"
3
3
  import { Model } from "./model"
4
4
  import { PmxLoader } from "./pmx-loader"
5
5
  import { Physics } from "./physics"
@@ -37,14 +37,9 @@ export interface EngineStats {
37
37
 
38
38
  type DrawCallType =
39
39
  | "opaque"
40
- | "eye"
41
- | "hair-over-eyes"
42
- | "hair-over-non-eyes"
43
40
  | "transparent"
44
41
  | "ground"
45
42
  | "opaque-outline"
46
- | "eye-outline"
47
- | "hair-outline"
48
43
  | "transparent-outline"
49
44
 
50
45
  interface DrawCall {
@@ -55,6 +50,12 @@ interface DrawCall {
55
50
  materialName: string
56
51
  }
57
52
 
53
+ interface PickDrawCall {
54
+ count: number
55
+ firstIndex: number
56
+ bindGroup: GPUBindGroup
57
+ }
58
+
58
59
  interface ModelInstance {
59
60
  name: string
60
61
  model: Model
@@ -67,6 +68,9 @@ interface ModelInstance {
67
68
  drawCalls: DrawCall[]
68
69
  shadowDrawCalls: DrawCall[]
69
70
  shadowBindGroup: GPUBindGroup
71
+ mainPerInstanceBindGroup: GPUBindGroup
72
+ pickPerInstanceBindGroup: GPUBindGroup
73
+ pickDrawCalls: PickDrawCall[]
70
74
  hiddenMaterials: Set<string>
71
75
  physics: Physics | null
72
76
  vertexBufferNeedsUpdate: boolean
@@ -97,25 +101,20 @@ export class Engine {
97
101
  private lightCount = 0
98
102
  private resizeObserver: ResizeObserver | null = null
99
103
  private depthTexture!: GPUTexture
100
- // Material rendering pipelines
101
104
  private modelPipeline!: GPURenderPipeline
102
- private eyePipeline!: GPURenderPipeline
103
- private hairPipelineOverEyes!: GPURenderPipeline
104
- private hairPipelineOverNonEyes!: GPURenderPipeline
105
- private hairDepthPipeline!: GPURenderPipeline
106
- // Ground (shadow only)
107
105
  private groundShadowPipeline!: GPURenderPipeline
108
106
  private groundShadowBindGroupLayout!: GPUBindGroupLayout
109
- // Outline pipelines
110
107
  private outlinePipeline!: GPURenderPipeline
111
- private hairOutlinePipeline!: GPURenderPipeline
112
- private mainBindGroupLayout!: GPUBindGroupLayout
113
- private outlineBindGroupLayout!: GPUBindGroupLayout
108
+ private mainPerFrameBindGroupLayout!: GPUBindGroupLayout
109
+ private mainPerInstanceBindGroupLayout!: GPUBindGroupLayout
110
+ private mainPerMaterialBindGroupLayout!: GPUBindGroupLayout
111
+ private outlinePerFrameBindGroupLayout!: GPUBindGroupLayout
112
+ private outlinePerMaterialBindGroupLayout!: GPUBindGroupLayout
113
+ private perFrameBindGroup!: GPUBindGroup
114
+ private outlinePerFrameBindGroup!: GPUBindGroup
114
115
  private multisampleTexture!: GPUTexture
115
116
  private static readonly MULTISAMPLE_COUNT = 4
116
117
  private renderPassDescriptor!: GPURenderPassDescriptor
117
- // Post-alpha eye: eyes write stencil, hair-over-eyes reads it for see-through bangs (MMD-style).
118
- private readonly STENCIL_EYE_VALUE = 1
119
118
 
120
119
  // Ambient light settings
121
120
  private ambientColor!: Vec3
@@ -142,9 +141,18 @@ export class Engine {
142
141
  private shadowVPLightZ = Number.NaN
143
142
 
144
143
  private onRaycast?: RaycastCallback
145
- // Double-tap detection
146
144
  private lastTouchTime = 0
147
- private readonly DOUBLE_TAP_DELAY = 300 // ms
145
+ private readonly DOUBLE_TAP_DELAY = 300
146
+ // GPU picking
147
+ private pickPipeline!: GPURenderPipeline
148
+ private pickPerFrameBindGroupLayout!: GPUBindGroupLayout
149
+ private pickPerInstanceBindGroupLayout!: GPUBindGroupLayout
150
+ private pickPerMaterialBindGroupLayout!: GPUBindGroupLayout
151
+ private pickPerFrameBindGroup!: GPUBindGroup
152
+ private pickTexture!: GPUTexture
153
+ private pickDepthTexture!: GPUTexture
154
+ private pickReadbackBuffer!: GPUBuffer
155
+ private pendingPick: { x: number; y: number } | null = null
148
156
 
149
157
  private modelInstances = new Map<string, ModelInstance>()
150
158
  private materialSampler!: GPUSampler
@@ -294,24 +302,6 @@ export class Engine {
294
302
  },
295
303
  ]
296
304
 
297
- const depthOnlyVertexBuffers: GPUVertexBufferLayout[] = [
298
- {
299
- arrayStride: 8 * 4,
300
- attributes: [
301
- { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
302
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
303
- ],
304
- },
305
- {
306
- arrayStride: 4 * 2,
307
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
308
- },
309
- {
310
- arrayStride: 4,
311
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
312
- },
313
- ]
314
-
315
305
  const standardBlend: GPUColorTargetState = {
316
306
  format: this.presentationFormat,
317
307
  blend: {
@@ -350,17 +340,17 @@ export class Engine {
350
340
 
351
341
  struct MaterialUniforms {
352
342
  alpha: f32,
353
- alphaMultiplier: f32,
354
343
  rimIntensity: f32,
355
344
  shininess: f32,
345
+ _padding1: f32,
356
346
  rimColor: vec3f,
357
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
358
- diffuseColor: vec3f,
359
347
  _padding2: f32,
360
- ambientColor: vec3f,
348
+ diffuseColor: vec3f,
361
349
  _padding3: f32,
362
- specularColor: vec3f,
350
+ ambientColor: vec3f,
363
351
  _padding4: f32,
352
+ specularColor: vec3f,
353
+ _padding5: f32,
364
354
  };
365
355
 
366
356
  struct VertexOutput {
@@ -370,12 +360,15 @@ export class Engine {
370
360
  @location(2) worldPos: vec3f,
371
361
  };
372
362
 
363
+ // group 0: per-frame (bound once per pass)
373
364
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
374
365
  @group(0) @binding(1) var<uniform> light: LightUniforms;
375
- @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
376
- @group(0) @binding(3) var diffuseSampler: sampler;
377
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
378
- @group(0) @binding(5) var<uniform> material: MaterialUniforms;
366
+ @group(0) @binding(2) var diffuseSampler: sampler;
367
+ // group 1: per-instance (bound once per model)
368
+ @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
369
+ // group 2: per-material (bound per draw call)
370
+ @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
371
+ @group(2) @binding(1) var<uniform> material: MaterialUniforms;
379
372
 
380
373
  @vertex fn vs(
381
374
  @location(0) position: vec3f,
@@ -387,7 +380,6 @@ export class Engine {
387
380
  var output: VertexOutput;
388
381
  let pos4 = vec4f(position, 1.0);
389
382
 
390
- // Branchless weight normalization (avoids GPU branch divergence)
391
383
  let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
392
384
  let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
393
385
  let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
@@ -411,11 +403,7 @@ export class Engine {
411
403
  }
412
404
 
413
405
  @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
414
- // Early alpha test - discard before expensive calculations
415
- var finalAlpha = material.alpha * material.alphaMultiplier;
416
- if (material.isOverEyes > 0.5) {
417
- finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
418
- }
406
+ let finalAlpha = material.alpha;
419
407
  if (finalAlpha < 0.001) {
420
408
  discard;
421
409
  }
@@ -423,18 +411,14 @@ export class Engine {
423
411
  let n = normalize(input.normal);
424
412
  let textureColor = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
425
413
 
426
- // View direction for specular and rim
427
414
  let viewDir = normalize(camera.viewPos - input.worldPos);
428
415
 
429
- // Simple lighting: global ambient + diffuse lighting
430
416
  let albedo = textureColor * material.diffuseColor;
431
417
 
432
- // Precompute material values
433
418
  let minSpec = light.ambientColor.w;
434
419
  let effectiveSpecular = max(material.specularColor, vec3f(minSpec));
435
420
  let specPower = max(material.shininess, 1.0);
436
421
 
437
- // Single directional light
438
422
  let l = -light.lights[0].direction.xyz;
439
423
  let nDotL = max(dot(n, l), 0.0);
440
424
  let intensity = light.lights[0].color.w;
@@ -442,7 +426,6 @@ export class Engine {
442
426
 
443
427
  let lightAccum = light.ambientColor.xyz + radiance * nDotL;
444
428
 
445
- // Blinn-Phong specular
446
429
  let h = normalize(l + viewDir);
447
430
  let nDotH = max(dot(n, h), 0.0);
448
431
  let specFactor = pow(nDotH, specPower);
@@ -450,9 +433,8 @@ export class Engine {
450
433
 
451
434
  let litColor = albedo * lightAccum;
452
435
 
453
- // Rim light calculation - proper Fresnel for edge-only highlights
454
436
  let fresnel = 1.0 - abs(dot(n, viewDir));
455
- let rimFactor = pow(fresnel, 4.0); // Higher power for sharper edge-only effect
437
+ let rimFactor = pow(fresnel, 4.0);
456
438
  let rimLight = material.rimColor * material.rimIntensity * rimFactor;
457
439
 
458
440
  let color = litColor + specularAccum + rimLight;
@@ -462,22 +444,44 @@ export class Engine {
462
444
  `,
463
445
  })
464
446
 
465
- // Create explicit bind group layout for all pipelines using the main shader
466
- this.mainBindGroupLayout = this.device.createBindGroupLayout({
467
- label: "main material bind group layout",
447
+ // group 0: per-frame (camera + light + sampler) bound once per pass
448
+ this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
449
+ label: "main per-frame bind group layout",
468
450
  entries: [
469
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
470
- { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
471
- { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // diffuseTexture
472
- { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // diffuseSampler
473
- { binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
474
- { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
451
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
452
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
453
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
454
+ ],
455
+ })
456
+ // group 1: per-instance (skinMats) bound once per model
457
+ this.mainPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
458
+ label: "main per-instance bind group layout",
459
+ entries: [
460
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
461
+ ],
462
+ })
463
+ // group 2: per-material (texture + material uniforms) — bound per draw call
464
+ this.mainPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
465
+ label: "main per-material bind group layout",
466
+ entries: [
467
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
468
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
475
469
  ],
476
470
  })
477
471
 
478
472
  const mainPipelineLayout = this.device.createPipelineLayout({
479
473
  label: "main pipeline layout",
480
- bindGroupLayouts: [this.mainBindGroupLayout],
474
+ bindGroupLayouts: [this.mainPerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout, this.mainPerMaterialBindGroupLayout],
475
+ })
476
+
477
+ this.perFrameBindGroup = this.device.createBindGroup({
478
+ label: "main per-frame bind group",
479
+ layout: this.mainPerFrameBindGroupLayout,
480
+ entries: [
481
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
482
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
483
+ { binding: 2, resource: this.materialSampler },
484
+ ],
481
485
  })
482
486
 
483
487
  this.modelPipeline = this.createRenderPipeline({
@@ -610,19 +614,32 @@ export class Engine {
610
614
  depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
611
615
  })
612
616
 
613
- // Create bind group layout for outline pipelines
614
- this.outlineBindGroupLayout = this.device.createBindGroupLayout({
615
- label: "outline bind group layout",
617
+ // Outline: group 0 = per-frame (camera), group 1 = per-instance (skinMats), group 2 = per-material (edge uniforms)
618
+ this.outlinePerFrameBindGroupLayout = this.device.createBindGroupLayout({
619
+ label: "outline per-frame bind group layout",
616
620
  entries: [
617
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
618
- { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
619
- { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
621
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
622
+ ],
623
+ })
624
+ // Outline per-instance reuses mainPerInstanceBindGroupLayout (same skinMats binding)
625
+ this.outlinePerMaterialBindGroupLayout = this.device.createBindGroupLayout({
626
+ label: "outline per-material bind group layout",
627
+ entries: [
628
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
620
629
  ],
621
630
  })
622
631
 
623
632
  const outlinePipelineLayout = this.device.createPipelineLayout({
624
633
  label: "outline pipeline layout",
625
- bindGroupLayouts: [this.outlineBindGroupLayout],
634
+ bindGroupLayouts: [this.outlinePerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout, this.outlinePerMaterialBindGroupLayout],
635
+ })
636
+
637
+ this.outlinePerFrameBindGroup = this.device.createBindGroup({
638
+ label: "outline per-frame bind group",
639
+ layout: this.outlinePerFrameBindGroupLayout,
640
+ entries: [
641
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
642
+ ],
626
643
  })
627
644
 
628
645
  const outlineShaderModule = this.device.createShaderModule({
@@ -638,14 +655,17 @@ export class Engine {
638
655
  struct MaterialUniforms {
639
656
  edgeColor: vec4f,
640
657
  edgeSize: f32,
641
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
642
658
  _padding1: f32,
643
659
  _padding2: f32,
660
+ _padding3: f32,
644
661
  };
645
662
 
663
+ // group 0: per-frame
646
664
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
647
- @group(0) @binding(1) var<uniform> material: MaterialUniforms;
648
- @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
665
+ // group 1: per-instance
666
+ @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
667
+ // group 2: per-material
668
+ @group(2) @binding(0) var<uniform> material: MaterialUniforms;
649
669
 
650
670
  struct VertexOutput {
651
671
  @builtin(position) position: vec4f,
@@ -660,7 +680,6 @@ export class Engine {
660
680
  var output: VertexOutput;
661
681
  let pos4 = vec4f(position, 1.0);
662
682
 
663
- // Branchless weight normalization (avoids GPU branch divergence)
664
683
  let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
665
684
  let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
666
685
  let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
@@ -677,22 +696,17 @@ export class Engine {
677
696
  }
678
697
  let worldPos = skinnedPos.xyz;
679
698
  let worldNormal = normalize(skinnedNrm);
680
-
681
- // MMD invert hull: expand vertices outward along normals
682
- let scaleFactor = 0.01;
683
- let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
699
+ // Screen-stable edgeline: extrusion ∝ camera distance (same idea as MMD viewers / babylon-mmd-style scaling)
700
+ let camDist = max(length(camera.viewPos - worldPos), 0.25);
701
+ let refDist = 30.0;
702
+ let edgeScale = 0.03;
703
+ let expandedPos = worldPos + worldNormal * material.edgeSize * edgeScale * (camDist / refDist);
684
704
  output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
685
705
  return output;
686
706
  }
687
707
 
688
708
  @fragment fn fs() -> @location(0) vec4f {
689
- var color = material.edgeColor;
690
-
691
- if (material.isOverEyes > 0.5) {
692
- color.a *= 0.5; // Hair outlines over eyes get 50% alpha
693
- }
694
-
695
- return color;
709
+ return material.edgeColor;
696
710
  }
697
711
  `,
698
712
  })
@@ -706,62 +720,15 @@ export class Engine {
706
720
  cullMode: "back",
707
721
  depthStencil: {
708
722
  format: "depth24plus-stencil8",
709
- depthWriteEnabled: true,
710
- depthCompare: "less-equal",
711
- },
712
- })
713
-
714
- // Hair outline pipeline
715
- this.hairOutlinePipeline = this.createRenderPipeline({
716
- label: "hair outline pipeline",
717
- layout: outlinePipelineLayout,
718
- shaderModule: outlineShaderModule,
719
- vertexBuffers: outlineVertexBuffers,
720
- fragmentTarget: standardBlend,
721
- cullMode: "back",
722
- depthStencil: {
723
- format: "depth24plus-stencil8",
723
+ // Don’t write outline into depth buffer — stops z-fighting / black cracks vs body (MMD-style; body depth stays authoritative)
724
724
  depthWriteEnabled: false,
725
725
  depthCompare: "less-equal",
726
- depthBias: -0.0001,
727
- depthBiasSlopeScale: 0.0,
728
- depthBiasClamp: 0.0,
729
- },
730
- })
731
-
732
- // Eye overlay pipeline (renders after opaque, writes stencil)
733
- this.eyePipeline = this.createRenderPipeline({
734
- label: "eye overlay pipeline",
735
- layout: mainPipelineLayout,
736
- shaderModule,
737
- vertexBuffers: fullVertexBuffers,
738
- fragmentTarget: standardBlend,
739
- cullMode: "front",
740
- depthStencil: {
741
- format: "depth24plus-stencil8",
742
- depthWriteEnabled: true,
743
- depthCompare: "less-equal",
744
- depthBias: -0.00005,
745
- depthBiasSlopeScale: 0.0,
746
- depthBiasClamp: 0.0,
747
- stencilFront: {
748
- compare: "always",
749
- failOp: "keep",
750
- depthFailOp: "keep",
751
- passOp: "replace",
752
- },
753
- stencilBack: {
754
- compare: "always",
755
- failOp: "keep",
756
- depthFailOp: "keep",
757
- passOp: "replace",
758
- },
759
726
  },
760
727
  })
761
728
 
762
- // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
763
- const depthOnlyShaderModule = this.device.createShaderModule({
764
- label: "depth only shader",
729
+ // GPU picking: encode (modelIndex, materialIndex) as color
730
+ const pickShaderModule = this.device.createShaderModule({
731
+ label: "pick shader",
765
732
  code: /* wgsl */ `
766
733
  struct CameraUniforms {
767
734
  view: mat4x4f,
@@ -769,94 +736,92 @@ export class Engine {
769
736
  viewPos: vec3f,
770
737
  _padding: f32,
771
738
  };
739
+ struct PickId {
740
+ modelId: f32,
741
+ materialId: f32,
742
+ _p1: f32,
743
+ _p2: f32,
744
+ };
772
745
 
773
746
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
774
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
747
+ @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
748
+ @group(2) @binding(0) var<uniform> pickId: PickId;
775
749
 
776
750
  @vertex fn vs(
777
751
  @location(0) position: vec3f,
778
752
  @location(1) normal: vec3f,
753
+ @location(2) uv: vec2f,
779
754
  @location(3) joints0: vec4<u32>,
780
755
  @location(4) weights0: vec4<f32>
781
756
  ) -> @builtin(position) vec4f {
782
757
  let pos4 = vec4f(position, 1.0);
783
-
784
- // Branchless weight normalization (avoids GPU branch divergence)
785
758
  let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
786
759
  let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
787
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
788
-
789
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
790
- for (var i = 0u; i < 4u; i++) {
791
- let j = joints0[i];
792
- let w = normalizedWeights[i];
793
- let m = skinMats[j];
794
- skinnedPos += (m * pos4) * w;
795
- }
796
- let worldPos = skinnedPos.xyz;
797
- let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
798
- return clipPos;
760
+ let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
761
+ var sp = vec4f(0.0);
762
+ for (var i = 0u; i < 4u; i++) { sp += (skinMats[joints0[i]] * pos4) * nw[i]; }
763
+ return camera.projection * camera.view * vec4f(sp.xyz, 1.0);
799
764
  }
800
765
 
801
766
  @fragment fn fs() -> @location(0) vec4f {
802
- return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
767
+ return vec4f(pickId.modelId / 255.0, pickId.materialId / 255.0, 0.0, 1.0);
803
768
  }
804
769
  `,
805
770
  })
806
771
 
807
- // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
808
- this.hairDepthPipeline = this.createRenderPipeline({
809
- label: "hair depth pre-pass",
810
- layout: mainPipelineLayout,
811
- shaderModule: depthOnlyShaderModule,
812
- vertexBuffers: depthOnlyVertexBuffers,
813
- fragmentTarget: {
814
- format: this.presentationFormat,
815
- writeMask: 0,
772
+ this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
773
+ label: "pick per-frame layout",
774
+ entries: [
775
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
776
+ ],
777
+ })
778
+ this.pickPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
779
+ label: "pick per-instance layout",
780
+ entries: [
781
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
782
+ ],
783
+ })
784
+ this.pickPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
785
+ label: "pick per-material layout",
786
+ entries: [
787
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
788
+ ],
789
+ })
790
+
791
+ const pickPipelineLayout = this.device.createPipelineLayout({
792
+ label: "pick pipeline layout",
793
+ bindGroupLayouts: [this.pickPerFrameBindGroupLayout, this.pickPerInstanceBindGroupLayout, this.pickPerMaterialBindGroupLayout],
794
+ })
795
+
796
+ this.pickPerFrameBindGroup = this.device.createBindGroup({
797
+ label: "pick per-frame bind group",
798
+ layout: this.pickPerFrameBindGroupLayout,
799
+ entries: [
800
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
801
+ ],
802
+ })
803
+
804
+ this.pickPipeline = this.device.createRenderPipeline({
805
+ label: "pick pipeline",
806
+ layout: pickPipelineLayout,
807
+ vertex: { module: pickShaderModule, buffers: fullVertexBuffers },
808
+ fragment: {
809
+ module: pickShaderModule,
810
+ targets: [{ format: "rgba8unorm" }],
816
811
  },
817
- fragmentEntryPoint: "fs",
818
- cullMode: "none",
812
+ primitive: { cullMode: "none" },
819
813
  depthStencil: {
820
- format: "depth24plus-stencil8",
814
+ format: "depth24plus",
821
815
  depthWriteEnabled: true,
822
816
  depthCompare: "less-equal",
823
- depthBias: 0.0,
824
- depthBiasSlopeScale: 0.0,
825
- depthBiasClamp: 0.0,
826
817
  },
827
818
  })
828
819
 
829
- // Hair pipelines for rendering over eyes vs non-eyes (only differ in stencil compare mode)
830
- const createHairPipeline = (isOverEyes: boolean): GPURenderPipeline => {
831
- return this.createRenderPipeline({
832
- label: `hair pipeline (${isOverEyes ? "over eyes" : "over non-eyes"})`,
833
- layout: mainPipelineLayout,
834
- shaderModule,
835
- vertexBuffers: fullVertexBuffers,
836
- fragmentTarget: standardBlend,
837
- cullMode: "none",
838
- depthStencil: {
839
- format: "depth24plus-stencil8",
840
- depthWriteEnabled: false,
841
- depthCompare: "less-equal",
842
- stencilFront: {
843
- compare: isOverEyes ? "equal" : "not-equal",
844
- failOp: "keep",
845
- depthFailOp: "keep",
846
- passOp: "keep",
847
- },
848
- stencilBack: {
849
- compare: isOverEyes ? "equal" : "not-equal",
850
- failOp: "keep",
851
- depthFailOp: "keep",
852
- passOp: "keep",
853
- },
854
- },
855
- })
856
- }
857
-
858
- this.hairPipelineOverEyes = createHairPipeline(true)
859
- this.hairPipelineOverNonEyes = createHairPipeline(false)
820
+ this.pickReadbackBuffer = this.device.createBuffer({
821
+ label: "pick readback",
822
+ size: 256,
823
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
824
+ })
860
825
  }
861
826
 
862
827
 
@@ -921,11 +886,26 @@ export class Engine {
921
886
  depthStoreOp: "store",
922
887
  stencilClearValue: 0,
923
888
  stencilLoadOp: "clear",
924
- stencilStoreOp: "discard", // Discard stencil after frame to save bandwidth (we only use it during rendering)
889
+ stencilStoreOp: "discard",
925
890
  },
926
891
  }
927
892
 
928
893
  this.camera.aspect = width / height
894
+
895
+ if (this.onRaycast) {
896
+ this.pickTexture = this.device.createTexture({
897
+ label: "pick render target",
898
+ size: [width, height],
899
+ format: "rgba8unorm",
900
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
901
+ })
902
+ this.pickDepthTexture = this.device.createTexture({
903
+ label: "pick depth",
904
+ size: [width, height],
905
+ format: "depth24plus",
906
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
907
+ })
908
+ }
929
909
  }
930
910
  }
931
911
 
@@ -943,9 +923,9 @@ export class Engine {
943
923
  this.camera.attachControl(this.canvas)
944
924
  }
945
925
 
946
- /** Set camera look-at target to a point. Clears any model binding. */
926
+ /** Set static camera look-at / orbit center. Clears any model follow binding. */
947
927
  public setCameraTarget(v: Vec3): void
948
- /** Bind camera target to a model's bone. Engine updates target each frame. Bone not found → (0,0,0) + offset. Pass null to unbind. */
928
+ /** Bind camera orbit center to a model's bone (Souls-style follow cam). Pass null to unbind. */
949
929
  public setCameraTarget(model: Model | null, boneName: string, offset?: Vec3): void
950
930
  public setCameraTarget(modelOrVec: Model | Vec3 | null, boneName?: string, offset?: Vec3): void {
951
931
  if (modelOrVec === null) {
@@ -966,6 +946,26 @@ export class Engine {
966
946
  this.cameraTargetOffset.z = offset?.z ?? 0
967
947
  }
968
948
 
949
+ /** Souls-style follow cam: orbit center tracks a model bone each frame. Shorthand for setCameraTarget(model, boneName, offset). */
950
+ public setCameraFollow(model: Model | null, boneName?: string, offset?: Vec3): void {
951
+ if (model === null) {
952
+ this.cameraTargetModel = null
953
+ return
954
+ }
955
+ this.cameraTargetModel = model
956
+ this.cameraTargetBoneName = boneName ?? "全ての親"
957
+ this.cameraTargetOffset.x = offset?.x ?? 0
958
+ this.cameraTargetOffset.y = offset?.y ?? 0
959
+ this.cameraTargetOffset.z = offset?.z ?? 0
960
+ }
961
+
962
+ public getCameraDistance(): number { return this.camera.radius }
963
+ public setCameraDistance(d: number): void { this.camera.radius = d }
964
+ public getCameraAlpha(): number { return this.camera.alpha }
965
+ public setCameraAlpha(a: number): void { this.camera.alpha = a }
966
+ public getCameraBeta(): number { return this.camera.beta }
967
+ public setCameraBeta(b: number): void { this.camera.beta = b }
968
+
969
969
  // Step 5: Create lighting buffers
970
970
  private setupLighting() {
971
971
  this.lightUniformBuffer = this.device.createBuffer({
@@ -1126,10 +1126,6 @@ export class Engine {
1126
1126
  return key
1127
1127
  }
1128
1128
 
1129
- public async registerModel(model: Model, pmxPath: string): Promise<string> {
1130
- return this.addModel(model, pmxPath)
1131
- }
1132
-
1133
1129
  public removeModel(name: string): void {
1134
1130
  this.modelInstances.delete(name)
1135
1131
  }
@@ -1200,12 +1196,8 @@ export class Engine {
1200
1196
  })
1201
1197
  }
1202
1198
 
1203
- private instances(): IterableIterator<ModelInstance> {
1204
- return this.modelInstances.values()
1205
- }
1206
-
1207
1199
  private forEachInstance(fn: (inst: ModelInstance) => void): void {
1208
- for (const inst of this.instances()) fn(inst)
1200
+ for (const inst of this.modelInstances.values()) fn(inst)
1209
1201
  }
1210
1202
 
1211
1203
  private updateInstances(deltaTime: number): void {
@@ -1297,6 +1289,22 @@ export class Engine {
1297
1289
  ],
1298
1290
  })
1299
1291
 
1292
+ const mainPerInstanceBindGroup = this.device.createBindGroup({
1293
+ label: `${name}: main per-instance bind group`,
1294
+ layout: this.mainPerInstanceBindGroupLayout,
1295
+ entries: [
1296
+ { binding: 0, resource: { buffer: skinMatrixBuffer } },
1297
+ ],
1298
+ })
1299
+
1300
+ const pickPerInstanceBindGroup = this.device.createBindGroup({
1301
+ label: `${name}: pick per-instance bind group`,
1302
+ layout: this.pickPerInstanceBindGroupLayout,
1303
+ entries: [
1304
+ { binding: 0, resource: { buffer: skinMatrixBuffer } },
1305
+ ],
1306
+ })
1307
+
1300
1308
  const inst: ModelInstance = {
1301
1309
  name,
1302
1310
  model,
@@ -1309,6 +1317,9 @@ export class Engine {
1309
1317
  drawCalls: [],
1310
1318
  shadowDrawCalls: [],
1311
1319
  shadowBindGroup,
1320
+ mainPerInstanceBindGroup,
1321
+ pickPerInstanceBindGroup,
1322
+ pickDrawCalls: [],
1312
1323
  hiddenMaterials: new Set(),
1313
1324
  physics,
1314
1325
  vertexBufferNeedsUpdate: false,
@@ -1457,6 +1468,8 @@ export class Engine {
1457
1468
  if (materials.length === 0) throw new Error("Model has no materials")
1458
1469
  const textures = model.getTextures()
1459
1470
  const prefix = `${inst.name}: `
1471
+ // 1-based so that (0,0) = clear color = "no hit"
1472
+ const modelId = this.modelInstances.size + 1
1460
1473
 
1461
1474
  const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
1462
1475
  if (texIndex < 0 || texIndex >= textures.length) return null
@@ -1465,9 +1478,11 @@ export class Engine {
1465
1478
  }
1466
1479
 
1467
1480
  let currentIndexOffset = 0
1481
+ let materialId = 0
1468
1482
  for (const mat of materials) {
1469
1483
  const indexCount = mat.vertexCount
1470
1484
  if (indexCount === 0) continue
1485
+ materialId++
1471
1486
 
1472
1487
  const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex)
1473
1488
  if (!diffuseTexture) throw new Error(`Material "${mat.name}" has no diffuse texture`)
@@ -1478,73 +1493,24 @@ export class Engine {
1478
1493
  const materialUniformBuffer = this.createMaterialUniformBuffer(
1479
1494
  prefix + mat.name,
1480
1495
  materialAlpha,
1481
- 0.0,
1482
1496
  [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]],
1483
1497
  mat.ambient,
1484
1498
  mat.specular,
1485
1499
  mat.shininess
1486
1500
  )
1487
1501
 
1502
+ const textureView = diffuseTexture.createView()
1488
1503
  const bindGroup = this.device.createBindGroup({
1489
1504
  label: `${prefix}material: ${mat.name}`,
1490
- layout: this.mainBindGroupLayout,
1505
+ layout: this.mainPerMaterialBindGroupLayout,
1491
1506
  entries: [
1492
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1493
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1494
- { binding: 2, resource: diffuseTexture.createView() },
1495
- { binding: 3, resource: this.materialSampler },
1496
- { binding: 4, resource: { buffer: inst.skinMatrixBuffer } },
1497
- { binding: 5, resource: { buffer: materialUniformBuffer } },
1507
+ { binding: 0, resource: textureView },
1508
+ { binding: 1, resource: { buffer: materialUniformBuffer } },
1498
1509
  ],
1499
1510
  })
1500
1511
 
1501
- if (indexCount > 0) {
1502
- if (mat.isEye) {
1503
- inst.drawCalls.push({ type: "eye", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
1504
- } else if (mat.isHair) {
1505
- const createHairBindGroup = (isOverEyes: boolean) => {
1506
- const buf = this.createMaterialUniformBuffer(
1507
- `${prefix}${mat.name} (${isOverEyes ? "over eyes" : "over non-eyes"})`,
1508
- materialAlpha,
1509
- isOverEyes ? 1.0 : 0.0,
1510
- [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]],
1511
- mat.ambient,
1512
- mat.specular,
1513
- mat.shininess
1514
- )
1515
- return this.device.createBindGroup({
1516
- label: `${prefix}hair ${isOverEyes ? "over eyes" : "over non-eyes"}: ${mat.name}`,
1517
- layout: this.mainBindGroupLayout,
1518
- entries: [
1519
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1520
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1521
- { binding: 2, resource: diffuseTexture.createView() },
1522
- { binding: 3, resource: this.materialSampler },
1523
- { binding: 4, resource: { buffer: inst.skinMatrixBuffer } },
1524
- { binding: 5, resource: { buffer: buf } },
1525
- ],
1526
- })
1527
- }
1528
- inst.drawCalls.push({
1529
- type: "hair-over-eyes",
1530
- count: indexCount,
1531
- firstIndex: currentIndexOffset,
1532
- bindGroup: createHairBindGroup(true),
1533
- materialName: mat.name,
1534
- })
1535
- inst.drawCalls.push({
1536
- type: "hair-over-non-eyes",
1537
- count: indexCount,
1538
- firstIndex: currentIndexOffset,
1539
- bindGroup: createHairBindGroup(false),
1540
- materialName: mat.name,
1541
- })
1542
- } else if (isTransparent) {
1543
- inst.drawCalls.push({ type: "transparent", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
1544
- } else {
1545
- inst.drawCalls.push({ type: "opaque", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
1546
- }
1547
- }
1512
+ const type: DrawCallType = isTransparent ? "transparent" : "opaque"
1513
+ inst.drawCalls.push({ type, count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
1548
1514
 
1549
1515
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1550
1516
  const materialUniformData = new Float32Array([
@@ -1554,32 +1520,37 @@ export class Engine {
1554
1520
  const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData)
1555
1521
  const outlineBindGroup = this.device.createBindGroup({
1556
1522
  label: `${prefix}outline: ${mat.name}`,
1557
- layout: this.outlineBindGroupLayout,
1523
+ layout: this.outlinePerMaterialBindGroupLayout,
1558
1524
  entries: [
1559
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1560
- { binding: 1, resource: { buffer: outlineUniformBuffer } },
1561
- { binding: 2, resource: { buffer: inst.skinMatrixBuffer } },
1525
+ { binding: 0, resource: { buffer: outlineUniformBuffer } },
1562
1526
  ],
1563
1527
  })
1564
- if (indexCount > 0) {
1565
- const outlineType: DrawCallType = mat.isEye ? "eye-outline" : mat.isHair ? "hair-outline" : isTransparent ? "transparent-outline" : "opaque-outline"
1566
- inst.drawCalls.push({ type: outlineType, count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup, materialName: mat.name })
1567
- }
1528
+ const outlineType: DrawCallType = isTransparent ? "transparent-outline" : "opaque-outline"
1529
+ inst.drawCalls.push({ type: outlineType, count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup, materialName: mat.name })
1530
+ }
1531
+
1532
+ if (this.onRaycast) {
1533
+ const pickIdData = new Float32Array([modelId, materialId, 0, 0])
1534
+ const pickIdBuffer = this.createUniformBuffer(`${prefix}pick: ${mat.name}`, pickIdData)
1535
+ const pickBindGroup = this.device.createBindGroup({
1536
+ label: `${prefix}pick: ${mat.name}`,
1537
+ layout: this.pickPerMaterialBindGroupLayout,
1538
+ entries: [{ binding: 0, resource: { buffer: pickIdBuffer } }],
1539
+ })
1540
+ inst.pickDrawCalls.push({ count: indexCount, firstIndex: currentIndexOffset, bindGroup: pickBindGroup })
1568
1541
  }
1569
1542
 
1570
1543
  currentIndexOffset += indexCount
1571
1544
  }
1572
1545
 
1573
1546
  for (const d of inst.drawCalls) {
1574
- if (d.type === "opaque" || d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes")
1575
- inst.shadowDrawCalls.push(d)
1547
+ if (d.type === "opaque") inst.shadowDrawCalls.push(d)
1576
1548
  }
1577
1549
  }
1578
1550
 
1579
1551
  private createMaterialUniformBuffer(
1580
1552
  label: string,
1581
1553
  alpha: number,
1582
- isOverEyes: number,
1583
1554
  diffuseColor: [number, number, number],
1584
1555
  ambientColor: [number, number, number],
1585
1556
  specularColor: [number, number, number],
@@ -1588,25 +1559,13 @@ export class Engine {
1588
1559
  const data = new Float32Array(20)
1589
1560
  data.set([
1590
1561
  alpha,
1591
- 1.0,
1592
1562
  this.rimLightIntensity,
1593
- shininess, // alpha, alphaMultiplier, rimIntensity, shininess
1594
- 1.0,
1595
- 1.0,
1596
- 1.0,
1597
- isOverEyes, // rimColor (vec3), isOverEyes
1598
- diffuseColor[0],
1599
- diffuseColor[1],
1600
- diffuseColor[2],
1601
- 0.0, // diffuseColor (vec3), _padding2
1602
- ambientColor[0],
1603
- ambientColor[1],
1604
- ambientColor[2],
1605
- 0.0, // ambientColor (vec3), _padding3
1606
- specularColor[0],
1607
- specularColor[1],
1608
- specularColor[2],
1609
- 0.0, // specularColor (vec3), _padding4
1563
+ shininess,
1564
+ 0.0,
1565
+ 1.0, 1.0, 1.0, 0.0, // rimColor (vec3), _padding2
1566
+ diffuseColor[0], diffuseColor[1], diffuseColor[2], 0.0,
1567
+ ambientColor[0], ambientColor[1], ambientColor[2], 0.0,
1568
+ specularColor[0], specularColor[1], specularColor[2], 0.0,
1610
1569
  ])
1611
1570
  return this.createUniformBuffer(`material uniform: ${label}`, data)
1612
1571
  }
@@ -1659,17 +1618,6 @@ export class Engine {
1659
1618
  }
1660
1619
  }
1661
1620
 
1662
- // Post-alpha eye: render eye draws; main pass writes stencil so hair-over-eyes can use it for see-through bangs.
1663
- private renderEyes(pass: GPURenderPassEncoder, inst: ModelInstance) {
1664
- pass.setPipeline(this.eyePipeline)
1665
- pass.setStencilReference(this.STENCIL_EYE_VALUE)
1666
- for (const draw of inst.drawCalls) {
1667
- if (draw.type === "eye" && this.shouldRenderDrawCall(inst, draw)) {
1668
- pass.setBindGroup(0, draw.bindGroup)
1669
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1670
- }
1671
- }
1672
- }
1673
1621
 
1674
1622
  private renderGround(pass: GPURenderPassEncoder) {
1675
1623
  if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
@@ -1680,51 +1628,6 @@ export class Engine {
1680
1628
  pass.drawIndexed(this.groundDrawCall.count, 1, this.groundDrawCall.firstIndex, 0, 0)
1681
1629
  }
1682
1630
 
1683
- // Post-alpha eye: hair-over-eyes uses stencil (from renderEyes) for 50% alpha; hair-over-non-eyes uses inverse stencil.
1684
- private renderHair(pass: GPURenderPassEncoder, inst: ModelInstance) {
1685
-
1686
- const hasHair = inst.drawCalls.some(
1687
- (d) => (d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(inst, d)
1688
- )
1689
- if (hasHair) {
1690
- pass.setPipeline(this.hairDepthPipeline)
1691
- for (const draw of inst.drawCalls) {
1692
- if ((draw.type === "hair-over-eyes" || draw.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(inst, draw)) {
1693
- pass.setBindGroup(0, draw.bindGroup)
1694
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1695
- }
1696
- }
1697
- }
1698
-
1699
- const hairOverEyes = inst.drawCalls.filter((d) => d.type === "hair-over-eyes" && this.shouldRenderDrawCall(inst, d))
1700
- if (hairOverEyes.length > 0) {
1701
- pass.setPipeline(this.hairPipelineOverEyes)
1702
- pass.setStencilReference(this.STENCIL_EYE_VALUE)
1703
- for (const draw of hairOverEyes) {
1704
- pass.setBindGroup(0, draw.bindGroup)
1705
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1706
- }
1707
- }
1708
-
1709
- const hairOverNonEyes = inst.drawCalls.filter((d) => d.type === "hair-over-non-eyes" && this.shouldRenderDrawCall(inst, d))
1710
- if (hairOverNonEyes.length > 0) {
1711
- pass.setPipeline(this.hairPipelineOverNonEyes)
1712
- pass.setStencilReference(this.STENCIL_EYE_VALUE)
1713
- for (const draw of hairOverNonEyes) {
1714
- pass.setBindGroup(0, draw.bindGroup)
1715
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1716
- }
1717
- }
1718
-
1719
- const hairOutlines = inst.drawCalls.filter((d) => d.type === "hair-outline" && this.shouldRenderDrawCall(inst, d))
1720
- if (hairOutlines.length > 0) {
1721
- pass.setPipeline(this.hairOutlinePipeline)
1722
- for (const draw of hairOutlines) {
1723
- pass.setBindGroup(0, draw.bindGroup)
1724
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1725
- }
1726
- }
1727
- }
1728
1631
 
1729
1632
  private handleCanvasDoubleClick = (event: MouseEvent) => {
1730
1633
  if (!this.onRaycast || this.modelInstances.size === 0) return
@@ -1765,111 +1668,92 @@ export class Engine {
1765
1668
  this.onRaycast?.("", null, screenX, screenY)
1766
1669
  return
1767
1670
  }
1671
+ const dpr = window.devicePixelRatio || 1
1672
+ this.pendingPick = { x: Math.floor(screenX * dpr), y: Math.floor(screenY * dpr) }
1673
+ }
1768
1674
 
1769
- const viewMatrix = this.camera.getViewMatrix()
1770
- const projectionMatrix = this.camera.getProjectionMatrix()
1771
- const rect = this.canvas.getBoundingClientRect()
1772
- const clipX = (screenX / rect.width) * 2 - 1
1773
- const clipY = 1 - (screenY / rect.height) * 2
1774
- const viewProjMatrix = projectionMatrix.multiply(viewMatrix)
1775
- const inverseViewProj = viewProjMatrix.inverse()
1776
- const transformPoint = (matrix: Mat4, point: Vec3): Vec3 => {
1777
- const m = matrix.values
1778
- const x = point.x, y = point.y, z = point.z
1779
- const result = new Vec3(
1780
- m[0] * x + m[4] * y + m[8] * z + m[12],
1781
- m[1] * x + m[5] * y + m[9] * z + m[13],
1782
- m[2] * x + m[6] * y + m[10] * z + m[14]
1783
- )
1784
- const w = m[3] * x + m[7] * y + m[11] * z + m[15]
1785
- return result.scale(w !== 0 ? 1 / w : 1)
1786
- }
1787
- const worldNear = transformPoint(inverseViewProj, new Vec3(clipX, clipY, -1))
1788
- const worldFar = transformPoint(inverseViewProj, new Vec3(clipX, clipY, 1))
1789
- const rayOrigin = this.camera.getPosition()
1790
- const rayDirection = worldFar.subtract(worldNear).normalize()
1791
-
1792
- const transformByMatrix = (matrix: Float32Array, offset: number, point: Vec3): Vec3 => {
1793
- const m = matrix, x = point.x, y = point.y, z = point.z
1794
- return new Vec3(
1795
- m[offset + 0] * x + m[offset + 4] * y + m[offset + 8] * z + m[offset + 12],
1796
- m[offset + 1] * x + m[offset + 5] * y + m[offset + 9] * z + m[offset + 13],
1797
- m[offset + 2] * x + m[offset + 6] * y + m[offset + 10] * z + m[offset + 14]
1798
- )
1799
- }
1675
+ private renderPickPass(encoder: GPUCommandEncoder): void {
1676
+ if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture) return
1800
1677
 
1801
- let closest: { modelName: string; materialName: string; distance: number } | null = null
1802
- const maxDistance = 1000
1678
+ const pass = encoder.beginRenderPass({
1679
+ colorAttachments: [{
1680
+ view: this.pickTexture.createView(),
1681
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1682
+ loadOp: "clear",
1683
+ storeOp: "store",
1684
+ }],
1685
+ depthStencilAttachment: {
1686
+ view: this.pickDepthTexture.createView(),
1687
+ depthClearValue: 1.0,
1688
+ depthLoadOp: "clear",
1689
+ depthStoreOp: "store",
1690
+ },
1691
+ })
1692
+
1693
+ pass.setPipeline(this.pickPipeline)
1694
+ pass.setBindGroup(0, this.pickPerFrameBindGroup)
1803
1695
 
1804
1696
  this.forEachInstance((inst) => {
1805
- const model = inst.model
1806
- const materials = model.getMaterials()
1807
- if (materials.length === 0) return
1808
- const baseVertices = model.getVertices()
1809
- const indices = model.getIndices()
1810
- const skinning = model.getSkinning()
1811
- if (!baseVertices?.length || !indices || !skinning) return
1812
-
1813
- const vertices = new Float32Array(baseVertices.length)
1814
- const skinMatrices = model.getSkinMatrices()
1815
- for (let i = 0; i < baseVertices.length; i += 8) {
1816
- const vertexIndex = i / 8
1817
- const position = new Vec3(baseVertices[i], baseVertices[i + 1], baseVertices[i + 2])
1818
- const j0 = skinning.joints[vertexIndex * 4], j1 = skinning.joints[vertexIndex * 4 + 1], j2 = skinning.joints[vertexIndex * 4 + 2], j3 = skinning.joints[vertexIndex * 4 + 3]
1819
- 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
1820
- const ws = w0 + w1 + w2 + w3
1821
- const nw = ws > 0.0001 ? [w0 / ws, w1 / ws, w2 / ws, w3 / ws] : [1, 0, 0, 0]
1822
- let sp = new Vec3(0, 0, 0)
1823
- for (let j = 0; j < 4; j++) {
1824
- if (nw[j] <= 0) continue
1825
- const transformed = transformByMatrix(skinMatrices, [j0, j1, j2, j3][j] * 16, position)
1826
- sp = sp.add(transformed.scale(nw[j]))
1827
- }
1828
- vertices[i] = sp.x
1829
- vertices[i + 1] = sp.y
1830
- vertices[i + 2] = sp.z
1831
- vertices[i + 3] = baseVertices[i + 3]
1832
- vertices[i + 4] = baseVertices[i + 4]
1833
- vertices[i + 5] = baseVertices[i + 5]
1834
- vertices[i + 6] = baseVertices[i + 6]
1835
- vertices[i + 7] = baseVertices[i + 7]
1697
+ pass.setVertexBuffer(0, inst.vertexBuffer)
1698
+ pass.setVertexBuffer(1, inst.jointsBuffer)
1699
+ pass.setVertexBuffer(2, inst.weightsBuffer)
1700
+ pass.setIndexBuffer(inst.indexBuffer, "uint32")
1701
+ pass.setBindGroup(1, inst.pickPerInstanceBindGroup)
1702
+ for (const draw of inst.pickDrawCalls) {
1703
+ pass.setBindGroup(2, draw.bindGroup)
1704
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1836
1705
  }
1706
+ })
1837
1707
 
1838
- for (let i = 0; i < indices.length; i += 3) {
1839
- const idx0 = indices[i] * 8, idx1 = indices[i + 1] * 8, idx2 = indices[i + 2] * 8
1840
- const v0 = new Vec3(vertices[idx0], vertices[idx0 + 1], vertices[idx0 + 2])
1841
- const v1 = new Vec3(vertices[idx1], vertices[idx1 + 1], vertices[idx1 + 2])
1842
- const v2 = new Vec3(vertices[idx2], vertices[idx2 + 1], vertices[idx2 + 2])
1843
- let triangleMaterialIndex = -1
1844
- let indexOffset = 0
1845
- for (let matIdx = 0; matIdx < materials.length; matIdx++) {
1846
- if (i >= indexOffset && i < indexOffset + materials[matIdx].vertexCount) {
1847
- triangleMaterialIndex = matIdx
1848
- break
1849
- }
1850
- indexOffset += materials[matIdx].vertexCount
1851
- }
1852
- if (triangleMaterialIndex === -1) continue
1853
- const edge1 = v1.subtract(v0), edge2 = v2.subtract(v0), h = rayDirection.cross(edge2), a = edge1.dot(h)
1854
- if (Math.abs(a) < 0.0001) continue
1855
- const f = 1 / a, s = rayOrigin.subtract(v0), u = f * s.dot(h)
1856
- if (u < 0 || u > 1) continue
1857
- const q = s.cross(edge1), v = f * rayDirection.dot(q)
1858
- if (v < 0 || u + v > 1) continue
1859
- const t = f * edge2.dot(q)
1860
- if (t <= 0.0001 || t >= maxDistance) continue
1861
- const triangleNormal = edge1.cross(edge2).normalize()
1862
- if (triangleNormal.dot(rayDirection) >= 0) continue
1863
- if (!closest || t < closest.distance) {
1864
- closest = { modelName: inst.name, materialName: materials[triangleMaterialIndex].name, distance: t }
1708
+ pass.end()
1709
+
1710
+ // Copy the single pixel under cursor to readback buffer
1711
+ const px = Math.min(this.pendingPick.x, this.pickTexture.width - 1)
1712
+ const py = Math.min(this.pendingPick.y, this.pickTexture.height - 1)
1713
+ encoder.copyTextureToBuffer(
1714
+ { texture: this.pickTexture, origin: { x: Math.max(0, px), y: Math.max(0, py) } },
1715
+ { buffer: this.pickReadbackBuffer, bytesPerRow: 256 },
1716
+ { width: 1, height: 1 }
1717
+ )
1718
+ }
1719
+
1720
+ private async resolvePickResult(screenX: number, screenY: number): Promise<void> {
1721
+ if (!this.onRaycast) return
1722
+ await this.pickReadbackBuffer.mapAsync(GPUMapMode.READ)
1723
+ const data = new Uint8Array(this.pickReadbackBuffer.getMappedRange())
1724
+ const modelId = data[0]
1725
+ const materialId = data[1]
1726
+ this.pickReadbackBuffer.unmap()
1727
+
1728
+ if (modelId === 0) {
1729
+ this.onRaycast("", null, screenX, screenY)
1730
+ return
1731
+ }
1732
+
1733
+ // Find model by 1-based index
1734
+ let idx = 1
1735
+ let hitModel = ""
1736
+ for (const [name] of this.modelInstances) {
1737
+ if (idx === modelId) { hitModel = name; break }
1738
+ idx++
1739
+ }
1740
+
1741
+ // Find material by 1-based index (skipping zero-vertex materials)
1742
+ let hitMaterial: string | null = null
1743
+ if (hitModel) {
1744
+ const inst = this.modelInstances.get(hitModel)
1745
+ if (inst) {
1746
+ const materials = inst.model.getMaterials()
1747
+ let matIdx = 0
1748
+ for (const mat of materials) {
1749
+ if (mat.vertexCount === 0) continue
1750
+ matIdx++
1751
+ if (matIdx === materialId) { hitMaterial = mat.name; break }
1865
1752
  }
1866
1753
  }
1867
- })
1868
-
1869
- if (this.onRaycast) {
1870
- const hit = closest as { modelName: string; materialName: string; distance: number } | null
1871
- this.onRaycast(hit?.modelName ?? "", hit?.materialName ?? null, screenX, screenY)
1872
1754
  }
1755
+
1756
+ this.onRaycast(hitModel, hitMaterial, screenX, screenY)
1873
1757
  }
1874
1758
 
1875
1759
  public render() {
@@ -1919,12 +1803,27 @@ export class Engine {
1919
1803
  const pass = encoder.beginRenderPass(this.renderPassDescriptor)
1920
1804
  if (hasModels) this.forEachInstance((inst) => this.renderOneModel(pass, inst))
1921
1805
  if (this.hasGround) this.renderGround(pass)
1922
-
1923
1806
  pass.end()
1807
+
1808
+ const pick = this.pendingPick
1809
+ if (pick && hasModels) this.renderPickPass(encoder)
1810
+
1924
1811
  this.device.queue.submit([encoder.finish()])
1812
+
1813
+ if (pick) {
1814
+ this.pendingPick = null
1815
+ const dpr = window.devicePixelRatio || 1
1816
+ this.resolvePickResult(pick.x / dpr, pick.y / dpr)
1817
+ }
1818
+
1925
1819
  this.updateStats(performance.now() - currentTime)
1926
1820
  }
1927
1821
 
1822
+ private updateRenderTarget() {
1823
+ const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
1824
+ colorAttachment.resolveTarget = this.context.getCurrentTexture().createView()
1825
+ }
1826
+
1928
1827
  private drawInstanceShadow(sp: GPURenderPassEncoder, inst: ModelInstance): void {
1929
1828
  sp.setBindGroup(0, inst.shadowBindGroup)
1930
1829
  sp.setVertexBuffer(0, inst.vertexBuffer)
@@ -1940,7 +1839,7 @@ export class Engine {
1940
1839
  pass.setPipeline(pipeline)
1941
1840
  for (const draw of inst.drawCalls) {
1942
1841
  if (draw.type === "opaque" && this.shouldRenderDrawCall(inst, draw)) {
1943
- pass.setBindGroup(0, draw.bindGroup)
1842
+ pass.setBindGroup(2, draw.bindGroup)
1944
1843
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1945
1844
  }
1946
1845
  }
@@ -1950,22 +1849,27 @@ export class Engine {
1950
1849
  pass.setPipeline(pipeline)
1951
1850
  for (const draw of inst.drawCalls) {
1952
1851
  if (draw.type === "transparent" && this.shouldRenderDrawCall(inst, draw)) {
1953
- pass.setBindGroup(0, draw.bindGroup)
1852
+ pass.setBindGroup(2, draw.bindGroup)
1954
1853
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1955
1854
  }
1956
1855
  }
1957
1856
  }
1958
1857
 
1858
+ private bindMainGroups(pass: GPURenderPassEncoder, inst: ModelInstance): void {
1859
+ pass.setBindGroup(0, this.perFrameBindGroup)
1860
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
1861
+ }
1862
+
1959
1863
  private renderOneModel(pass: GPURenderPassEncoder, inst: ModelInstance): void {
1960
1864
  pass.setVertexBuffer(0, inst.vertexBuffer)
1961
1865
  pass.setVertexBuffer(1, inst.jointsBuffer)
1962
1866
  pass.setVertexBuffer(2, inst.weightsBuffer)
1963
1867
  pass.setIndexBuffer(inst.indexBuffer, "uint32")
1964
1868
 
1869
+ this.bindMainGroups(pass, inst)
1965
1870
  this.drawOpaque(pass, inst, this.modelPipeline)
1966
- this.renderEyes(pass, inst)
1967
1871
  this.drawOutlines(pass, inst, false)
1968
- this.renderHair(pass, inst)
1872
+ this.bindMainGroups(pass, inst)
1969
1873
  this.drawTransparent(pass, inst, this.modelPipeline)
1970
1874
  this.drawOutlines(pass, inst, true)
1971
1875
  }
@@ -1983,10 +1887,6 @@ export class Engine {
1983
1887
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
1984
1888
  }
1985
1889
 
1986
- private updateRenderTarget() {
1987
- const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
1988
- colorAttachment.resolveTarget = this.context.getCurrentTexture().createView()
1989
- }
1990
1890
 
1991
1891
  private updateSkinMatrices() {
1992
1892
  this.forEachInstance((inst) => {
@@ -2003,10 +1903,12 @@ export class Engine {
2003
1903
 
2004
1904
  private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, transparent: boolean) {
2005
1905
  pass.setPipeline(this.outlinePipeline)
1906
+ pass.setBindGroup(0, this.outlinePerFrameBindGroup)
1907
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
2006
1908
  const outlineType: DrawCallType = transparent ? "transparent-outline" : "opaque-outline"
2007
1909
  for (const draw of inst.drawCalls) {
2008
1910
  if (draw.type === outlineType && this.shouldRenderDrawCall(inst, draw)) {
2009
- pass.setBindGroup(0, draw.bindGroup)
1911
+ pass.setBindGroup(2, draw.bindGroup)
2010
1912
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2011
1913
  }
2012
1914
  }