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