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/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);
@@ -678,7 +697,6 @@ export class Engine {
678
697
  let worldPos = skinnedPos.xyz;
679
698
  let worldNormal = normalize(skinnedNrm);
680
699
 
681
- // MMD invert hull: expand vertices outward along normals
682
700
  let scaleFactor = 0.01;
683
701
  let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
684
702
  output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
@@ -686,13 +704,7 @@ export class Engine {
686
704
  }
687
705
 
688
706
  @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;
707
+ return material.edgeColor;
696
708
  }
697
709
  `,
698
710
  })
@@ -711,57 +723,9 @@ export class Engine {
711
723
  },
712
724
  })
713
725
 
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",
724
- depthWriteEnabled: false,
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
- },
760
- })
761
-
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",
726
+ // GPU picking: encode (modelIndex, materialIndex) as color
727
+ const pickShaderModule = this.device.createShaderModule({
728
+ label: "pick shader",
765
729
  code: /* wgsl */ `
766
730
  struct CameraUniforms {
767
731
  view: mat4x4f,
@@ -769,94 +733,92 @@ export class Engine {
769
733
  viewPos: vec3f,
770
734
  _padding: f32,
771
735
  };
736
+ struct PickId {
737
+ modelId: f32,
738
+ materialId: f32,
739
+ _p1: f32,
740
+ _p2: f32,
741
+ };
772
742
 
773
743
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
774
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
744
+ @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
745
+ @group(2) @binding(0) var<uniform> pickId: PickId;
775
746
 
776
747
  @vertex fn vs(
777
748
  @location(0) position: vec3f,
778
749
  @location(1) normal: vec3f,
750
+ @location(2) uv: vec2f,
779
751
  @location(3) joints0: vec4<u32>,
780
752
  @location(4) weights0: vec4<f32>
781
753
  ) -> @builtin(position) vec4f {
782
754
  let pos4 = vec4f(position, 1.0);
783
-
784
- // Branchless weight normalization (avoids GPU branch divergence)
785
755
  let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
786
756
  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;
757
+ let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
758
+ var sp = vec4f(0.0);
759
+ for (var i = 0u; i < 4u; i++) { sp += (skinMats[joints0[i]] * pos4) * nw[i]; }
760
+ return camera.projection * camera.view * vec4f(sp.xyz, 1.0);
799
761
  }
800
762
 
801
763
  @fragment fn fs() -> @location(0) vec4f {
802
- return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
764
+ return vec4f(pickId.modelId / 255.0, pickId.materialId / 255.0, 0.0, 1.0);
803
765
  }
804
766
  `,
805
767
  })
806
768
 
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,
769
+ this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
770
+ label: "pick per-frame layout",
771
+ entries: [
772
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
773
+ ],
774
+ })
775
+ this.pickPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
776
+ label: "pick per-instance layout",
777
+ entries: [
778
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
779
+ ],
780
+ })
781
+ this.pickPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
782
+ label: "pick per-material layout",
783
+ entries: [
784
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
785
+ ],
786
+ })
787
+
788
+ const pickPipelineLayout = this.device.createPipelineLayout({
789
+ label: "pick pipeline layout",
790
+ bindGroupLayouts: [this.pickPerFrameBindGroupLayout, this.pickPerInstanceBindGroupLayout, this.pickPerMaterialBindGroupLayout],
791
+ })
792
+
793
+ this.pickPerFrameBindGroup = this.device.createBindGroup({
794
+ label: "pick per-frame bind group",
795
+ layout: this.pickPerFrameBindGroupLayout,
796
+ entries: [
797
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
798
+ ],
799
+ })
800
+
801
+ this.pickPipeline = this.device.createRenderPipeline({
802
+ label: "pick pipeline",
803
+ layout: pickPipelineLayout,
804
+ vertex: { module: pickShaderModule, buffers: fullVertexBuffers },
805
+ fragment: {
806
+ module: pickShaderModule,
807
+ targets: [{ format: "rgba8unorm" }],
816
808
  },
817
- fragmentEntryPoint: "fs",
818
- cullMode: "none",
809
+ primitive: { cullMode: "none" },
819
810
  depthStencil: {
820
- format: "depth24plus-stencil8",
811
+ format: "depth24plus",
821
812
  depthWriteEnabled: true,
822
813
  depthCompare: "less-equal",
823
- depthBias: 0.0,
824
- depthBiasSlopeScale: 0.0,
825
- depthBiasClamp: 0.0,
826
814
  },
827
815
  })
828
816
 
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)
817
+ this.pickReadbackBuffer = this.device.createBuffer({
818
+ label: "pick readback",
819
+ size: 256,
820
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
821
+ })
860
822
  }
861
823
 
862
824
 
@@ -921,11 +883,26 @@ export class Engine {
921
883
  depthStoreOp: "store",
922
884
  stencilClearValue: 0,
923
885
  stencilLoadOp: "clear",
924
- stencilStoreOp: "discard", // Discard stencil after frame to save bandwidth (we only use it during rendering)
886
+ stencilStoreOp: "discard",
925
887
  },
926
888
  }
927
889
 
928
890
  this.camera.aspect = width / height
891
+
892
+ if (this.onRaycast) {
893
+ this.pickTexture = this.device.createTexture({
894
+ label: "pick render target",
895
+ size: [width, height],
896
+ format: "rgba8unorm",
897
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
898
+ })
899
+ this.pickDepthTexture = this.device.createTexture({
900
+ label: "pick depth",
901
+ size: [width, height],
902
+ format: "depth24plus",
903
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
904
+ })
905
+ }
929
906
  }
930
907
  }
931
908
 
@@ -943,9 +920,9 @@ export class Engine {
943
920
  this.camera.attachControl(this.canvas)
944
921
  }
945
922
 
946
- /** Set camera look-at target to a point. Clears any model binding. */
923
+ /** Set static camera look-at / orbit center. Clears any model follow binding. */
947
924
  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. */
925
+ /** Bind camera orbit center to a model's bone (Souls-style follow cam). Pass null to unbind. */
949
926
  public setCameraTarget(model: Model | null, boneName: string, offset?: Vec3): void
950
927
  public setCameraTarget(modelOrVec: Model | Vec3 | null, boneName?: string, offset?: Vec3): void {
951
928
  if (modelOrVec === null) {
@@ -966,6 +943,26 @@ export class Engine {
966
943
  this.cameraTargetOffset.z = offset?.z ?? 0
967
944
  }
968
945
 
946
+ /** Souls-style follow cam: orbit center tracks a model bone each frame. Shorthand for setCameraTarget(model, boneName, offset). */
947
+ public setCameraFollow(model: Model | null, boneName?: string, offset?: Vec3): void {
948
+ if (model === null) {
949
+ this.cameraTargetModel = null
950
+ return
951
+ }
952
+ this.cameraTargetModel = model
953
+ this.cameraTargetBoneName = boneName ?? "全ての親"
954
+ this.cameraTargetOffset.x = offset?.x ?? 0
955
+ this.cameraTargetOffset.y = offset?.y ?? 0
956
+ this.cameraTargetOffset.z = offset?.z ?? 0
957
+ }
958
+
959
+ public getCameraDistance(): number { return this.camera.radius }
960
+ public setCameraDistance(d: number): void { this.camera.radius = d }
961
+ public getCameraAlpha(): number { return this.camera.alpha }
962
+ public setCameraAlpha(a: number): void { this.camera.alpha = a }
963
+ public getCameraBeta(): number { return this.camera.beta }
964
+ public setCameraBeta(b: number): void { this.camera.beta = b }
965
+
969
966
  // Step 5: Create lighting buffers
970
967
  private setupLighting() {
971
968
  this.lightUniformBuffer = this.device.createBuffer({
@@ -1126,10 +1123,6 @@ export class Engine {
1126
1123
  return key
1127
1124
  }
1128
1125
 
1129
- public async registerModel(model: Model, pmxPath: string): Promise<string> {
1130
- return this.addModel(model, pmxPath)
1131
- }
1132
-
1133
1126
  public removeModel(name: string): void {
1134
1127
  this.modelInstances.delete(name)
1135
1128
  }
@@ -1200,12 +1193,8 @@ export class Engine {
1200
1193
  })
1201
1194
  }
1202
1195
 
1203
- private instances(): IterableIterator<ModelInstance> {
1204
- return this.modelInstances.values()
1205
- }
1206
-
1207
1196
  private forEachInstance(fn: (inst: ModelInstance) => void): void {
1208
- for (const inst of this.instances()) fn(inst)
1197
+ for (const inst of this.modelInstances.values()) fn(inst)
1209
1198
  }
1210
1199
 
1211
1200
  private updateInstances(deltaTime: number): void {
@@ -1297,6 +1286,22 @@ export class Engine {
1297
1286
  ],
1298
1287
  })
1299
1288
 
1289
+ const mainPerInstanceBindGroup = this.device.createBindGroup({
1290
+ label: `${name}: main per-instance bind group`,
1291
+ layout: this.mainPerInstanceBindGroupLayout,
1292
+ entries: [
1293
+ { binding: 0, resource: { buffer: skinMatrixBuffer } },
1294
+ ],
1295
+ })
1296
+
1297
+ const pickPerInstanceBindGroup = this.device.createBindGroup({
1298
+ label: `${name}: pick per-instance bind group`,
1299
+ layout: this.pickPerInstanceBindGroupLayout,
1300
+ entries: [
1301
+ { binding: 0, resource: { buffer: skinMatrixBuffer } },
1302
+ ],
1303
+ })
1304
+
1300
1305
  const inst: ModelInstance = {
1301
1306
  name,
1302
1307
  model,
@@ -1309,6 +1314,9 @@ export class Engine {
1309
1314
  drawCalls: [],
1310
1315
  shadowDrawCalls: [],
1311
1316
  shadowBindGroup,
1317
+ mainPerInstanceBindGroup,
1318
+ pickPerInstanceBindGroup,
1319
+ pickDrawCalls: [],
1312
1320
  hiddenMaterials: new Set(),
1313
1321
  physics,
1314
1322
  vertexBufferNeedsUpdate: false,
@@ -1457,6 +1465,8 @@ export class Engine {
1457
1465
  if (materials.length === 0) throw new Error("Model has no materials")
1458
1466
  const textures = model.getTextures()
1459
1467
  const prefix = `${inst.name}: `
1468
+ // 1-based so that (0,0) = clear color = "no hit"
1469
+ const modelId = this.modelInstances.size + 1
1460
1470
 
1461
1471
  const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
1462
1472
  if (texIndex < 0 || texIndex >= textures.length) return null
@@ -1465,9 +1475,11 @@ export class Engine {
1465
1475
  }
1466
1476
 
1467
1477
  let currentIndexOffset = 0
1478
+ let materialId = 0
1468
1479
  for (const mat of materials) {
1469
1480
  const indexCount = mat.vertexCount
1470
1481
  if (indexCount === 0) continue
1482
+ materialId++
1471
1483
 
1472
1484
  const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex)
1473
1485
  if (!diffuseTexture) throw new Error(`Material "${mat.name}" has no diffuse texture`)
@@ -1478,73 +1490,24 @@ export class Engine {
1478
1490
  const materialUniformBuffer = this.createMaterialUniformBuffer(
1479
1491
  prefix + mat.name,
1480
1492
  materialAlpha,
1481
- 0.0,
1482
1493
  [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]],
1483
1494
  mat.ambient,
1484
1495
  mat.specular,
1485
1496
  mat.shininess
1486
1497
  )
1487
1498
 
1499
+ const textureView = diffuseTexture.createView()
1488
1500
  const bindGroup = this.device.createBindGroup({
1489
1501
  label: `${prefix}material: ${mat.name}`,
1490
- layout: this.mainBindGroupLayout,
1502
+ layout: this.mainPerMaterialBindGroupLayout,
1491
1503
  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 } },
1504
+ { binding: 0, resource: textureView },
1505
+ { binding: 1, resource: { buffer: materialUniformBuffer } },
1498
1506
  ],
1499
1507
  })
1500
1508
 
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
- }
1509
+ const type: DrawCallType = isTransparent ? "transparent" : "opaque"
1510
+ inst.drawCalls.push({ type, count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
1548
1511
 
1549
1512
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1550
1513
  const materialUniformData = new Float32Array([
@@ -1554,32 +1517,37 @@ export class Engine {
1554
1517
  const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData)
1555
1518
  const outlineBindGroup = this.device.createBindGroup({
1556
1519
  label: `${prefix}outline: ${mat.name}`,
1557
- layout: this.outlineBindGroupLayout,
1520
+ layout: this.outlinePerMaterialBindGroupLayout,
1558
1521
  entries: [
1559
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1560
- { binding: 1, resource: { buffer: outlineUniformBuffer } },
1561
- { binding: 2, resource: { buffer: inst.skinMatrixBuffer } },
1522
+ { binding: 0, resource: { buffer: outlineUniformBuffer } },
1562
1523
  ],
1563
1524
  })
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
- }
1525
+ const outlineType: DrawCallType = isTransparent ? "transparent-outline" : "opaque-outline"
1526
+ inst.drawCalls.push({ type: outlineType, count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup, materialName: mat.name })
1527
+ }
1528
+
1529
+ if (this.onRaycast) {
1530
+ const pickIdData = new Float32Array([modelId, materialId, 0, 0])
1531
+ const pickIdBuffer = this.createUniformBuffer(`${prefix}pick: ${mat.name}`, pickIdData)
1532
+ const pickBindGroup = this.device.createBindGroup({
1533
+ label: `${prefix}pick: ${mat.name}`,
1534
+ layout: this.pickPerMaterialBindGroupLayout,
1535
+ entries: [{ binding: 0, resource: { buffer: pickIdBuffer } }],
1536
+ })
1537
+ inst.pickDrawCalls.push({ count: indexCount, firstIndex: currentIndexOffset, bindGroup: pickBindGroup })
1568
1538
  }
1569
1539
 
1570
1540
  currentIndexOffset += indexCount
1571
1541
  }
1572
1542
 
1573
1543
  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)
1544
+ if (d.type === "opaque") inst.shadowDrawCalls.push(d)
1576
1545
  }
1577
1546
  }
1578
1547
 
1579
1548
  private createMaterialUniformBuffer(
1580
1549
  label: string,
1581
1550
  alpha: number,
1582
- isOverEyes: number,
1583
1551
  diffuseColor: [number, number, number],
1584
1552
  ambientColor: [number, number, number],
1585
1553
  specularColor: [number, number, number],
@@ -1588,25 +1556,13 @@ export class Engine {
1588
1556
  const data = new Float32Array(20)
1589
1557
  data.set([
1590
1558
  alpha,
1591
- 1.0,
1592
1559
  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
1560
+ shininess,
1561
+ 0.0,
1562
+ 1.0, 1.0, 1.0, 0.0, // rimColor (vec3), _padding2
1563
+ diffuseColor[0], diffuseColor[1], diffuseColor[2], 0.0,
1564
+ ambientColor[0], ambientColor[1], ambientColor[2], 0.0,
1565
+ specularColor[0], specularColor[1], specularColor[2], 0.0,
1610
1566
  ])
1611
1567
  return this.createUniformBuffer(`material uniform: ${label}`, data)
1612
1568
  }
@@ -1659,17 +1615,6 @@ export class Engine {
1659
1615
  }
1660
1616
  }
1661
1617
 
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
1618
 
1674
1619
  private renderGround(pass: GPURenderPassEncoder) {
1675
1620
  if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
@@ -1680,51 +1625,6 @@ export class Engine {
1680
1625
  pass.drawIndexed(this.groundDrawCall.count, 1, this.groundDrawCall.firstIndex, 0, 0)
1681
1626
  }
1682
1627
 
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
1628
 
1729
1629
  private handleCanvasDoubleClick = (event: MouseEvent) => {
1730
1630
  if (!this.onRaycast || this.modelInstances.size === 0) return
@@ -1765,111 +1665,92 @@ export class Engine {
1765
1665
  this.onRaycast?.("", null, screenX, screenY)
1766
1666
  return
1767
1667
  }
1668
+ const dpr = window.devicePixelRatio || 1
1669
+ this.pendingPick = { x: Math.floor(screenX * dpr), y: Math.floor(screenY * dpr) }
1670
+ }
1768
1671
 
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
- }
1672
+ private renderPickPass(encoder: GPUCommandEncoder): void {
1673
+ if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture) return
1800
1674
 
1801
- let closest: { modelName: string; materialName: string; distance: number } | null = null
1802
- const maxDistance = 1000
1675
+ const pass = encoder.beginRenderPass({
1676
+ colorAttachments: [{
1677
+ view: this.pickTexture.createView(),
1678
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1679
+ loadOp: "clear",
1680
+ storeOp: "store",
1681
+ }],
1682
+ depthStencilAttachment: {
1683
+ view: this.pickDepthTexture.createView(),
1684
+ depthClearValue: 1.0,
1685
+ depthLoadOp: "clear",
1686
+ depthStoreOp: "store",
1687
+ },
1688
+ })
1689
+
1690
+ pass.setPipeline(this.pickPipeline)
1691
+ pass.setBindGroup(0, this.pickPerFrameBindGroup)
1803
1692
 
1804
1693
  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]
1694
+ pass.setVertexBuffer(0, inst.vertexBuffer)
1695
+ pass.setVertexBuffer(1, inst.jointsBuffer)
1696
+ pass.setVertexBuffer(2, inst.weightsBuffer)
1697
+ pass.setIndexBuffer(inst.indexBuffer, "uint32")
1698
+ pass.setBindGroup(1, inst.pickPerInstanceBindGroup)
1699
+ for (const draw of inst.pickDrawCalls) {
1700
+ pass.setBindGroup(2, draw.bindGroup)
1701
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1836
1702
  }
1703
+ })
1837
1704
 
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 }
1705
+ pass.end()
1706
+
1707
+ // Copy the single pixel under cursor to readback buffer
1708
+ const px = Math.min(this.pendingPick.x, this.pickTexture.width - 1)
1709
+ const py = Math.min(this.pendingPick.y, this.pickTexture.height - 1)
1710
+ encoder.copyTextureToBuffer(
1711
+ { texture: this.pickTexture, origin: { x: Math.max(0, px), y: Math.max(0, py) } },
1712
+ { buffer: this.pickReadbackBuffer, bytesPerRow: 256 },
1713
+ { width: 1, height: 1 }
1714
+ )
1715
+ }
1716
+
1717
+ private async resolvePickResult(screenX: number, screenY: number): Promise<void> {
1718
+ if (!this.onRaycast) return
1719
+ await this.pickReadbackBuffer.mapAsync(GPUMapMode.READ)
1720
+ const data = new Uint8Array(this.pickReadbackBuffer.getMappedRange())
1721
+ const modelId = data[0]
1722
+ const materialId = data[1]
1723
+ this.pickReadbackBuffer.unmap()
1724
+
1725
+ if (modelId === 0) {
1726
+ this.onRaycast("", null, screenX, screenY)
1727
+ return
1728
+ }
1729
+
1730
+ // Find model by 1-based index
1731
+ let idx = 1
1732
+ let hitModel = ""
1733
+ for (const [name] of this.modelInstances) {
1734
+ if (idx === modelId) { hitModel = name; break }
1735
+ idx++
1736
+ }
1737
+
1738
+ // Find material by 1-based index (skipping zero-vertex materials)
1739
+ let hitMaterial: string | null = null
1740
+ if (hitModel) {
1741
+ const inst = this.modelInstances.get(hitModel)
1742
+ if (inst) {
1743
+ const materials = inst.model.getMaterials()
1744
+ let matIdx = 0
1745
+ for (const mat of materials) {
1746
+ if (mat.vertexCount === 0) continue
1747
+ matIdx++
1748
+ if (matIdx === materialId) { hitMaterial = mat.name; break }
1865
1749
  }
1866
1750
  }
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
1751
  }
1752
+
1753
+ this.onRaycast(hitModel, hitMaterial, screenX, screenY)
1873
1754
  }
1874
1755
 
1875
1756
  public render() {
@@ -1919,12 +1800,27 @@ export class Engine {
1919
1800
  const pass = encoder.beginRenderPass(this.renderPassDescriptor)
1920
1801
  if (hasModels) this.forEachInstance((inst) => this.renderOneModel(pass, inst))
1921
1802
  if (this.hasGround) this.renderGround(pass)
1922
-
1923
1803
  pass.end()
1804
+
1805
+ const pick = this.pendingPick
1806
+ if (pick && hasModels) this.renderPickPass(encoder)
1807
+
1924
1808
  this.device.queue.submit([encoder.finish()])
1809
+
1810
+ if (pick) {
1811
+ this.pendingPick = null
1812
+ const dpr = window.devicePixelRatio || 1
1813
+ this.resolvePickResult(pick.x / dpr, pick.y / dpr)
1814
+ }
1815
+
1925
1816
  this.updateStats(performance.now() - currentTime)
1926
1817
  }
1927
1818
 
1819
+ private updateRenderTarget() {
1820
+ const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
1821
+ colorAttachment.resolveTarget = this.context.getCurrentTexture().createView()
1822
+ }
1823
+
1928
1824
  private drawInstanceShadow(sp: GPURenderPassEncoder, inst: ModelInstance): void {
1929
1825
  sp.setBindGroup(0, inst.shadowBindGroup)
1930
1826
  sp.setVertexBuffer(0, inst.vertexBuffer)
@@ -1940,7 +1836,7 @@ export class Engine {
1940
1836
  pass.setPipeline(pipeline)
1941
1837
  for (const draw of inst.drawCalls) {
1942
1838
  if (draw.type === "opaque" && this.shouldRenderDrawCall(inst, draw)) {
1943
- pass.setBindGroup(0, draw.bindGroup)
1839
+ pass.setBindGroup(2, draw.bindGroup)
1944
1840
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1945
1841
  }
1946
1842
  }
@@ -1950,22 +1846,27 @@ export class Engine {
1950
1846
  pass.setPipeline(pipeline)
1951
1847
  for (const draw of inst.drawCalls) {
1952
1848
  if (draw.type === "transparent" && this.shouldRenderDrawCall(inst, draw)) {
1953
- pass.setBindGroup(0, draw.bindGroup)
1849
+ pass.setBindGroup(2, draw.bindGroup)
1954
1850
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1955
1851
  }
1956
1852
  }
1957
1853
  }
1958
1854
 
1855
+ private bindMainGroups(pass: GPURenderPassEncoder, inst: ModelInstance): void {
1856
+ pass.setBindGroup(0, this.perFrameBindGroup)
1857
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
1858
+ }
1859
+
1959
1860
  private renderOneModel(pass: GPURenderPassEncoder, inst: ModelInstance): void {
1960
1861
  pass.setVertexBuffer(0, inst.vertexBuffer)
1961
1862
  pass.setVertexBuffer(1, inst.jointsBuffer)
1962
1863
  pass.setVertexBuffer(2, inst.weightsBuffer)
1963
1864
  pass.setIndexBuffer(inst.indexBuffer, "uint32")
1964
1865
 
1866
+ this.bindMainGroups(pass, inst)
1965
1867
  this.drawOpaque(pass, inst, this.modelPipeline)
1966
- this.renderEyes(pass, inst)
1967
1868
  this.drawOutlines(pass, inst, false)
1968
- this.renderHair(pass, inst)
1869
+ this.bindMainGroups(pass, inst)
1969
1870
  this.drawTransparent(pass, inst, this.modelPipeline)
1970
1871
  this.drawOutlines(pass, inst, true)
1971
1872
  }
@@ -1983,10 +1884,6 @@ export class Engine {
1983
1884
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
1984
1885
  }
1985
1886
 
1986
- private updateRenderTarget() {
1987
- const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
1988
- colorAttachment.resolveTarget = this.context.getCurrentTexture().createView()
1989
- }
1990
1887
 
1991
1888
  private updateSkinMatrices() {
1992
1889
  this.forEachInstance((inst) => {
@@ -2003,10 +1900,12 @@ export class Engine {
2003
1900
 
2004
1901
  private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, transparent: boolean) {
2005
1902
  pass.setPipeline(this.outlinePipeline)
1903
+ pass.setBindGroup(0, this.outlinePerFrameBindGroup)
1904
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
2006
1905
  const outlineType: DrawCallType = transparent ? "transparent-outline" : "opaque-outline"
2007
1906
  for (const draw of inst.drawCalls) {
2008
1907
  if (draw.type === outlineType && this.shouldRenderDrawCall(inst, draw)) {
2009
- pass.setBindGroup(0, draw.bindGroup)
1908
+ pass.setBindGroup(2, draw.bindGroup)
2010
1909
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2011
1910
  }
2012
1911
  }