reze-engine 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Camera } from "./camera";
2
2
  import { Mat4, Vec3 } from "./math";
3
+ import { PmxLoader } from "./pmx-loader";
3
4
  import { Physics } from "./physics";
4
5
  export const DEFAULT_ENGINE_OPTIONS = {
5
6
  ambientColor: new Vec3(0.88, 0.88, 0.88),
@@ -10,12 +11,11 @@ export const DEFAULT_ENGINE_OPTIONS = {
10
11
  cameraTarget: new Vec3(0, 12.5, 0),
11
12
  cameraFov: Math.PI / 4,
12
13
  onRaycast: undefined,
13
- multisampleCount: 4,
14
14
  };
15
15
  export class Engine {
16
16
  static getInstance() {
17
17
  if (!Engine.instance) {
18
- throw new Error("Engine not ready: create Engine, await init(), then load models via Model.loadFrom().");
18
+ throw new Error("Engine not ready: create Engine, await init(), then load models via engine.loadModel().");
19
19
  }
20
20
  return Engine.instance;
21
21
  }
@@ -24,22 +24,25 @@ export class Engine {
24
24
  this.lightData = new Float32Array(64);
25
25
  this.lightCount = 0;
26
26
  this.resizeObserver = null;
27
- this.sampleCount = 4;
28
- // Constants
29
- this.STENCIL_EYE_VALUE = 1;
30
- this.groundHasReflections = false;
31
- this.groundMode = "reflection";
27
+ this.hasGround = false;
32
28
  this.shadowLightVPMatrix = new Float32Array(16);
33
29
  this.groundDrawCall = null;
34
30
  this.shadowVPLightX = Number.NaN;
35
31
  this.shadowVPLightY = Number.NaN;
36
32
  this.shadowVPLightZ = Number.NaN;
37
- // Double-tap detection
38
33
  this.lastTouchTime = 0;
39
- this.DOUBLE_TAP_DELAY = 300; // ms
34
+ this.DOUBLE_TAP_DELAY = 300;
35
+ this.pendingPick = null;
40
36
  this.modelInstances = new Map();
41
37
  this.textureCache = new Map();
42
- this.raycastVertexBuffer = null;
38
+ this._nextDefaultModelId = 0;
39
+ // IK and physics enabled at engine level (same for all models)
40
+ this.ikEnabled = true;
41
+ this.physicsEnabled = true;
42
+ // Camera target binding (Babylon/Three style: camera follows model)
43
+ this.cameraTargetModel = null;
44
+ this.cameraTargetBoneName = "全ての親";
45
+ this.cameraTargetOffset = new Vec3(0, 0, 0);
43
46
  this.lastFpsUpdate = performance.now();
44
47
  this.framesSinceLastUpdate = 0;
45
48
  this.lastFrameTime = performance.now();
@@ -83,7 +86,6 @@ export class Engine {
83
86
  }
84
87
  };
85
88
  this.canvas = canvas;
86
- this.sampleCount = options?.multisampleCount ?? DEFAULT_ENGINE_OPTIONS.multisampleCount;
87
89
  if (options) {
88
90
  this.ambientColor = options.ambientColor ?? DEFAULT_ENGINE_OPTIONS.ambientColor;
89
91
  this.directionalLightIntensity =
@@ -138,7 +140,7 @@ export class Engine {
138
140
  : undefined,
139
141
  primitive: { cullMode: config.cullMode ?? "none" },
140
142
  depthStencil: config.depthStencil,
141
- multisample: config.multisample ?? { count: this.sampleCount },
143
+ multisample: config.multisample ?? { count: Engine.MULTISAMPLE_COUNT },
142
144
  });
143
145
  }
144
146
  createPipelines() {
@@ -184,23 +186,6 @@ export class Engine {
184
186
  attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
185
187
  },
186
188
  ];
187
- const depthOnlyVertexBuffers = [
188
- {
189
- arrayStride: 8 * 4,
190
- attributes: [
191
- { shaderLocation: 0, offset: 0, format: "float32x3" },
192
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
193
- ],
194
- },
195
- {
196
- arrayStride: 4 * 2,
197
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" }],
198
- },
199
- {
200
- arrayStride: 4,
201
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
202
- },
203
- ];
204
189
  const standardBlend = {
205
190
  format: this.presentationFormat,
206
191
  blend: {
@@ -238,17 +223,17 @@ export class Engine {
238
223
 
239
224
  struct MaterialUniforms {
240
225
  alpha: f32,
241
- alphaMultiplier: f32,
242
226
  rimIntensity: f32,
243
227
  shininess: f32,
228
+ _padding1: f32,
244
229
  rimColor: vec3f,
245
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
246
- diffuseColor: vec3f,
247
230
  _padding2: f32,
248
- ambientColor: vec3f,
231
+ diffuseColor: vec3f,
249
232
  _padding3: f32,
250
- specularColor: vec3f,
233
+ ambientColor: vec3f,
251
234
  _padding4: f32,
235
+ specularColor: vec3f,
236
+ _padding5: f32,
252
237
  };
253
238
 
254
239
  struct VertexOutput {
@@ -258,12 +243,15 @@ export class Engine {
258
243
  @location(2) worldPos: vec3f,
259
244
  };
260
245
 
246
+ // group 0: per-frame (bound once per pass)
261
247
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
262
248
  @group(0) @binding(1) var<uniform> light: LightUniforms;
263
- @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
264
- @group(0) @binding(3) var diffuseSampler: sampler;
265
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
266
- @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;
267
255
 
268
256
  @vertex fn vs(
269
257
  @location(0) position: vec3f,
@@ -275,7 +263,6 @@ export class Engine {
275
263
  var output: VertexOutput;
276
264
  let pos4 = vec4f(position, 1.0);
277
265
 
278
- // Branchless weight normalization (avoids GPU branch divergence)
279
266
  let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
280
267
  let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
281
268
  let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
@@ -299,11 +286,7 @@ export class Engine {
299
286
  }
300
287
 
301
288
  @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
302
- // Early alpha test - discard before expensive calculations
303
- var finalAlpha = material.alpha * material.alphaMultiplier;
304
- if (material.isOverEyes > 0.5) {
305
- finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
306
- }
289
+ let finalAlpha = material.alpha;
307
290
  if (finalAlpha < 0.001) {
308
291
  discard;
309
292
  }
@@ -311,18 +294,14 @@ export class Engine {
311
294
  let n = normalize(input.normal);
312
295
  let textureColor = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
313
296
 
314
- // View direction for specular and rim
315
297
  let viewDir = normalize(camera.viewPos - input.worldPos);
316
298
 
317
- // Simple lighting: global ambient + diffuse lighting
318
299
  let albedo = textureColor * material.diffuseColor;
319
300
 
320
- // Precompute material values
321
301
  let minSpec = light.ambientColor.w;
322
302
  let effectiveSpecular = max(material.specularColor, vec3f(minSpec));
323
303
  let specPower = max(material.shininess, 1.0);
324
304
 
325
- // Single directional light
326
305
  let l = -light.lights[0].direction.xyz;
327
306
  let nDotL = max(dot(n, l), 0.0);
328
307
  let intensity = light.lights[0].color.w;
@@ -330,7 +309,6 @@ export class Engine {
330
309
 
331
310
  let lightAccum = light.ambientColor.xyz + radiance * nDotL;
332
311
 
333
- // Blinn-Phong specular
334
312
  let h = normalize(l + viewDir);
335
313
  let nDotH = max(dot(n, h), 0.0);
336
314
  let specFactor = pow(nDotH, specPower);
@@ -338,9 +316,8 @@ export class Engine {
338
316
 
339
317
  let litColor = albedo * lightAccum;
340
318
 
341
- // Rim light calculation - proper Fresnel for edge-only highlights
342
319
  let fresnel = 1.0 - abs(dot(n, viewDir));
343
- let rimFactor = pow(fresnel, 4.0); // Higher power for sharper edge-only effect
320
+ let rimFactor = pow(fresnel, 4.0);
344
321
  let rimLight = material.rimColor * material.rimIntensity * rimFactor;
345
322
 
346
323
  let color = litColor + specularAccum + rimLight;
@@ -349,21 +326,42 @@ export class Engine {
349
326
  }
350
327
  `,
351
328
  });
352
- // Create explicit bind group layout for all pipelines using the main shader
353
- this.mainBindGroupLayout = this.device.createBindGroupLayout({
354
- 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",
332
+ entries: [
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",
355
341
  entries: [
356
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
357
- { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
358
- { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // diffuseTexture
359
- { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // diffuseSampler
360
- { binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
361
- { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
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" } },
362
351
  ],
363
352
  });
364
353
  const mainPipelineLayout = this.device.createPipelineLayout({
365
354
  label: "main pipeline layout",
366
- 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
+ ],
367
365
  });
368
366
  this.modelPipeline = this.createRenderPipeline({
369
367
  label: "model pipeline",
@@ -378,70 +376,6 @@ export class Engine {
378
376
  depthCompare: "less-equal",
379
377
  },
380
378
  });
381
- this.groundBindGroupLayout = this.device.createBindGroupLayout({
382
- label: "ground bind group layout",
383
- entries: [
384
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
385
- { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
386
- { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} },
387
- { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
388
- { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
389
- ],
390
- });
391
- const groundPipelineLayout = this.device.createPipelineLayout({
392
- label: "ground pipeline layout",
393
- bindGroupLayouts: [this.groundBindGroupLayout],
394
- });
395
- const groundShaderModule = this.device.createShaderModule({
396
- label: "ground shaders",
397
- code: /* wgsl */ `
398
- struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
399
- struct Light { direction: vec4f, color: vec4f, };
400
- struct LightUniforms { ambientColor: vec4f, lights: array<Light, 4>, };
401
- struct GroundMaterialUniforms { diffuseColor: vec3f, reflectionLevel: f32, fadeStart: f32, fadeEnd: f32, _a: f32, _b: f32, };
402
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
403
- @group(0) @binding(1) var<uniform> light: LightUniforms;
404
- @group(0) @binding(2) var reflectionTexture: texture_2d<f32>;
405
- @group(0) @binding(3) var reflectionSampler: sampler;
406
- @group(0) @binding(4) var<uniform> material: GroundMaterialUniforms;
407
- struct VertexOutput {
408
- @builtin(position) position: vec4f, @location(0) normal: vec3f, @location(1) uv: vec2f, @location(2) worldPos: vec3f,
409
- };
410
- @vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VertexOutput {
411
- var o: VertexOutput;
412
- o.worldPos = position;
413
- o.position = camera.projection * camera.view * vec4f(position, 1.0);
414
- o.normal = normal; o.uv = uv; return o;
415
- }
416
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
417
- let n = normalize(input.normal);
418
- let centerDist = length(input.worldPos.xz);
419
- let t = clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0);
420
- let edgeFade = 1.0 - smoothstep(0.0, 1.0, t);
421
- let clipPos = camera.projection * camera.view * vec4f(input.worldPos, 1.0);
422
- let ndcPos = clipPos.xyz / clipPos.w;
423
- let reflectionUV = vec2f(ndcPos.x * 0.5 + 0.5, 0.5 - ndcPos.y * 0.5);
424
- let sampledReflectionColor = textureSample(reflectionTexture, reflectionSampler, reflectionUV).rgb;
425
- let isValidReflection = clipPos.w > 0.0 && all(reflectionUV >= vec2f(0.0)) && all(reflectionUV <= vec2f(1.0));
426
- let reflectionColor = select(vec3f(1.0, 1.0, 1.0), sampledReflectionColor, isValidReflection);
427
- let fadeFactor = clamp((length(input.worldPos - camera.viewPos) - 15.0) / 20.0, 0.0, 1.0);
428
- let refl = reflectionColor * (1.0 - fadeFactor * 0.3);
429
- var finalColor = mix(material.diffuseColor, refl, material.reflectionLevel) * edgeFade;
430
- let l = -light.lights[0].direction.xyz;
431
- let lightAccum = light.ambientColor.xyz + light.lights[0].color.xyz * light.lights[0].color.w * max(dot(n, l), 0.0);
432
- return vec4f(finalColor * lightAccum, edgeFade);
433
- }
434
- `,
435
- });
436
- this.groundPipeline = this.createRenderPipeline({
437
- label: "ground pipeline",
438
- layout: groundPipelineLayout,
439
- shaderModule: groundShaderModule,
440
- vertexBuffers: fullVertexBuffers,
441
- fragmentTarget: standardBlend,
442
- cullMode: "back",
443
- depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
444
- });
445
379
  this.shadowLightVPBuffer = this.device.createBuffer({
446
380
  size: 64,
447
381
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -557,36 +491,30 @@ export class Engine {
557
491
  cullMode: "back",
558
492
  depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
559
493
  });
560
- // Create reflection pipeline (multisampled version for higher quality)
561
- this.reflectionPipeline = this.createRenderPipeline({
562
- label: "reflection pipeline",
563
- layout: mainPipelineLayout,
564
- shaderModule,
565
- vertexBuffers: fullVertexBuffers,
566
- fragmentTarget: {
567
- format: this.presentationFormat,
568
- blend: standardBlend.blend,
569
- },
570
- multisample: { count: this.sampleCount }, // Use same multisampling as main render
571
- cullMode: "none",
572
- depthStencil: {
573
- format: "depth24plus-stencil8",
574
- depthWriteEnabled: true,
575
- depthCompare: "less-equal",
576
- },
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
+ ],
577
500
  });
578
- // Create bind group layout for outline pipelines
579
- this.outlineBindGroupLayout = this.device.createBindGroupLayout({
580
- label: "outline bind group layout",
501
+ // Outline per-instance reuses mainPerInstanceBindGroupLayout (same skinMats binding)
502
+ this.outlinePerMaterialBindGroupLayout = this.device.createBindGroupLayout({
503
+ label: "outline per-material bind group layout",
581
504
  entries: [
582
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
583
- { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
584
- { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
505
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
585
506
  ],
586
507
  });
587
508
  const outlinePipelineLayout = this.device.createPipelineLayout({
588
509
  label: "outline pipeline layout",
589
- 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
+ ],
590
518
  });
591
519
  const outlineShaderModule = this.device.createShaderModule({
592
520
  label: "outline shaders",
@@ -601,14 +529,17 @@ export class Engine {
601
529
  struct MaterialUniforms {
602
530
  edgeColor: vec4f,
603
531
  edgeSize: f32,
604
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
605
532
  _padding1: f32,
606
533
  _padding2: f32,
534
+ _padding3: f32,
607
535
  };
608
536
 
537
+ // group 0: per-frame
609
538
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
610
- @group(0) @binding(1) var<uniform> material: MaterialUniforms;
611
- @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;
612
543
 
613
544
  struct VertexOutput {
614
545
  @builtin(position) position: vec4f,
@@ -623,7 +554,6 @@ export class Engine {
623
554
  var output: VertexOutput;
624
555
  let pos4 = vec4f(position, 1.0);
625
556
 
626
- // Branchless weight normalization (avoids GPU branch divergence)
627
557
  let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
628
558
  let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
629
559
  let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
@@ -641,7 +571,6 @@ export class Engine {
641
571
  let worldPos = skinnedPos.xyz;
642
572
  let worldNormal = normalize(skinnedNrm);
643
573
 
644
- // MMD invert hull: expand vertices outward along normals
645
574
  let scaleFactor = 0.01;
646
575
  let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
647
576
  output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
@@ -649,13 +578,7 @@ export class Engine {
649
578
  }
650
579
 
651
580
  @fragment fn fs() -> @location(0) vec4f {
652
- var color = material.edgeColor;
653
-
654
- if (material.isOverEyes > 0.5) {
655
- color.a *= 0.5; // Hair outlines over eyes get 50% alpha
656
- }
657
-
658
- return color;
581
+ return material.edgeColor;
659
582
  }
660
583
  `,
661
584
  });
@@ -672,55 +595,9 @@ export class Engine {
672
595
  depthCompare: "less-equal",
673
596
  },
674
597
  });
675
- // Hair outline pipeline
676
- this.hairOutlinePipeline = this.createRenderPipeline({
677
- label: "hair outline pipeline",
678
- layout: outlinePipelineLayout,
679
- shaderModule: outlineShaderModule,
680
- vertexBuffers: outlineVertexBuffers,
681
- fragmentTarget: standardBlend,
682
- cullMode: "back",
683
- depthStencil: {
684
- format: "depth24plus-stencil8",
685
- depthWriteEnabled: false,
686
- depthCompare: "less-equal",
687
- depthBias: -0.0001,
688
- depthBiasSlopeScale: 0.0,
689
- depthBiasClamp: 0.0,
690
- },
691
- });
692
- // Eye overlay pipeline (renders after opaque, writes stencil)
693
- this.eyePipeline = this.createRenderPipeline({
694
- label: "eye overlay pipeline",
695
- layout: mainPipelineLayout,
696
- shaderModule,
697
- vertexBuffers: fullVertexBuffers,
698
- fragmentTarget: standardBlend,
699
- cullMode: "front",
700
- depthStencil: {
701
- format: "depth24plus-stencil8",
702
- depthWriteEnabled: true,
703
- depthCompare: "less-equal",
704
- depthBias: -0.00005,
705
- depthBiasSlopeScale: 0.0,
706
- depthBiasClamp: 0.0,
707
- stencilFront: {
708
- compare: "always",
709
- failOp: "keep",
710
- depthFailOp: "keep",
711
- passOp: "replace",
712
- },
713
- stencilBack: {
714
- compare: "always",
715
- failOp: "keep",
716
- depthFailOp: "keep",
717
- passOp: "replace",
718
- },
719
- },
720
- });
721
- // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
722
- const depthOnlyShaderModule = this.device.createShaderModule({
723
- label: "depth only shader",
598
+ // GPU picking: encode (modelIndex, materialIndex) as color
599
+ const pickShaderModule = this.device.createShaderModule({
600
+ label: "pick shader",
724
601
  code: /* wgsl */ `
725
602
  struct CameraUniforms {
726
603
  view: mat4x4f,
@@ -728,91 +605,87 @@ export class Engine {
728
605
  viewPos: vec3f,
729
606
  _padding: f32,
730
607
  };
608
+ struct PickId {
609
+ modelId: f32,
610
+ materialId: f32,
611
+ _p1: f32,
612
+ _p2: f32,
613
+ };
731
614
 
732
615
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
733
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
616
+ @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
617
+ @group(2) @binding(0) var<uniform> pickId: PickId;
734
618
 
735
619
  @vertex fn vs(
736
620
  @location(0) position: vec3f,
737
621
  @location(1) normal: vec3f,
622
+ @location(2) uv: vec2f,
738
623
  @location(3) joints0: vec4<u32>,
739
624
  @location(4) weights0: vec4<f32>
740
625
  ) -> @builtin(position) vec4f {
741
626
  let pos4 = vec4f(position, 1.0);
742
-
743
- // Branchless weight normalization (avoids GPU branch divergence)
744
627
  let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
745
628
  let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
746
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
747
-
748
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
749
- for (var i = 0u; i < 4u; i++) {
750
- let j = joints0[i];
751
- let w = normalizedWeights[i];
752
- let m = skinMats[j];
753
- skinnedPos += (m * pos4) * w;
754
- }
755
- let worldPos = skinnedPos.xyz;
756
- let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
757
- return clipPos;
629
+ let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
630
+ var sp = vec4f(0.0);
631
+ for (var i = 0u; i < 4u; i++) { sp += (skinMats[joints0[i]] * pos4) * nw[i]; }
632
+ return camera.projection * camera.view * vec4f(sp.xyz, 1.0);
758
633
  }
759
634
 
760
635
  @fragment fn fs() -> @location(0) vec4f {
761
- return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
636
+ return vec4f(pickId.modelId / 255.0, pickId.materialId / 255.0, 0.0, 1.0);
762
637
  }
763
638
  `,
764
639
  });
765
- // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
766
- this.hairDepthPipeline = this.createRenderPipeline({
767
- label: "hair depth pre-pass",
768
- layout: mainPipelineLayout,
769
- shaderModule: depthOnlyShaderModule,
770
- vertexBuffers: depthOnlyVertexBuffers,
771
- fragmentTarget: {
772
- format: this.presentationFormat,
773
- writeMask: 0,
640
+ this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
641
+ label: "pick per-frame layout",
642
+ entries: [
643
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
644
+ ],
645
+ });
646
+ this.pickPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
647
+ label: "pick per-instance layout",
648
+ entries: [
649
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
650
+ ],
651
+ });
652
+ this.pickPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
653
+ label: "pick per-material layout",
654
+ entries: [
655
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
656
+ ],
657
+ });
658
+ const pickPipelineLayout = this.device.createPipelineLayout({
659
+ label: "pick pipeline layout",
660
+ bindGroupLayouts: [this.pickPerFrameBindGroupLayout, this.pickPerInstanceBindGroupLayout, this.pickPerMaterialBindGroupLayout],
661
+ });
662
+ this.pickPerFrameBindGroup = this.device.createBindGroup({
663
+ label: "pick per-frame bind group",
664
+ layout: this.pickPerFrameBindGroupLayout,
665
+ entries: [
666
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
667
+ ],
668
+ });
669
+ this.pickPipeline = this.device.createRenderPipeline({
670
+ label: "pick pipeline",
671
+ layout: pickPipelineLayout,
672
+ vertex: { module: pickShaderModule, buffers: fullVertexBuffers },
673
+ fragment: {
674
+ module: pickShaderModule,
675
+ targets: [{ format: "rgba8unorm" }],
774
676
  },
775
- fragmentEntryPoint: "fs",
776
- cullMode: "none",
677
+ primitive: { cullMode: "none" },
777
678
  depthStencil: {
778
- format: "depth24plus-stencil8",
679
+ format: "depth24plus",
779
680
  depthWriteEnabled: true,
780
681
  depthCompare: "less-equal",
781
- depthBias: 0.0,
782
- depthBiasSlopeScale: 0.0,
783
- depthBiasClamp: 0.0,
784
682
  },
785
683
  });
786
- // Hair pipelines for rendering over eyes vs non-eyes (only differ in stencil compare mode)
787
- const createHairPipeline = (isOverEyes) => {
788
- return this.createRenderPipeline({
789
- label: `hair pipeline (${isOverEyes ? "over eyes" : "over non-eyes"})`,
790
- layout: mainPipelineLayout,
791
- shaderModule,
792
- vertexBuffers: fullVertexBuffers,
793
- fragmentTarget: standardBlend,
794
- cullMode: "none",
795
- depthStencil: {
796
- format: "depth24plus-stencil8",
797
- depthWriteEnabled: false,
798
- depthCompare: "less-equal",
799
- stencilFront: {
800
- compare: isOverEyes ? "equal" : "not-equal",
801
- failOp: "keep",
802
- depthFailOp: "keep",
803
- passOp: "keep",
804
- },
805
- stencilBack: {
806
- compare: isOverEyes ? "equal" : "not-equal",
807
- failOp: "keep",
808
- depthFailOp: "keep",
809
- passOp: "keep",
810
- },
811
- },
812
- });
813
- };
814
- this.hairPipelineOverEyes = createHairPipeline(true);
815
- this.hairPipelineOverNonEyes = createHairPipeline(false);
684
+ this.pickReadbackBuffer = this.device.createBuffer({
685
+ label: "pick readback",
686
+ size: 256,
687
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
688
+ });
816
689
  }
817
690
  // Step 3: Setup canvas resize handling
818
691
  setupResize() {
@@ -837,33 +710,25 @@ export class Engine {
837
710
  this.multisampleTexture = this.device.createTexture({
838
711
  label: "multisample render target",
839
712
  size: [width, height],
840
- sampleCount: this.sampleCount,
713
+ sampleCount: Engine.MULTISAMPLE_COUNT,
841
714
  format: this.presentationFormat,
842
715
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
843
716
  });
844
717
  this.depthTexture = this.device.createTexture({
845
718
  label: "depth texture",
846
719
  size: [width, height],
847
- sampleCount: this.sampleCount,
720
+ sampleCount: Engine.MULTISAMPLE_COUNT,
848
721
  format: "depth24plus-stencil8",
849
722
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
850
723
  });
851
724
  const depthTextureView = this.depthTexture.createView();
852
- // Render directly to canvas
853
- const colorAttachment = this.sampleCount > 1
854
- ? {
855
- view: this.multisampleTexture.createView(),
856
- resolveTarget: this.context.getCurrentTexture().createView(),
857
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
858
- loadOp: "clear",
859
- storeOp: "store",
860
- }
861
- : {
862
- view: this.context.getCurrentTexture().createView(),
863
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
864
- loadOp: "clear",
865
- storeOp: "store",
866
- };
725
+ const colorAttachment = {
726
+ view: this.multisampleTexture.createView(),
727
+ resolveTarget: this.context.getCurrentTexture().createView(),
728
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
729
+ loadOp: "clear",
730
+ storeOp: "store",
731
+ };
867
732
  this.renderPassDescriptor = {
868
733
  label: "renderPass",
869
734
  colorAttachments: [colorAttachment],
@@ -874,10 +739,24 @@ export class Engine {
874
739
  depthStoreOp: "store",
875
740
  stencilClearValue: 0,
876
741
  stencilLoadOp: "clear",
877
- stencilStoreOp: "discard", // Discard stencil after frame to save bandwidth (we only use it during rendering)
742
+ stencilStoreOp: "discard",
878
743
  },
879
744
  };
880
745
  this.camera.aspect = width / height;
746
+ if (this.onRaycast) {
747
+ this.pickTexture = this.device.createTexture({
748
+ label: "pick render target",
749
+ size: [width, height],
750
+ format: "rgba8unorm",
751
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
752
+ });
753
+ this.pickDepthTexture = this.device.createTexture({
754
+ label: "pick depth",
755
+ size: [width, height],
756
+ format: "depth24plus",
757
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
758
+ });
759
+ }
881
760
  }
882
761
  }
883
762
  // Step 4: Create camera and uniform buffer
@@ -891,6 +770,42 @@ export class Engine {
891
770
  this.camera.aspect = this.canvas.width / this.canvas.height;
892
771
  this.camera.attachControl(this.canvas);
893
772
  }
773
+ setCameraTarget(modelOrVec, boneName, offset) {
774
+ if (modelOrVec === null) {
775
+ this.cameraTargetModel = null;
776
+ return;
777
+ }
778
+ if ("x" in modelOrVec && "y" in modelOrVec && "z" in modelOrVec) {
779
+ this.cameraTargetModel = null;
780
+ this.camera.target.x = modelOrVec.x;
781
+ this.camera.target.y = modelOrVec.y;
782
+ this.camera.target.z = modelOrVec.z;
783
+ return;
784
+ }
785
+ this.cameraTargetModel = modelOrVec;
786
+ this.cameraTargetBoneName = boneName ?? "";
787
+ this.cameraTargetOffset.x = offset?.x ?? 0;
788
+ this.cameraTargetOffset.y = offset?.y ?? 0;
789
+ this.cameraTargetOffset.z = offset?.z ?? 0;
790
+ }
791
+ /** Souls-style follow cam: orbit center tracks a model bone each frame. Shorthand for setCameraTarget(model, boneName, offset). */
792
+ setCameraFollow(model, boneName, offset) {
793
+ if (model === null) {
794
+ this.cameraTargetModel = null;
795
+ return;
796
+ }
797
+ this.cameraTargetModel = model;
798
+ this.cameraTargetBoneName = boneName ?? "全ての親";
799
+ this.cameraTargetOffset.x = offset?.x ?? 0;
800
+ this.cameraTargetOffset.y = offset?.y ?? 0;
801
+ this.cameraTargetOffset.z = offset?.z ?? 0;
802
+ }
803
+ getCameraDistance() { return this.camera.radius; }
804
+ setCameraDistance(d) { this.camera.radius = d; }
805
+ getCameraAlpha() { return this.camera.alpha; }
806
+ setCameraAlpha(a) { this.camera.alpha = a; }
807
+ getCameraBeta() { return this.camera.beta; }
808
+ setCameraBeta(b) { this.camera.beta = b; }
894
809
  // Step 5: Create lighting buffers
895
810
  setupLighting() {
896
811
  this.lightUniformBuffer = this.device.createBuffer({
@@ -943,30 +858,20 @@ export class Engine {
943
858
  width: 100,
944
859
  height: 100,
945
860
  diffuseColor: new Vec3(1, 1, 1),
946
- reflectionLevel: 0.5,
947
- reflectionTextureSize: 1024,
948
861
  fadeStart: 5.0,
949
862
  fadeEnd: 60.0,
950
- mode: "reflection",
951
863
  shadowMapSize: 4096,
952
864
  shadowStrength: 1.0,
953
865
  ...options,
954
866
  };
955
- this.groundMode = opts.mode;
956
867
  this.createGroundGeometry(opts.width, opts.height);
957
- if (opts.mode === "reflection") {
958
- this.createGroundMaterialBuffer(opts.diffuseColor, opts.reflectionLevel, opts.fadeStart, opts.fadeEnd);
959
- this.createReflectionTexture(opts.reflectionTextureSize);
960
- }
961
- else {
962
- this.createShadowGroundResources(opts.shadowMapSize, opts.diffuseColor, opts.fadeStart, opts.fadeEnd, opts.shadowStrength);
963
- }
964
- this.groundHasReflections = true;
868
+ this.createShadowGroundResources(opts.shadowMapSize, opts.diffuseColor, opts.fadeStart, opts.fadeEnd, opts.shadowStrength);
869
+ this.hasGround = true;
965
870
  this.groundDrawCall = {
966
871
  type: "ground",
967
872
  count: 6,
968
873
  firstIndex: 0,
969
- bindGroup: (opts.mode === "reflection" ? this.groundReflectionBindGroup : this.groundShadowBindGroup),
874
+ bindGroup: this.groundShadowBindGroup,
970
875
  materialName: "Ground",
971
876
  };
972
877
  }
@@ -1011,6 +916,14 @@ export class Engine {
1011
916
  this.resizeObserver = null;
1012
917
  }
1013
918
  }
919
+ async loadModel(nameOrPath, path) {
920
+ const pmxPath = path === undefined ? nameOrPath : path;
921
+ const name = path === undefined ? "model_" + (this._nextDefaultModelId++) : nameOrPath;
922
+ const model = await PmxLoader.load(pmxPath);
923
+ model.setName(name);
924
+ await this.addModel(model, pmxPath, name);
925
+ return model;
926
+ }
1014
927
  async addModel(model, pmxPath, name) {
1015
928
  const requested = name ?? model.name;
1016
929
  let key = requested;
@@ -1024,9 +937,6 @@ export class Engine {
1024
937
  await this.setupModelInstance(key, model, basePath);
1025
938
  return key;
1026
939
  }
1027
- async registerModel(model, pmxPath) {
1028
- return this.addModel(model, pmxPath);
1029
- }
1030
940
  removeModel(name) {
1031
941
  this.modelInstances.delete(name);
1032
942
  }
@@ -1074,11 +984,17 @@ export class Engine {
1074
984
  const inst = this.modelInstances.get(modelName);
1075
985
  return inst ? !inst.hiddenMaterials.has(materialName) : false;
1076
986
  }
1077
- setModelIKEnabled(modelName, enabled) {
1078
- this.modelInstances.get(modelName)?.model.setIKEnabled(enabled);
987
+ setIKEnabled(enabled) {
988
+ this.ikEnabled = enabled;
989
+ }
990
+ getIKEnabled() {
991
+ return this.ikEnabled;
992
+ }
993
+ setPhysicsEnabled(enabled) {
994
+ this.physicsEnabled = enabled;
1079
995
  }
1080
- setModelPhysicsEnabled(modelName, enabled) {
1081
- this.modelInstances.get(modelName)?.model.setPhysicsEnabled(enabled);
996
+ getPhysicsEnabled() {
997
+ return this.physicsEnabled;
1082
998
  }
1083
999
  resetPhysics() {
1084
1000
  this.forEachInstance((inst) => {
@@ -1088,19 +1004,16 @@ export class Engine {
1088
1004
  inst.physics.reset(inst.model.getWorldMatrices(), inst.model.getBoneInverseBindMatrices());
1089
1005
  });
1090
1006
  }
1091
- instances() {
1092
- return this.modelInstances.values();
1093
- }
1094
1007
  forEachInstance(fn) {
1095
- for (const inst of this.instances())
1008
+ for (const inst of this.modelInstances.values())
1096
1009
  fn(inst);
1097
1010
  }
1098
1011
  updateInstances(deltaTime) {
1099
1012
  this.forEachInstance((inst) => {
1100
- const verticesChanged = inst.model.update(deltaTime);
1013
+ const verticesChanged = inst.model.update(deltaTime, this.ikEnabled);
1101
1014
  if (verticesChanged)
1102
1015
  inst.vertexBufferNeedsUpdate = true;
1103
- if (inst.physics && inst.model.getPhysicsEnabled()) {
1016
+ if (inst.physics && this.physicsEnabled) {
1104
1017
  inst.physics.step(deltaTime, inst.model.getWorldMatrices(), inst.model.getBoneInverseBindMatrices());
1105
1018
  }
1106
1019
  if (inst.vertexBufferNeedsUpdate)
@@ -1162,6 +1075,20 @@ export class Engine {
1162
1075
  { binding: 1, resource: { buffer: skinMatrixBuffer } },
1163
1076
  ],
1164
1077
  });
1078
+ const mainPerInstanceBindGroup = this.device.createBindGroup({
1079
+ label: `${name}: main per-instance bind group`,
1080
+ layout: this.mainPerInstanceBindGroupLayout,
1081
+ entries: [
1082
+ { binding: 0, resource: { buffer: skinMatrixBuffer } },
1083
+ ],
1084
+ });
1085
+ const pickPerInstanceBindGroup = this.device.createBindGroup({
1086
+ label: `${name}: pick per-instance bind group`,
1087
+ layout: this.pickPerInstanceBindGroupLayout,
1088
+ entries: [
1089
+ { binding: 0, resource: { buffer: skinMatrixBuffer } },
1090
+ ],
1091
+ });
1165
1092
  const inst = {
1166
1093
  name,
1167
1094
  model,
@@ -1174,6 +1101,9 @@ export class Engine {
1174
1101
  drawCalls: [],
1175
1102
  shadowDrawCalls: [],
1176
1103
  shadowBindGroup,
1104
+ mainPerInstanceBindGroup,
1105
+ pickPerInstanceBindGroup,
1106
+ pickDrawCalls: [],
1177
1107
  hiddenMaterials: new Set(),
1178
1108
  physics,
1179
1109
  vertexBufferNeedsUpdate: false,
@@ -1245,56 +1175,6 @@ export class Engine {
1245
1175
  });
1246
1176
  this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices);
1247
1177
  }
1248
- createGroundMaterialBuffer(diffuseColor, reflectionLevel, fadeStart, fadeEnd) {
1249
- const u = new Float32Array(8);
1250
- u[0] = diffuseColor.x;
1251
- u[1] = diffuseColor.y;
1252
- u[2] = diffuseColor.z;
1253
- u[3] = reflectionLevel;
1254
- u[4] = fadeStart;
1255
- u[5] = fadeEnd;
1256
- u[6] = 0;
1257
- u[7] = 0;
1258
- this.groundMaterialUniformBuffer = this.device.createBuffer({
1259
- label: "ground material uniform buffer",
1260
- size: 64,
1261
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1262
- });
1263
- this.device.queue.writeBuffer(this.groundMaterialUniformBuffer, 0, u);
1264
- }
1265
- createReflectionTexture(size) {
1266
- this.groundReflectionTexture = this.device.createTexture({
1267
- label: "ground reflection texture",
1268
- size: [size, size],
1269
- sampleCount: this.sampleCount,
1270
- format: this.presentationFormat,
1271
- usage: GPUTextureUsage.RENDER_ATTACHMENT,
1272
- });
1273
- this.groundReflectionResolveTexture = this.device.createTexture({
1274
- label: "ground reflection resolve texture",
1275
- size: [size, size],
1276
- format: this.presentationFormat,
1277
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1278
- });
1279
- this.groundReflectionDepthTexture = this.device.createTexture({
1280
- label: "ground reflection depth texture",
1281
- size: [size, size],
1282
- sampleCount: this.sampleCount,
1283
- format: "depth24plus-stencil8",
1284
- usage: GPUTextureUsage.RENDER_ATTACHMENT,
1285
- });
1286
- this.groundReflectionBindGroup = this.device.createBindGroup({
1287
- label: "ground reflection bind group",
1288
- layout: this.groundBindGroupLayout,
1289
- entries: [
1290
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1291
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1292
- { binding: 2, resource: this.groundReflectionResolveTexture.createView() },
1293
- { binding: 3, resource: this.materialSampler },
1294
- { binding: 4, resource: { buffer: this.groundMaterialUniformBuffer } },
1295
- ],
1296
- });
1297
- }
1298
1178
  createShadowGroundResources(shadowMapSize, diffuseColor, fadeStart, fadeEnd, shadowStrength) {
1299
1179
  this.shadowMapTexture = this.device.createTexture({
1300
1180
  label: "shadow map",
@@ -1359,6 +1239,8 @@ export class Engine {
1359
1239
  throw new Error("Model has no materials");
1360
1240
  const textures = model.getTextures();
1361
1241
  const prefix = `${inst.name}: `;
1242
+ // 1-based so that (0,0) = clear color = "no hit"
1243
+ const modelId = this.modelInstances.size + 1;
1362
1244
  const loadTextureByIndex = async (texIndex) => {
1363
1245
  if (texIndex < 0 || texIndex >= textures.length)
1364
1246
  return null;
@@ -1366,70 +1248,29 @@ export class Engine {
1366
1248
  return this.createTextureFromPath(path);
1367
1249
  };
1368
1250
  let currentIndexOffset = 0;
1251
+ let materialId = 0;
1369
1252
  for (const mat of materials) {
1370
1253
  const indexCount = mat.vertexCount;
1371
1254
  if (indexCount === 0)
1372
1255
  continue;
1256
+ materialId++;
1373
1257
  const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex);
1374
1258
  if (!diffuseTexture)
1375
1259
  throw new Error(`Material "${mat.name}" has no diffuse texture`);
1376
1260
  const materialAlpha = mat.diffuse[3];
1377
1261
  const isTransparent = materialAlpha < 1.0 - 0.001;
1378
- const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, 0.0, [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]], mat.ambient, mat.specular, mat.shininess);
1262
+ const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]], mat.ambient, mat.specular, mat.shininess);
1263
+ const textureView = diffuseTexture.createView();
1379
1264
  const bindGroup = this.device.createBindGroup({
1380
1265
  label: `${prefix}material: ${mat.name}`,
1381
- layout: this.mainBindGroupLayout,
1266
+ layout: this.mainPerMaterialBindGroupLayout,
1382
1267
  entries: [
1383
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1384
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1385
- { binding: 2, resource: diffuseTexture.createView() },
1386
- { binding: 3, resource: this.materialSampler },
1387
- { binding: 4, resource: { buffer: inst.skinMatrixBuffer } },
1388
- { binding: 5, resource: { buffer: materialUniformBuffer } },
1268
+ { binding: 0, resource: textureView },
1269
+ { binding: 1, resource: { buffer: materialUniformBuffer } },
1389
1270
  ],
1390
1271
  });
1391
- if (indexCount > 0) {
1392
- if (mat.isEye) {
1393
- inst.drawCalls.push({ type: "eye", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name });
1394
- }
1395
- else if (mat.isHair) {
1396
- const createHairBindGroup = (isOverEyes) => {
1397
- 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);
1398
- return this.device.createBindGroup({
1399
- label: `${prefix}hair ${isOverEyes ? "over eyes" : "over non-eyes"}: ${mat.name}`,
1400
- layout: this.mainBindGroupLayout,
1401
- entries: [
1402
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1403
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1404
- { binding: 2, resource: diffuseTexture.createView() },
1405
- { binding: 3, resource: this.materialSampler },
1406
- { binding: 4, resource: { buffer: inst.skinMatrixBuffer } },
1407
- { binding: 5, resource: { buffer: buf } },
1408
- ],
1409
- });
1410
- };
1411
- inst.drawCalls.push({
1412
- type: "hair-over-eyes",
1413
- count: indexCount,
1414
- firstIndex: currentIndexOffset,
1415
- bindGroup: createHairBindGroup(true),
1416
- materialName: mat.name,
1417
- });
1418
- inst.drawCalls.push({
1419
- type: "hair-over-non-eyes",
1420
- count: indexCount,
1421
- firstIndex: currentIndexOffset,
1422
- bindGroup: createHairBindGroup(false),
1423
- materialName: mat.name,
1424
- });
1425
- }
1426
- else if (isTransparent) {
1427
- inst.drawCalls.push({ type: "transparent", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name });
1428
- }
1429
- else {
1430
- inst.drawCalls.push({ type: "opaque", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name });
1431
- }
1432
- }
1272
+ const type = isTransparent ? "transparent" : "opaque";
1273
+ inst.drawCalls.push({ type, count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name });
1433
1274
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1434
1275
  const materialUniformData = new Float32Array([
1435
1276
  mat.edgeColor[0], mat.edgeColor[1], mat.edgeColor[2], mat.edgeColor[3],
@@ -1438,48 +1279,42 @@ export class Engine {
1438
1279
  const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData);
1439
1280
  const outlineBindGroup = this.device.createBindGroup({
1440
1281
  label: `${prefix}outline: ${mat.name}`,
1441
- layout: this.outlineBindGroupLayout,
1282
+ layout: this.outlinePerMaterialBindGroupLayout,
1442
1283
  entries: [
1443
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1444
- { binding: 1, resource: { buffer: outlineUniformBuffer } },
1445
- { binding: 2, resource: { buffer: inst.skinMatrixBuffer } },
1284
+ { binding: 0, resource: { buffer: outlineUniformBuffer } },
1446
1285
  ],
1447
1286
  });
1448
- if (indexCount > 0) {
1449
- const outlineType = mat.isEye ? "eye-outline" : mat.isHair ? "hair-outline" : isTransparent ? "transparent-outline" : "opaque-outline";
1450
- inst.drawCalls.push({ type: outlineType, count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup, materialName: mat.name });
1451
- }
1287
+ const outlineType = isTransparent ? "transparent-outline" : "opaque-outline";
1288
+ inst.drawCalls.push({ type: outlineType, count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup, materialName: mat.name });
1289
+ }
1290
+ if (this.onRaycast) {
1291
+ const pickIdData = new Float32Array([modelId, materialId, 0, 0]);
1292
+ const pickIdBuffer = this.createUniformBuffer(`${prefix}pick: ${mat.name}`, pickIdData);
1293
+ const pickBindGroup = this.device.createBindGroup({
1294
+ label: `${prefix}pick: ${mat.name}`,
1295
+ layout: this.pickPerMaterialBindGroupLayout,
1296
+ entries: [{ binding: 0, resource: { buffer: pickIdBuffer } }],
1297
+ });
1298
+ inst.pickDrawCalls.push({ count: indexCount, firstIndex: currentIndexOffset, bindGroup: pickBindGroup });
1452
1299
  }
1453
1300
  currentIndexOffset += indexCount;
1454
1301
  }
1455
1302
  for (const d of inst.drawCalls) {
1456
- if (d.type === "opaque" || d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes")
1303
+ if (d.type === "opaque")
1457
1304
  inst.shadowDrawCalls.push(d);
1458
1305
  }
1459
1306
  }
1460
- createMaterialUniformBuffer(label, alpha, isOverEyes, diffuseColor, ambientColor, specularColor, shininess) {
1307
+ createMaterialUniformBuffer(label, alpha, diffuseColor, ambientColor, specularColor, shininess) {
1461
1308
  const data = new Float32Array(20);
1462
1309
  data.set([
1463
1310
  alpha,
1464
- 1.0,
1465
1311
  this.rimLightIntensity,
1466
- shininess, // alpha, alphaMultiplier, rimIntensity, shininess
1467
- 1.0,
1468
- 1.0,
1469
- 1.0,
1470
- isOverEyes, // rimColor (vec3), isOverEyes
1471
- diffuseColor[0],
1472
- diffuseColor[1],
1473
- diffuseColor[2],
1474
- 0.0, // diffuseColor (vec3), _padding2
1475
- ambientColor[0],
1476
- ambientColor[1],
1477
- ambientColor[2],
1478
- 0.0, // ambientColor (vec3), _padding3
1479
- specularColor[0],
1480
- specularColor[1],
1481
- specularColor[2],
1482
- 0.0, // specularColor (vec3), _padding4
1312
+ shininess,
1313
+ 0.0,
1314
+ 1.0, 1.0, 1.0, 0.0, // rimColor (vec3), _padding2
1315
+ diffuseColor[0], diffuseColor[1], diffuseColor[2], 0.0,
1316
+ ambientColor[0], ambientColor[1], ambientColor[2], 0.0,
1317
+ specularColor[0], specularColor[1], specularColor[2], 0.0,
1483
1318
  ]);
1484
1319
  return this.createUniformBuffer(`material uniform: ${label}`, data);
1485
1320
  }
@@ -1526,224 +1361,100 @@ export class Engine {
1526
1361
  return null;
1527
1362
  }
1528
1363
  }
1529
- // Helper: Render eyes with stencil writing (for post-alpha-eye effect)
1530
- renderEyes(pass, inst, useReflectionPipeline = false) {
1531
- if (useReflectionPipeline) {
1532
- pass.setPipeline(this.reflectionPipeline);
1533
- for (const draw of inst.drawCalls) {
1534
- if (draw.type === "eye") {
1535
- pass.setBindGroup(0, draw.bindGroup);
1536
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1537
- }
1538
- }
1539
- }
1540
- else {
1541
- pass.setPipeline(this.eyePipeline);
1542
- pass.setStencilReference(this.STENCIL_EYE_VALUE);
1543
- for (const draw of inst.drawCalls) {
1544
- if (draw.type === "eye" && this.shouldRenderDrawCall(inst, draw)) {
1545
- pass.setBindGroup(0, draw.bindGroup);
1546
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1547
- }
1548
- }
1549
- }
1550
- }
1551
1364
  renderGround(pass) {
1552
- if (!this.groundHasReflections || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall)
1365
+ if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall)
1553
1366
  return;
1554
- if (this.groundMode === "reflection" && this.groundReflectionTexture)
1555
- this.renderReflectionTexture();
1556
- pass.setPipeline(this.groundMode === "reflection" ? this.groundPipeline : this.groundShadowPipeline);
1367
+ pass.setPipeline(this.groundShadowPipeline);
1557
1368
  pass.setVertexBuffer(0, this.groundVertexBuffer);
1558
1369
  pass.setIndexBuffer(this.groundIndexBuffer, "uint16");
1559
1370
  pass.setBindGroup(0, this.groundDrawCall.bindGroup);
1560
1371
  pass.drawIndexed(this.groundDrawCall.count, 1, this.groundDrawCall.firstIndex, 0, 0);
1561
1372
  }
1562
- renderReflectionTexture() {
1563
- if (!this.groundReflectionTexture)
1373
+ performRaycast(screenX, screenY) {
1374
+ if (!this.onRaycast || this.modelInstances.size === 0) {
1375
+ this.onRaycast?.("", null, screenX, screenY);
1564
1376
  return;
1565
- const mirrorMatrix = this.createMirrorMatrix(new Vec3(0, 1, 0), 0);
1566
- this.updateCameraUniforms();
1567
- const reflectionEncoder = this.device.createCommandEncoder();
1568
- const reflectionPassDescriptor = {
1569
- label: "reflection render pass",
1570
- colorAttachments: [
1571
- {
1572
- view: this.groundReflectionTexture.createView(),
1573
- resolveTarget: this.groundReflectionResolveTexture.createView(),
1574
- clearValue: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, // White
1377
+ }
1378
+ const dpr = window.devicePixelRatio || 1;
1379
+ this.pendingPick = { x: Math.floor(screenX * dpr), y: Math.floor(screenY * dpr) };
1380
+ }
1381
+ renderPickPass(encoder) {
1382
+ if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture)
1383
+ return;
1384
+ const pass = encoder.beginRenderPass({
1385
+ colorAttachments: [{
1386
+ view: this.pickTexture.createView(),
1387
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1575
1388
  loadOp: "clear",
1576
1389
  storeOp: "store",
1577
- },
1578
- ],
1390
+ }],
1579
1391
  depthStencilAttachment: {
1580
- view: this.groundReflectionDepthTexture.createView(),
1392
+ view: this.pickDepthTexture.createView(),
1581
1393
  depthClearValue: 1.0,
1582
1394
  depthLoadOp: "clear",
1583
1395
  depthStoreOp: "store",
1584
- stencilClearValue: 0,
1585
- stencilLoadOp: "clear",
1586
- stencilStoreOp: "discard",
1587
1396
  },
1588
- };
1589
- const reflectionPass = reflectionEncoder.beginRenderPass(reflectionPassDescriptor);
1590
- this.forEachInstance((inst) => this.renderOneModel(reflectionPass, inst, true, mirrorMatrix));
1591
- reflectionPass.end();
1592
- this.device.queue.submit([reflectionEncoder.finish()]);
1593
- this.updateSkinMatrices();
1594
- }
1595
- renderHair(pass, inst, useReflectionPipeline = false) {
1596
- if (useReflectionPipeline) {
1597
- pass.setPipeline(this.reflectionPipeline);
1598
- for (const draw of inst.drawCalls) {
1599
- if (draw.type === "hair-over-eyes" || draw.type === "hair-over-non-eyes") {
1600
- pass.setBindGroup(0, draw.bindGroup);
1601
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1602
- }
1603
- }
1604
- return;
1605
- }
1606
- const hasHair = inst.drawCalls.some((d) => (d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(inst, d));
1607
- if (hasHair) {
1608
- pass.setPipeline(this.hairDepthPipeline);
1609
- for (const draw of inst.drawCalls) {
1610
- if ((draw.type === "hair-over-eyes" || draw.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(inst, draw)) {
1611
- pass.setBindGroup(0, draw.bindGroup);
1612
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1613
- }
1614
- }
1615
- }
1616
- const hairOverEyes = inst.drawCalls.filter((d) => d.type === "hair-over-eyes" && this.shouldRenderDrawCall(inst, d));
1617
- if (hairOverEyes.length > 0) {
1618
- pass.setPipeline(this.hairPipelineOverEyes);
1619
- pass.setStencilReference(this.STENCIL_EYE_VALUE);
1620
- for (const draw of hairOverEyes) {
1621
- pass.setBindGroup(0, draw.bindGroup);
1622
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1623
- }
1624
- }
1625
- const hairOverNonEyes = inst.drawCalls.filter((d) => d.type === "hair-over-non-eyes" && this.shouldRenderDrawCall(inst, d));
1626
- if (hairOverNonEyes.length > 0) {
1627
- pass.setPipeline(this.hairPipelineOverNonEyes);
1628
- pass.setStencilReference(this.STENCIL_EYE_VALUE);
1629
- for (const draw of hairOverNonEyes) {
1630
- pass.setBindGroup(0, draw.bindGroup);
1631
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1632
- }
1633
- }
1634
- const hairOutlines = inst.drawCalls.filter((d) => d.type === "hair-outline" && this.shouldRenderDrawCall(inst, d));
1635
- if (hairOutlines.length > 0) {
1636
- pass.setPipeline(this.hairOutlinePipeline);
1637
- for (const draw of hairOutlines) {
1638
- pass.setBindGroup(0, draw.bindGroup);
1397
+ });
1398
+ pass.setPipeline(this.pickPipeline);
1399
+ pass.setBindGroup(0, this.pickPerFrameBindGroup);
1400
+ this.forEachInstance((inst) => {
1401
+ pass.setVertexBuffer(0, inst.vertexBuffer);
1402
+ pass.setVertexBuffer(1, inst.jointsBuffer);
1403
+ pass.setVertexBuffer(2, inst.weightsBuffer);
1404
+ pass.setIndexBuffer(inst.indexBuffer, "uint32");
1405
+ pass.setBindGroup(1, inst.pickPerInstanceBindGroup);
1406
+ for (const draw of inst.pickDrawCalls) {
1407
+ pass.setBindGroup(2, draw.bindGroup);
1639
1408
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1640
1409
  }
1641
- }
1410
+ });
1411
+ pass.end();
1412
+ // Copy the single pixel under cursor to readback buffer
1413
+ const px = Math.min(this.pendingPick.x, this.pickTexture.width - 1);
1414
+ const py = Math.min(this.pendingPick.y, this.pickTexture.height - 1);
1415
+ encoder.copyTextureToBuffer({ texture: this.pickTexture, origin: { x: Math.max(0, px), y: Math.max(0, py) } }, { buffer: this.pickReadbackBuffer, bytesPerRow: 256 }, { width: 1, height: 1 });
1642
1416
  }
1643
- performRaycast(screenX, screenY) {
1644
- if (!this.onRaycast || this.modelInstances.size === 0) {
1645
- this.onRaycast?.("", null, screenX, screenY);
1417
+ async resolvePickResult(screenX, screenY) {
1418
+ if (!this.onRaycast)
1419
+ return;
1420
+ await this.pickReadbackBuffer.mapAsync(GPUMapMode.READ);
1421
+ const data = new Uint8Array(this.pickReadbackBuffer.getMappedRange());
1422
+ const modelId = data[0];
1423
+ const materialId = data[1];
1424
+ this.pickReadbackBuffer.unmap();
1425
+ if (modelId === 0) {
1426
+ this.onRaycast("", null, screenX, screenY);
1646
1427
  return;
1647
1428
  }
1648
- const viewMatrix = this.camera.getViewMatrix();
1649
- const projectionMatrix = this.camera.getProjectionMatrix();
1650
- const rect = this.canvas.getBoundingClientRect();
1651
- const clipX = (screenX / rect.width) * 2 - 1;
1652
- const clipY = 1 - (screenY / rect.height) * 2;
1653
- const viewProjMatrix = projectionMatrix.multiply(viewMatrix);
1654
- const inverseViewProj = viewProjMatrix.inverse();
1655
- const transformPoint = (matrix, point) => {
1656
- const m = matrix.values;
1657
- const x = point.x, y = point.y, z = point.z;
1658
- 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]);
1659
- const w = m[3] * x + m[7] * y + m[11] * z + m[15];
1660
- return result.scale(w !== 0 ? 1 / w : 1);
1661
- };
1662
- const worldNear = transformPoint(inverseViewProj, new Vec3(clipX, clipY, -1));
1663
- const worldFar = transformPoint(inverseViewProj, new Vec3(clipX, clipY, 1));
1664
- const rayOrigin = this.camera.getPosition();
1665
- const rayDirection = worldFar.subtract(worldNear).normalize();
1666
- const transformByMatrix = (matrix, offset, point) => {
1667
- const m = matrix, x = point.x, y = point.y, z = point.z;
1668
- 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]);
1669
- };
1670
- let closest = null;
1671
- const maxDistance = 1000;
1672
- this.forEachInstance((inst) => {
1673
- const model = inst.model;
1674
- const materials = model.getMaterials();
1675
- if (materials.length === 0)
1676
- return;
1677
- const baseVertices = model.getVertices();
1678
- const indices = model.getIndices();
1679
- const skinning = model.getSkinning();
1680
- if (!baseVertices?.length || !indices || !skinning)
1681
- return;
1682
- const vertices = new Float32Array(baseVertices.length);
1683
- const skinMatrices = model.getSkinMatrices();
1684
- for (let i = 0; i < baseVertices.length; i += 8) {
1685
- const vertexIndex = i / 8;
1686
- const position = new Vec3(baseVertices[i], baseVertices[i + 1], baseVertices[i + 2]);
1687
- const j0 = skinning.joints[vertexIndex * 4], j1 = skinning.joints[vertexIndex * 4 + 1], j2 = skinning.joints[vertexIndex * 4 + 2], j3 = skinning.joints[vertexIndex * 4 + 3];
1688
- 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;
1689
- const ws = w0 + w1 + w2 + w3;
1690
- const nw = ws > 0.0001 ? [w0 / ws, w1 / ws, w2 / ws, w3 / ws] : [1, 0, 0, 0];
1691
- let sp = new Vec3(0, 0, 0);
1692
- for (let j = 0; j < 4; j++) {
1693
- if (nw[j] <= 0)
1694
- continue;
1695
- const transformed = transformByMatrix(skinMatrices, [j0, j1, j2, j3][j] * 16, position);
1696
- sp = sp.add(transformed.scale(nw[j]));
1697
- }
1698
- vertices[i] = sp.x;
1699
- vertices[i + 1] = sp.y;
1700
- vertices[i + 2] = sp.z;
1701
- vertices[i + 3] = baseVertices[i + 3];
1702
- vertices[i + 4] = baseVertices[i + 4];
1703
- vertices[i + 5] = baseVertices[i + 5];
1704
- vertices[i + 6] = baseVertices[i + 6];
1705
- vertices[i + 7] = baseVertices[i + 7];
1429
+ // Find model by 1-based index
1430
+ let idx = 1;
1431
+ let hitModel = "";
1432
+ for (const [name] of this.modelInstances) {
1433
+ if (idx === modelId) {
1434
+ hitModel = name;
1435
+ break;
1706
1436
  }
1707
- for (let i = 0; i < indices.length; i += 3) {
1708
- const idx0 = indices[i] * 8, idx1 = indices[i + 1] * 8, idx2 = indices[i + 2] * 8;
1709
- const v0 = new Vec3(vertices[idx0], vertices[idx0 + 1], vertices[idx0 + 2]);
1710
- const v1 = new Vec3(vertices[idx1], vertices[idx1 + 1], vertices[idx1 + 2]);
1711
- const v2 = new Vec3(vertices[idx2], vertices[idx2 + 1], vertices[idx2 + 2]);
1712
- let triangleMaterialIndex = -1;
1713
- let indexOffset = 0;
1714
- for (let matIdx = 0; matIdx < materials.length; matIdx++) {
1715
- if (i >= indexOffset && i < indexOffset + materials[matIdx].vertexCount) {
1716
- triangleMaterialIndex = matIdx;
1437
+ idx++;
1438
+ }
1439
+ // Find material by 1-based index (skipping zero-vertex materials)
1440
+ let hitMaterial = null;
1441
+ if (hitModel) {
1442
+ const inst = this.modelInstances.get(hitModel);
1443
+ if (inst) {
1444
+ const materials = inst.model.getMaterials();
1445
+ let matIdx = 0;
1446
+ for (const mat of materials) {
1447
+ if (mat.vertexCount === 0)
1448
+ continue;
1449
+ matIdx++;
1450
+ if (matIdx === materialId) {
1451
+ hitMaterial = mat.name;
1717
1452
  break;
1718
1453
  }
1719
- indexOffset += materials[matIdx].vertexCount;
1720
- }
1721
- if (triangleMaterialIndex === -1)
1722
- continue;
1723
- const edge1 = v1.subtract(v0), edge2 = v2.subtract(v0), h = rayDirection.cross(edge2), a = edge1.dot(h);
1724
- if (Math.abs(a) < 0.0001)
1725
- continue;
1726
- const f = 1 / a, s = rayOrigin.subtract(v0), u = f * s.dot(h);
1727
- if (u < 0 || u > 1)
1728
- continue;
1729
- const q = s.cross(edge1), v = f * rayDirection.dot(q);
1730
- if (v < 0 || u + v > 1)
1731
- continue;
1732
- const t = f * edge2.dot(q);
1733
- if (t <= 0.0001 || t >= maxDistance)
1734
- continue;
1735
- const triangleNormal = edge1.cross(edge2).normalize();
1736
- if (triangleNormal.dot(rayDirection) >= 0)
1737
- continue;
1738
- if (!closest || t < closest.distance) {
1739
- closest = { modelName: inst.name, materialName: materials[triangleMaterialIndex].name, distance: t };
1740
1454
  }
1741
1455
  }
1742
- });
1743
- if (this.onRaycast) {
1744
- const hit = closest;
1745
- this.onRaycast(hit?.modelName ?? "", hit?.materialName ?? null, screenX, screenY);
1746
1456
  }
1457
+ this.onRaycast(hitModel, hitMaterial, screenX, screenY);
1747
1458
  }
1748
1459
  render() {
1749
1460
  if (!this.multisampleTexture || !this.camera || !this.device)
@@ -1751,17 +1462,27 @@ export class Engine {
1751
1462
  const currentTime = performance.now();
1752
1463
  const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016;
1753
1464
  this.lastFrameTime = currentTime;
1754
- this.updateCameraUniforms();
1755
1465
  this.updateRenderTarget();
1756
1466
  const hasModels = this.modelInstances.size > 0;
1757
1467
  if (hasModels) {
1758
1468
  this.updateInstances(deltaTime);
1759
1469
  this.updateSkinMatrices();
1470
+ // Update camera target from bound model (bone not found → 0,0,0 + offset)
1471
+ if (this.cameraTargetModel) {
1472
+ const pos = this.cameraTargetModel.getBoneWorldPosition(this.cameraTargetBoneName);
1473
+ const px = pos?.x ?? 0;
1474
+ const py = pos?.y ?? 0;
1475
+ const pz = pos?.z ?? 0;
1476
+ this.camera.target.x = px + this.cameraTargetOffset.x;
1477
+ this.camera.target.y = py + this.cameraTargetOffset.y;
1478
+ this.camera.target.z = pz + this.cameraTargetOffset.z;
1479
+ }
1760
1480
  }
1761
- if (this.groundMode === "shadow")
1481
+ this.updateCameraUniforms();
1482
+ if (this.hasGround)
1762
1483
  this.updateShadowLightVP();
1763
1484
  const encoder = this.device.createCommandEncoder();
1764
- if (hasModels && this.groundMode === "shadow" && this.shadowMapDepthView) {
1485
+ if (hasModels && this.hasGround && this.shadowMapDepthView) {
1765
1486
  const sp = encoder.beginRenderPass({
1766
1487
  colorAttachments: [],
1767
1488
  depthStencilAttachment: {
@@ -1777,13 +1498,25 @@ export class Engine {
1777
1498
  }
1778
1499
  const pass = encoder.beginRenderPass(this.renderPassDescriptor);
1779
1500
  if (hasModels)
1780
- this.forEachInstance((inst) => this.renderOneModel(pass, inst, false));
1781
- if (this.groundHasReflections)
1501
+ this.forEachInstance((inst) => this.renderOneModel(pass, inst));
1502
+ if (this.hasGround)
1782
1503
  this.renderGround(pass);
1783
1504
  pass.end();
1505
+ const pick = this.pendingPick;
1506
+ if (pick && hasModels)
1507
+ this.renderPickPass(encoder);
1784
1508
  this.device.queue.submit([encoder.finish()]);
1509
+ if (pick) {
1510
+ this.pendingPick = null;
1511
+ const dpr = window.devicePixelRatio || 1;
1512
+ this.resolvePickResult(pick.x / dpr, pick.y / dpr);
1513
+ }
1785
1514
  this.updateStats(performance.now() - currentTime);
1786
1515
  }
1516
+ updateRenderTarget() {
1517
+ const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
1518
+ colorAttachment.resolveTarget = this.context.getCurrentTexture().createView();
1519
+ }
1787
1520
  drawInstanceShadow(sp, inst) {
1788
1521
  sp.setBindGroup(0, inst.shadowBindGroup);
1789
1522
  sp.setVertexBuffer(0, inst.vertexBuffer);
@@ -1795,48 +1528,38 @@ export class Engine {
1795
1528
  sp.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1796
1529
  }
1797
1530
  }
1798
- renderOneModel(pass, inst, useReflection, mirrorMatrix) {
1799
- pass.setVertexBuffer(0, inst.vertexBuffer);
1800
- pass.setVertexBuffer(1, inst.jointsBuffer);
1801
- pass.setVertexBuffer(2, inst.weightsBuffer);
1802
- pass.setIndexBuffer(inst.indexBuffer, "uint32");
1803
- if (useReflection && mirrorMatrix) {
1804
- this.writeMirrorTransformedSkinMatrices(inst, mirrorMatrix);
1805
- pass.setPipeline(this.reflectionPipeline);
1806
- for (const draw of inst.drawCalls) {
1807
- if (draw.type === "opaque" && this.shouldRenderDrawCall(inst, draw)) {
1808
- pass.setBindGroup(0, draw.bindGroup);
1809
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1810
- }
1811
- }
1812
- this.renderEyes(pass, inst, true);
1813
- this.renderHair(pass, inst, true);
1814
- for (const draw of inst.drawCalls) {
1815
- if (draw.type === "transparent" && this.shouldRenderDrawCall(inst, draw)) {
1816
- pass.setBindGroup(0, draw.bindGroup);
1817
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1818
- }
1819
- }
1820
- this.drawOutlines(pass, inst, true, true);
1821
- return;
1822
- }
1823
- pass.setPipeline(this.modelPipeline);
1531
+ drawOpaque(pass, inst, pipeline) {
1532
+ pass.setPipeline(pipeline);
1824
1533
  for (const draw of inst.drawCalls) {
1825
1534
  if (draw.type === "opaque" && this.shouldRenderDrawCall(inst, draw)) {
1826
- pass.setBindGroup(0, draw.bindGroup);
1535
+ pass.setBindGroup(2, draw.bindGroup);
1827
1536
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1828
1537
  }
1829
1538
  }
1830
- this.renderEyes(pass, inst, false);
1831
- this.drawOutlines(pass, inst, false);
1832
- this.renderHair(pass, inst, false);
1833
- pass.setPipeline(this.modelPipeline);
1539
+ }
1540
+ drawTransparent(pass, inst, pipeline) {
1541
+ pass.setPipeline(pipeline);
1834
1542
  for (const draw of inst.drawCalls) {
1835
1543
  if (draw.type === "transparent" && this.shouldRenderDrawCall(inst, draw)) {
1836
- pass.setBindGroup(0, draw.bindGroup);
1544
+ pass.setBindGroup(2, draw.bindGroup);
1837
1545
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1838
1546
  }
1839
1547
  }
1548
+ }
1549
+ bindMainGroups(pass, inst) {
1550
+ pass.setBindGroup(0, this.perFrameBindGroup);
1551
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
1552
+ }
1553
+ renderOneModel(pass, inst) {
1554
+ pass.setVertexBuffer(0, inst.vertexBuffer);
1555
+ pass.setVertexBuffer(1, inst.jointsBuffer);
1556
+ pass.setVertexBuffer(2, inst.weightsBuffer);
1557
+ pass.setIndexBuffer(inst.indexBuffer, "uint32");
1558
+ this.bindMainGroups(pass, inst);
1559
+ this.drawOpaque(pass, inst, this.modelPipeline);
1560
+ this.drawOutlines(pass, inst, false);
1561
+ this.bindMainGroups(pass, inst);
1562
+ this.drawTransparent(pass, inst, this.modelPipeline);
1840
1563
  this.drawOutlines(pass, inst, true);
1841
1564
  }
1842
1565
  updateCameraUniforms() {
@@ -1850,30 +1573,20 @@ export class Engine {
1850
1573
  this.cameraMatrixData[34] = cameraPos.z;
1851
1574
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData);
1852
1575
  }
1853
- updateRenderTarget() {
1854
- // Update render target to use current canvas texture
1855
- const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
1856
- if (this.sampleCount > 1) {
1857
- colorAttachment.resolveTarget = this.context.getCurrentTexture().createView();
1858
- }
1859
- else {
1860
- colorAttachment.view = this.context.getCurrentTexture().createView();
1861
- }
1862
- }
1863
1576
  updateSkinMatrices() {
1864
1577
  this.forEachInstance((inst) => {
1865
1578
  const skinMatrices = inst.model.getSkinMatrices();
1866
1579
  this.device.queue.writeBuffer(inst.skinMatrixBuffer, 0, skinMatrices.buffer, skinMatrices.byteOffset, skinMatrices.byteLength);
1867
1580
  });
1868
1581
  }
1869
- drawOutlines(pass, inst, transparent, useReflectionPipeline = false) {
1870
- if (useReflectionPipeline)
1871
- return;
1582
+ drawOutlines(pass, inst, transparent) {
1872
1583
  pass.setPipeline(this.outlinePipeline);
1584
+ pass.setBindGroup(0, this.outlinePerFrameBindGroup);
1585
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
1873
1586
  const outlineType = transparent ? "transparent-outline" : "opaque-outline";
1874
1587
  for (const draw of inst.drawCalls) {
1875
1588
  if (draw.type === outlineType && this.shouldRenderDrawCall(inst, draw)) {
1876
- pass.setBindGroup(0, draw.bindGroup);
1589
+ pass.setBindGroup(2, draw.bindGroup);
1877
1590
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1878
1591
  }
1879
1592
  }
@@ -1900,41 +1613,6 @@ export class Engine {
1900
1613
  this.lastFpsUpdate = now;
1901
1614
  }
1902
1615
  }
1903
- createMirrorMatrix(planeNormal, planeDistance) {
1904
- // Create reflection matrix across a plane
1905
- const n = planeNormal.normalize();
1906
- return new Mat4(new Float32Array([
1907
- 1 - 2 * n.x * n.x,
1908
- -2 * n.x * n.y,
1909
- -2 * n.x * n.z,
1910
- 0,
1911
- -2 * n.y * n.x,
1912
- 1 - 2 * n.y * n.y,
1913
- -2 * n.y * n.z,
1914
- 0,
1915
- -2 * n.z * n.x,
1916
- -2 * n.z * n.y,
1917
- 1 - 2 * n.z * n.z,
1918
- 0,
1919
- -2 * planeDistance * n.x,
1920
- -2 * planeDistance * n.y,
1921
- -2 * planeDistance * n.z,
1922
- 1,
1923
- ]));
1924
- }
1925
- writeMirrorTransformedSkinMatrices(inst, mirrorMatrix) {
1926
- const originalMatrices = inst.model.getSkinMatrices();
1927
- const transformedMatrices = new Float32Array(originalMatrices.length);
1928
- for (let i = 0; i < originalMatrices.length; i += 16) {
1929
- const boneMatrixValues = new Float32Array(16);
1930
- for (let j = 0; j < 16; j++)
1931
- boneMatrixValues[j] = originalMatrices[i + j];
1932
- const boneMatrix = new Mat4(boneMatrixValues);
1933
- const transformed = mirrorMatrix.multiply(boneMatrix);
1934
- for (let j = 0; j < 16; j++)
1935
- transformedMatrices[i + j] = transformed.values[j];
1936
- }
1937
- this.device.queue.writeBuffer(inst.skinMatrixBuffer, 0, transformedMatrices);
1938
- }
1939
1616
  }
1940
1617
  Engine.instance = null;
1618
+ Engine.MULTISAMPLE_COUNT = 4;