reze-engine 0.7.0 → 0.8.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
@@ -1,6 +1,6 @@
1
1
  import { Camera } from "./camera";
2
2
  import { Mat4, Vec3 } from "./math";
3
- import { PmxLoader } from "./pmx-loader";
3
+ import { Physics } from "./physics";
4
4
  export const DEFAULT_ENGINE_OPTIONS = {
5
5
  ambientColor: new Vec3(0.88, 0.88, 0.88),
6
6
  directionalLightIntensity: 0.24,
@@ -10,10 +10,15 @@ export const DEFAULT_ENGINE_OPTIONS = {
10
10
  cameraTarget: new Vec3(0, 12.5, 0),
11
11
  cameraFov: Math.PI / 4,
12
12
  onRaycast: undefined,
13
- disableIK: false,
14
- disablePhysics: false,
13
+ multisampleCount: 4,
15
14
  };
16
15
  export class Engine {
16
+ static getInstance() {
17
+ if (!Engine.instance) {
18
+ throw new Error("Engine not ready: create Engine, await init(), then load models via Model.loadPmx().");
19
+ }
20
+ return Engine.instance;
21
+ }
17
22
  constructor(canvas, options) {
18
23
  this.cameraMatrixData = new Float32Array(36);
19
24
  this.lightData = new Float32Array(64);
@@ -23,22 +28,19 @@ export class Engine {
23
28
  // Constants
24
29
  this.STENCIL_EYE_VALUE = 1;
25
30
  this.groundHasReflections = false;
26
- this.cachedSkinMatricesVersion = -1;
27
- this.skinMatricesVersion = 0;
31
+ this.groundMode = "reflection";
32
+ this.shadowLightVPMatrix = new Float32Array(16);
33
+ this.groundDrawCall = null;
34
+ this.shadowVPLightX = Number.NaN;
35
+ this.shadowVPLightY = Number.NaN;
36
+ this.shadowVPLightZ = Number.NaN;
28
37
  // Double-tap detection
29
38
  this.lastTouchTime = 0;
30
39
  this.DOUBLE_TAP_DELAY = 300; // ms
31
- // IK and Physics flags
32
- this._disableIK = false;
33
- this._disablePhysics = false;
34
- this.currentModel = null;
35
- this.modelDir = "";
40
+ this.modelInstances = new Map();
36
41
  this.textureCache = new Map();
37
- this.vertexBufferNeedsUpdate = false;
38
- // Unified draw call list
39
- this.drawCalls = [];
40
- // Material visibility tracking
41
- this.hiddenMaterials = new Set();
42
+ /** Reusable buffer for raycast skinning to avoid per-instance allocations (Three.js/Babylon.js style). */
43
+ this.raycastVertexBuffer = null;
42
44
  this.lastFpsUpdate = performance.now();
43
45
  this.framesSinceLastUpdate = 0;
44
46
  this.lastFrameTime = performance.now();
@@ -51,15 +53,13 @@ export class Engine {
51
53
  this.animationFrameId = null;
52
54
  this.renderLoopCallback = null;
53
55
  this.handleCanvasDoubleClick = (event) => {
54
- if (!this.onRaycast || !this.currentModel)
56
+ if (!this.onRaycast || this.modelInstances.size === 0)
55
57
  return;
56
58
  const rect = this.canvas.getBoundingClientRect();
57
- const x = event.clientX - rect.left;
58
- const y = event.clientY - rect.top;
59
- this.performRaycast(x, y);
59
+ this.performRaycast(event.clientX - rect.left, event.clientY - rect.top);
60
60
  };
61
61
  this.handleCanvasTouch = (event) => {
62
- if (!this.onRaycast || !this.currentModel)
62
+ if (!this.onRaycast || this.modelInstances.size === 0)
63
63
  return;
64
64
  // Prevent default to avoid triggering mouse events
65
65
  event.preventDefault();
@@ -84,6 +84,7 @@ export class Engine {
84
84
  }
85
85
  };
86
86
  this.canvas = canvas;
87
+ this.sampleCount = options?.multisampleCount ?? DEFAULT_ENGINE_OPTIONS.multisampleCount;
87
88
  if (options) {
88
89
  this.ambientColor = options.ambientColor ?? DEFAULT_ENGINE_OPTIONS.ambientColor;
89
90
  this.directionalLightIntensity =
@@ -94,8 +95,6 @@ export class Engine {
94
95
  this.cameraTarget = options.cameraTarget ?? DEFAULT_ENGINE_OPTIONS.cameraTarget;
95
96
  this.cameraFov = options.cameraFov ?? DEFAULT_ENGINE_OPTIONS.cameraFov;
96
97
  this.onRaycast = options.onRaycast;
97
- this._disableIK = options.disableIK ?? DEFAULT_ENGINE_OPTIONS.disableIK;
98
- this._disablePhysics = options.disablePhysics ?? DEFAULT_ENGINE_OPTIONS.disablePhysics;
99
98
  }
100
99
  }
101
100
  // Step 1: Get WebGPU device and context
@@ -121,6 +120,7 @@ export class Engine {
121
120
  this.setupLighting();
122
121
  this.createPipelines();
123
122
  this.setupResize();
123
+ Engine.instance = this;
124
124
  }
125
125
  createRenderPipeline(config) {
126
126
  return this.device.createRenderPipeline({
@@ -379,15 +379,14 @@ export class Engine {
379
379
  depthCompare: "less-equal",
380
380
  },
381
381
  });
382
- // Create ground/reflection pipeline with reflection texture support
383
382
  this.groundBindGroupLayout = this.device.createBindGroupLayout({
384
383
  label: "ground bind group layout",
385
384
  entries: [
386
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
387
- { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
388
- { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // reflectionTexture
389
- { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // reflectionSampler
390
- { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // groundMaterial
385
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
386
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
387
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} },
388
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
389
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
391
390
  ],
392
391
  });
393
392
  const groundPipelineLayout = this.device.createPipelineLayout({
@@ -397,97 +396,41 @@ export class Engine {
397
396
  const groundShaderModule = this.device.createShaderModule({
398
397
  label: "ground shaders",
399
398
  code: /* wgsl */ `
400
- struct CameraUniforms {
401
- view: mat4x4f,
402
- projection: mat4x4f,
403
- viewPos: vec3f,
404
- _padding: f32,
405
- };
406
-
407
- struct LightUniforms {
408
- ambientColor: vec4f,
409
- lights: array<Light, 4>,
410
- };
411
-
412
- struct Light {
413
- direction: vec4f,
414
- color: vec4f,
415
- };
416
-
417
- struct GroundMaterialUniforms {
418
- diffuseColor: vec3f,
419
- reflectionLevel: f32,
420
- fadeStart: f32,
421
- fadeEnd: f32,
422
- _padding1: f32,
423
- _padding2: f32,
424
- };
425
-
399
+ struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
400
+ struct Light { direction: vec4f, color: vec4f, };
401
+ struct LightUniforms { ambientColor: vec4f, lights: array<Light, 4>, };
402
+ struct GroundMaterialUniforms { diffuseColor: vec3f, reflectionLevel: f32, fadeStart: f32, fadeEnd: f32, _a: f32, _b: f32, };
426
403
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
427
404
  @group(0) @binding(1) var<uniform> light: LightUniforms;
428
405
  @group(0) @binding(2) var reflectionTexture: texture_2d<f32>;
429
406
  @group(0) @binding(3) var reflectionSampler: sampler;
430
407
  @group(0) @binding(4) var<uniform> material: GroundMaterialUniforms;
431
-
432
408
  struct VertexOutput {
433
- @builtin(position) position: vec4f,
434
- @location(0) normal: vec3f,
435
- @location(1) uv: vec2f,
436
- @location(2) worldPos: vec3f,
409
+ @builtin(position) position: vec4f, @location(0) normal: vec3f, @location(1) uv: vec2f, @location(2) worldPos: vec3f,
437
410
  };
438
-
439
- @vertex fn vs(
440
- @location(0) position: vec3f,
441
- @location(1) normal: vec3f,
442
- @location(2) uv: vec2f,
443
- ) -> VertexOutput {
444
- var output: VertexOutput;
445
- let worldPos = position;
446
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
447
- output.normal = normal;
448
- output.uv = uv;
449
- output.worldPos = worldPos;
450
- return output;
411
+ @vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VertexOutput {
412
+ var o: VertexOutput;
413
+ o.worldPos = position;
414
+ o.position = camera.projection * camera.view * vec4f(position, 1.0);
415
+ o.normal = normal; o.uv = uv; return o;
451
416
  }
452
-
453
417
  @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
454
418
  let n = normalize(input.normal);
455
-
419
+ let centerDist = length(input.worldPos.xz);
420
+ let t = clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0);
421
+ let edgeFade = 1.0 - smoothstep(0.0, 1.0, t);
456
422
  let clipPos = camera.projection * camera.view * vec4f(input.worldPos, 1.0);
457
423
  let ndcPos = clipPos.xyz / clipPos.w;
458
- var reflectionUV = vec2f(ndcPos.x * 0.5 + 0.5, 0.5 - ndcPos.y * 0.5);
459
-
424
+ let reflectionUV = vec2f(ndcPos.x * 0.5 + 0.5, 0.5 - ndcPos.y * 0.5);
460
425
  let sampledReflectionColor = textureSample(reflectionTexture, reflectionSampler, reflectionUV).rgb;
461
- let isValidReflection = clipPos.w > 0.0 &&
462
- all(reflectionUV >= vec2f(0.0)) && all(reflectionUV <= vec2f(1.0));
463
- var reflectionColor = select(vec3f(1.0, 1.0, 1.0), sampledReflectionColor, isValidReflection);
464
-
465
- let distanceFromCamera = length(input.worldPos - camera.viewPos);
466
- let fadeFactor = clamp((distanceFromCamera - 15.0) / 20.0, 0.0, 1.0);
467
- reflectionColor *= (1.0 - fadeFactor * 0.3);
468
-
469
- let diffuseColor = material.diffuseColor;
470
- var finalColor = mix(diffuseColor, reflectionColor, material.reflectionLevel);
471
-
472
- // Ground edge fade effect - smooth fade out at edges based on distance from center
473
- let centerDist = length(input.worldPos.xz); // Distance from ground center in XZ plane
474
-
475
- // Smoothstep for much smoother gradient transition
476
- let t = clamp((centerDist - material.fadeStart) / (material.fadeEnd - material.fadeStart), 0.0, 1.0);
477
- let edgeFade = 1.0 - smoothstep(0.0, 1.0, t);
478
- finalColor *= edgeFade;
479
-
480
- // Single directional light
426
+ let isValidReflection = clipPos.w > 0.0 && all(reflectionUV >= vec2f(0.0)) && all(reflectionUV <= vec2f(1.0));
427
+ let reflectionColor = select(vec3f(1.0, 1.0, 1.0), sampledReflectionColor, isValidReflection);
428
+ let fadeFactor = clamp((length(input.worldPos - camera.viewPos) - 15.0) / 20.0, 0.0, 1.0);
429
+ let refl = reflectionColor * (1.0 - fadeFactor * 0.3);
430
+ var finalColor = mix(material.diffuseColor, refl, material.reflectionLevel) * edgeFade;
481
431
  let l = -light.lights[0].direction.xyz;
482
- let nDotL = max(dot(n, l), 0.0);
483
- let intensity = light.lights[0].color.w;
484
- let radiance = light.lights[0].color.xyz * intensity;
485
- let lightAccum = light.ambientColor.xyz + radiance * nDotL;
486
-
487
- // Apply lighting to the blended color
488
- let litColor = finalColor * lightAccum;
489
-
490
- return vec4f(litColor, edgeFade);
432
+ let lightAccum = light.ambientColor.xyz + light.lights[0].color.xyz * light.lights[0].color.w * max(dot(n, l), 0.0);
433
+ return vec4f(finalColor * lightAccum, edgeFade);
491
434
  }
492
435
  `,
493
436
  });
@@ -498,12 +441,123 @@ export class Engine {
498
441
  vertexBuffers: fullVertexBuffers,
499
442
  fragmentTarget: standardBlend,
500
443
  cullMode: "back",
444
+ depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
445
+ });
446
+ this.shadowLightVPBuffer = this.device.createBuffer({
447
+ size: 64,
448
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
449
+ });
450
+ const shadowBindGroupLayout = this.device.createBindGroupLayout({
451
+ label: "shadow depth bind layout",
452
+ entries: [
453
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
454
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
455
+ ],
456
+ });
457
+ const shadowShader = this.device.createShaderModule({
458
+ label: "shadow depth",
459
+ code: /* wgsl */ `
460
+ struct LightVP { viewProj: mat4x4f, };
461
+ @group(0) @binding(0) var<uniform> lp: LightVP;
462
+ @group(0) @binding(1) var<storage, read> skinMats: array<mat4x4f>;
463
+ @vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f,
464
+ @location(3) joints0: vec4<u32>, @location(4) weights0: vec4<f32>) -> @builtin(position) vec4f {
465
+ let pos4 = vec4f(position, 1.0);
466
+ let ws = weights0.x + weights0.y + weights0.z + weights0.w;
467
+ let inv = select(1.0, 1.0 / ws, ws > 0.0001);
468
+ let nw = select(vec4f(1.0,0.0,0.0,0.0), weights0 * inv, ws > 0.0001);
469
+ var sp = vec4f(0.0);
470
+ for (var i = 0u; i < 4u; i++) { sp += (skinMats[joints0[i]] * pos4) * nw[i]; }
471
+ return lp.viewProj * vec4f(sp.xyz, 1.0);
472
+ }
473
+ `,
474
+ });
475
+ this.shadowDepthPipeline = this.device.createRenderPipeline({
476
+ label: "shadow depth pipeline",
477
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [shadowBindGroupLayout] }),
478
+ vertex: { module: shadowShader, entryPoint: "vs", buffers: fullVertexBuffers },
479
+ primitive: { cullMode: "none" },
501
480
  depthStencil: {
502
- format: "depth24plus-stencil8",
481
+ format: "depth32float",
503
482
  depthWriteEnabled: true,
504
483
  depthCompare: "less-equal",
484
+ depthBias: 2,
485
+ depthBiasSlopeScale: 1.5,
486
+ depthBiasClamp: 0,
505
487
  },
506
488
  });
489
+ this.shadowComparisonSampler = this.device.createSampler({
490
+ compare: "less",
491
+ magFilter: "linear",
492
+ minFilter: "linear",
493
+ });
494
+ this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
495
+ label: "ground shadow layout",
496
+ entries: [
497
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
498
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
499
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
500
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
501
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
502
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
503
+ ],
504
+ });
505
+ const groundShadowShader = this.device.createShaderModule({
506
+ label: "ground shadow",
507
+ code: /* wgsl */ `
508
+ struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
509
+ struct Light { direction: vec4f, color: vec4f, };
510
+ struct LightUniforms { ambientColor: vec4f, lights: array<Light, 4>, };
511
+ struct GroundShadowMat { diffuseColor: vec3f, fadeStart: f32, fadeEnd: f32, shadowStrength: f32, pcfTexel: f32, _y: f32, };
512
+ struct LightVP { viewProj: mat4x4f, };
513
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
514
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
515
+ @group(0) @binding(2) var shadowMap: texture_depth_2d;
516
+ @group(0) @binding(3) var shadowSampler: sampler_comparison;
517
+ @group(0) @binding(4) var<uniform> material: GroundShadowMat;
518
+ @group(0) @binding(5) var<uniform> lightVP: LightVP;
519
+ struct VO { @builtin(position) position: vec4f, @location(0) worldPos: vec3f, @location(1) normal: vec3f, };
520
+ @vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VO {
521
+ var o: VO; o.worldPos = position; o.normal = normal;
522
+ o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;
523
+ }
524
+ @fragment fn fs(i: VO) -> @location(0) vec4f {
525
+ let n = normalize(i.normal);
526
+ let centerDist = length(i.worldPos.xz);
527
+ let edgeFade = 1.0 - smoothstep(0.0, 1.0, clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0));
528
+ let lclip = lightVP.viewProj * vec4f(i.worldPos, 1.0);
529
+ let ndc = lclip.xyz / max(lclip.w, 1e-6);
530
+ let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
531
+ let suv_c = clamp(suv, vec2f(0.02), vec2f(0.98));
532
+ let st = material.pcfTexel;
533
+ let compareZ = ndc.z - 0.0035;
534
+ var vis = 0.0;
535
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(-st, -st), compareZ);
536
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(0.0, -st), compareZ);
537
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(st, -st), compareZ);
538
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(-st, 0.0), compareZ);
539
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c, compareZ);
540
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(st, 0.0), compareZ);
541
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(-st, st), compareZ);
542
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(0.0, st), compareZ);
543
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(st, st), compareZ);
544
+ vis *= 0.1111111;
545
+ let sun = light.ambientColor.xyz + light.lights[0].color.xyz * light.lights[0].color.w * max(dot(n, -light.lights[0].direction.xyz), 0.0);
546
+ let dark = (1.0 - vis) * material.shadowStrength;
547
+ let lit = material.diffuseColor * sun * (1.0 - dark * 0.65);
548
+ return vec4f(lit * edgeFade, edgeFade);
549
+ }
550
+ `,
551
+ });
552
+ this.groundShadowPipeline = this.createRenderPipeline({
553
+ label: "ground shadow pipeline",
554
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.groundShadowBindGroupLayout] }),
555
+ shaderModule: groundShadowShader,
556
+ vertexBuffers: fullVertexBuffers,
557
+ fragmentTarget: standardBlend,
558
+ cullMode: "back",
559
+ depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
560
+ });
507
561
  // Create reflection pipeline (multisampled version for higher quality)
508
562
  this.reflectionPipeline = this.createRenderPipeline({
509
563
  label: "reflection pipeline",
@@ -894,50 +948,32 @@ export class Engine {
894
948
  reflectionTextureSize: 1024,
895
949
  fadeStart: 5.0,
896
950
  fadeEnd: 60.0,
951
+ mode: "reflection",
952
+ shadowMapSize: 4096,
953
+ shadowStrength: 1.0,
897
954
  ...options,
898
955
  };
899
- // Create ground geometry
956
+ this.groundMode = opts.mode;
900
957
  this.createGroundGeometry(opts.width, opts.height);
901
- this.createGroundMaterialBuffer(opts.diffuseColor, opts.reflectionLevel, opts.fadeStart, opts.fadeEnd);
902
- this.createReflectionTexture(opts.reflectionTextureSize);
958
+ if (opts.mode === "reflection") {
959
+ this.createGroundMaterialBuffer(opts.diffuseColor, opts.reflectionLevel, opts.fadeStart, opts.fadeEnd);
960
+ this.createReflectionTexture(opts.reflectionTextureSize);
961
+ }
962
+ else {
963
+ this.createShadowGroundResources(opts.shadowMapSize, opts.diffuseColor, opts.fadeStart, opts.fadeEnd, opts.shadowStrength);
964
+ }
903
965
  this.groundHasReflections = true;
904
- this.drawCalls.push({
966
+ this.groundDrawCall = {
905
967
  type: "ground",
906
- count: 6, // 2 triangles, 3 indices each
968
+ count: 6,
907
969
  firstIndex: 0,
908
- bindGroup: this.groundReflectionBindGroup,
970
+ bindGroup: (opts.mode === "reflection" ? this.groundReflectionBindGroup : this.groundShadowBindGroup),
909
971
  materialName: "Ground",
910
- });
972
+ };
911
973
  }
912
974
  updateLightBuffer() {
913
975
  this.device.queue.writeBuffer(this.lightUniformBuffer, 0, this.lightData);
914
976
  }
915
- async loadAnimation(url) {
916
- if (!this.currentModel)
917
- return;
918
- await this.currentModel.loadVmd(url);
919
- }
920
- loadAnimationData(data) {
921
- this.currentModel?.loadAnimationData(data);
922
- }
923
- getAnimationData() {
924
- return this.currentModel?.getAnimationData() ?? null;
925
- }
926
- playAnimation() {
927
- this.currentModel?.playAnimation();
928
- }
929
- stopAnimation() {
930
- this.currentModel?.stopAnimation();
931
- }
932
- pauseAnimation() {
933
- this.currentModel?.pauseAnimation();
934
- }
935
- seekAnimation(time) {
936
- this.currentModel?.seekAnimation(time);
937
- }
938
- getAnimationProgress() {
939
- return this.currentModel?.getAnimationProgress() ?? { current: 0, duration: 0, percentage: 0 };
940
- }
941
977
  getStats() {
942
978
  return { ...this.stats };
943
979
  }
@@ -961,7 +997,9 @@ export class Engine {
961
997
  }
962
998
  dispose() {
963
999
  this.stopRenderLoop();
964
- this.stopAnimation();
1000
+ this.forEachInstance((inst) => inst.model.stopAnimation());
1001
+ if (Engine.instance === this)
1002
+ Engine.instance = null;
965
1003
  if (this.camera)
966
1004
  this.camera.detachControl();
967
1005
  // Remove raycasting event listeners
@@ -974,145 +1012,175 @@ export class Engine {
974
1012
  this.resizeObserver = null;
975
1013
  }
976
1014
  }
977
- // Step 6: Load PMX model file
978
- async loadModel(path) {
979
- const pathParts = path.split("/");
1015
+ async addModel(model, pmxPath, name) {
1016
+ const requested = name ?? model.name;
1017
+ let key = requested;
1018
+ let n = 1;
1019
+ while (this.modelInstances.has(key)) {
1020
+ key = `${requested}_${n++}`;
1021
+ }
1022
+ const pathParts = pmxPath.split("/");
980
1023
  pathParts.pop();
981
- const dir = pathParts.join("/") + "/";
982
- this.modelDir = dir;
983
- const model = await PmxLoader.load(path);
984
- // Clear cached skinned vertices when loading a new model
985
- this.cachedSkinnedVertices = undefined;
986
- this.cachedSkinMatricesVersion = -1;
987
- await this.setupModelBuffers(model);
1024
+ const basePath = pathParts.join("/") + "/";
1025
+ await this.setupModelInstance(key, model, basePath);
1026
+ return key;
988
1027
  }
989
- rotateBones(boneRotations, durationMs) {
990
- this.currentModel?.rotateBones(boneRotations, durationMs);
1028
+ async registerModel(model, pmxPath) {
1029
+ return this.addModel(model, pmxPath);
991
1030
  }
992
- // moveBones now takes relative translations (VMD-style) by default
993
- moveBones(boneTranslations, durationMs) {
994
- this.currentModel?.moveBones(boneTranslations, durationMs);
1031
+ removeModel(name) {
1032
+ this.modelInstances.delete(name);
995
1033
  }
996
- resetAllBones() {
997
- this.currentModel?.resetAllBones();
1034
+ getModelNames() {
1035
+ return Array.from(this.modelInstances.keys());
998
1036
  }
999
- resetAllMorphs() {
1000
- this.currentModel?.resetAllMorphs();
1037
+ getModel(name) {
1038
+ return this.modelInstances.get(name)?.model ?? null;
1001
1039
  }
1002
- setMorphWeight(name, weight, durationMs) {
1003
- if (!this.currentModel)
1040
+ markVertexBufferDirty(modelNameOrModel) {
1041
+ if (modelNameOrModel === undefined)
1042
+ return;
1043
+ if (typeof modelNameOrModel === "string") {
1044
+ const inst = this.modelInstances.get(modelNameOrModel);
1045
+ if (inst)
1046
+ inst.vertexBufferNeedsUpdate = true;
1004
1047
  return;
1005
- this.currentModel.setMorphWeight(name, weight, durationMs);
1006
- if (!durationMs || durationMs === 0) {
1007
- this.vertexBufferNeedsUpdate = true;
1008
- }
1009
- }
1010
- setMaterialVisible(name, visible) {
1011
- if (visible) {
1012
- this.hiddenMaterials.delete(name);
1013
- }
1014
- else {
1015
- this.hiddenMaterials.add(name);
1016
- }
1017
- }
1018
- toggleMaterialVisible(name) {
1019
- if (this.hiddenMaterials.has(name)) {
1020
- this.hiddenMaterials.delete(name);
1021
1048
  }
1022
- else {
1023
- this.hiddenMaterials.add(name);
1049
+ for (const inst of this.modelInstances.values()) {
1050
+ if (inst.model === modelNameOrModel) {
1051
+ inst.vertexBufferNeedsUpdate = true;
1052
+ return;
1053
+ }
1024
1054
  }
1025
1055
  }
1026
- isMaterialVisible(name) {
1027
- return !this.hiddenMaterials.has(name);
1056
+ setMaterialVisible(modelName, materialName, visible) {
1057
+ const inst = this.modelInstances.get(modelName);
1058
+ if (!inst)
1059
+ return;
1060
+ if (visible)
1061
+ inst.hiddenMaterials.delete(materialName);
1062
+ else
1063
+ inst.hiddenMaterials.add(materialName);
1064
+ }
1065
+ toggleMaterialVisible(modelName, materialName) {
1066
+ const inst = this.modelInstances.get(modelName);
1067
+ if (!inst)
1068
+ return;
1069
+ if (inst.hiddenMaterials.has(materialName))
1070
+ inst.hiddenMaterials.delete(materialName);
1071
+ else
1072
+ inst.hiddenMaterials.add(materialName);
1028
1073
  }
1029
- getBones() {
1030
- return this.currentModel?.getSkeleton().bones.map((bone) => bone.name) ?? [];
1074
+ isMaterialVisible(modelName, materialName) {
1075
+ const inst = this.modelInstances.get(modelName);
1076
+ return inst ? !inst.hiddenMaterials.has(materialName) : false;
1031
1077
  }
1032
- getMorphs() {
1033
- return this.currentModel?.getMorphing().morphs.map((morph) => morph.name) ?? [];
1078
+ setModelIKEnabled(modelName, enabled) {
1079
+ this.modelInstances.get(modelName)?.model.setIKEnabled(enabled);
1034
1080
  }
1035
- getMaterials() {
1036
- return this.currentModel?.getMaterials().map((material) => material.name) ?? [];
1081
+ setModelPhysicsEnabled(modelName, enabled) {
1082
+ this.modelInstances.get(modelName)?.model.setPhysicsEnabled(enabled);
1037
1083
  }
1038
- // IK control
1039
- get disableIK() {
1040
- return this._disableIK;
1084
+ resetPhysics() {
1085
+ this.forEachInstance((inst) => {
1086
+ if (!inst.physics)
1087
+ return;
1088
+ inst.model.computeWorldMatrices();
1089
+ inst.physics.reset(inst.model.getWorldMatrices(), inst.model.getBoneInverseBindMatrices());
1090
+ });
1041
1091
  }
1042
- set disableIK(value) {
1043
- this._disableIK = value;
1044
- this.currentModel?.setIKEnabled(!value);
1092
+ instances() {
1093
+ return this.modelInstances.values();
1045
1094
  }
1046
- // Physics control
1047
- get disablePhysics() {
1048
- return this._disablePhysics;
1095
+ forEachInstance(fn) {
1096
+ for (const inst of this.instances())
1097
+ fn(inst);
1049
1098
  }
1050
- set disablePhysics(value) {
1051
- this._disablePhysics = value;
1052
- this.currentModel?.setPhysicsEnabled(!value);
1099
+ updateInstances(deltaTime) {
1100
+ this.forEachInstance((inst) => {
1101
+ const verticesChanged = inst.model.update(deltaTime);
1102
+ if (verticesChanged)
1103
+ inst.vertexBufferNeedsUpdate = true;
1104
+ if (inst.physics && inst.model.getPhysicsEnabled()) {
1105
+ inst.physics.step(deltaTime, inst.model.getWorldMatrices(), inst.model.getBoneInverseBindMatrices());
1106
+ }
1107
+ if (inst.vertexBufferNeedsUpdate)
1108
+ this.updateVertexBuffer(inst);
1109
+ });
1053
1110
  }
1054
- updateVertexBuffer() {
1055
- if (!this.currentModel || !this.vertexBuffer)
1056
- return;
1057
- const vertices = this.currentModel.getVertices();
1058
- if (!vertices || vertices.length === 0)
1111
+ updateVertexBuffer(inst) {
1112
+ const vertices = inst.model.getVertices();
1113
+ if (!vertices?.length)
1059
1114
  return;
1060
- this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices);
1115
+ this.device.queue.writeBuffer(inst.vertexBuffer, 0, vertices);
1116
+ inst.vertexBufferNeedsUpdate = false;
1061
1117
  }
1062
- // Step 7: Create vertex, index, and joint buffers
1063
- async setupModelBuffers(model) {
1064
- this.currentModel = model;
1065
- // Apply IK and Physics flags from engine options
1066
- model.setIKEnabled(!this._disableIK);
1067
- model.setPhysicsEnabled(!this._disablePhysics);
1118
+ async setupModelInstance(name, model, basePath) {
1068
1119
  const vertices = model.getVertices();
1069
1120
  const skinning = model.getSkinning();
1070
1121
  const skeleton = model.getSkeleton();
1071
- this.vertexBuffer = this.device.createBuffer({
1072
- label: "model vertex buffer",
1122
+ const boneCount = skeleton.bones.length;
1123
+ const matrixSize = boneCount * 16 * 4;
1124
+ const vertexBuffer = this.device.createBuffer({
1125
+ label: `${name}: vertex buffer`,
1073
1126
  size: vertices.byteLength,
1074
1127
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1075
1128
  });
1076
- this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices);
1077
- this.jointsBuffer = this.device.createBuffer({
1078
- label: "joints buffer",
1129
+ this.device.queue.writeBuffer(vertexBuffer, 0, vertices);
1130
+ const jointsBuffer = this.device.createBuffer({
1131
+ label: `${name}: joints buffer`,
1079
1132
  size: skinning.joints.byteLength,
1080
1133
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1081
1134
  });
1082
- this.device.queue.writeBuffer(this.jointsBuffer, 0, skinning.joints.buffer, skinning.joints.byteOffset, skinning.joints.byteLength);
1083
- this.weightsBuffer = this.device.createBuffer({
1084
- label: "weights buffer",
1135
+ this.device.queue.writeBuffer(jointsBuffer, 0, skinning.joints.buffer, skinning.joints.byteOffset, skinning.joints.byteLength);
1136
+ const weightsBuffer = this.device.createBuffer({
1137
+ label: `${name}: weights buffer`,
1085
1138
  size: skinning.weights.byteLength,
1086
1139
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1087
1140
  });
1088
- this.device.queue.writeBuffer(this.weightsBuffer, 0, skinning.weights.buffer, skinning.weights.byteOffset, skinning.weights.byteLength);
1089
- const boneCount = skeleton.bones.length;
1090
- const matrixSize = boneCount * 16 * 4;
1091
- this.skinMatrixBuffer = this.device.createBuffer({
1092
- label: "skin matrices",
1141
+ this.device.queue.writeBuffer(weightsBuffer, 0, skinning.weights.buffer, skinning.weights.byteOffset, skinning.weights.byteLength);
1142
+ const skinMatrixBuffer = this.device.createBuffer({
1143
+ label: `${name}: skin matrices`,
1093
1144
  size: Math.max(256, matrixSize),
1094
1145
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1095
1146
  });
1096
- this.inverseBindMatrixBuffer = this.device.createBuffer({
1097
- label: "inverse bind matrices",
1098
- size: Math.max(256, matrixSize),
1099
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1100
- });
1101
- const invBindMatrices = skeleton.inverseBindMatrices;
1102
- this.device.queue.writeBuffer(this.inverseBindMatrixBuffer, 0, invBindMatrices.buffer, invBindMatrices.byteOffset, invBindMatrices.byteLength);
1103
1147
  const indices = model.getIndices();
1104
- if (indices) {
1105
- this.indexBuffer = this.device.createBuffer({
1106
- label: "model index buffer",
1107
- size: indices.byteLength,
1108
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
1109
- });
1110
- this.device.queue.writeBuffer(this.indexBuffer, 0, indices);
1111
- }
1112
- else {
1148
+ if (!indices)
1113
1149
  throw new Error("Model has no index buffer");
1114
- }
1115
- await this.setupMaterials(model);
1150
+ const indexBuffer = this.device.createBuffer({
1151
+ label: `${name}: index buffer`,
1152
+ size: indices.byteLength,
1153
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
1154
+ });
1155
+ this.device.queue.writeBuffer(indexBuffer, 0, indices);
1156
+ const rbs = model.getRigidbodies();
1157
+ const physics = rbs.length > 0 ? new Physics(rbs, model.getJoints()) : null;
1158
+ const shadowBindGroup = this.device.createBindGroup({
1159
+ label: `${name}: shadow bind`,
1160
+ layout: this.shadowDepthPipeline.getBindGroupLayout(0),
1161
+ entries: [
1162
+ { binding: 0, resource: { buffer: this.shadowLightVPBuffer } },
1163
+ { binding: 1, resource: { buffer: skinMatrixBuffer } },
1164
+ ],
1165
+ });
1166
+ const inst = {
1167
+ name,
1168
+ model,
1169
+ basePath,
1170
+ vertexBuffer,
1171
+ indexBuffer,
1172
+ jointsBuffer,
1173
+ weightsBuffer,
1174
+ skinMatrixBuffer,
1175
+ drawCalls: [],
1176
+ shadowDrawCalls: [],
1177
+ shadowBindGroup,
1178
+ hiddenMaterials: new Set(),
1179
+ physics,
1180
+ vertexBufferNeedsUpdate: false,
1181
+ };
1182
+ await this.setupMaterialsForInstance(inst);
1183
+ this.modelInstances.set(name, inst);
1116
1184
  }
1117
1185
  createGroundGeometry(width = 100, height = 100) {
1118
1186
  const halfWidth = width / 2;
@@ -1178,27 +1246,24 @@ export class Engine {
1178
1246
  });
1179
1247
  this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices);
1180
1248
  }
1181
- createGroundMaterialBuffer(diffuseColor = new Vec3(1, 1, 1), reflectionLevel = 0.5, fadeStart = 5.0, fadeEnd = 60.0) {
1182
- const materialData = new Float32Array([
1183
- diffuseColor.x,
1184
- diffuseColor.y,
1185
- diffuseColor.z, // diffuseColor (12 bytes)
1186
- reflectionLevel, // reflectionLevel (4 bytes)
1187
- fadeStart, // fadeStart (4 bytes)
1188
- fadeEnd, // fadeEnd (4 bytes)
1189
- 0, // padding (4 bytes)
1190
- 0, // padding (4 bytes)
1191
- 0, // padding (4 bytes)
1192
- 0, // padding (4 bytes)
1193
- ]);
1249
+ createGroundMaterialBuffer(diffuseColor, reflectionLevel, fadeStart, fadeEnd) {
1250
+ const u = new Float32Array(8);
1251
+ u[0] = diffuseColor.x;
1252
+ u[1] = diffuseColor.y;
1253
+ u[2] = diffuseColor.z;
1254
+ u[3] = reflectionLevel;
1255
+ u[4] = fadeStart;
1256
+ u[5] = fadeEnd;
1257
+ u[6] = 0;
1258
+ u[7] = 0;
1194
1259
  this.groundMaterialUniformBuffer = this.device.createBuffer({
1195
1260
  label: "ground material uniform buffer",
1196
- size: materialData.byteLength,
1261
+ size: 64,
1197
1262
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1198
1263
  });
1199
- this.device.queue.writeBuffer(this.groundMaterialUniformBuffer, 0, materialData);
1264
+ this.device.queue.writeBuffer(this.groundMaterialUniformBuffer, 0, u);
1200
1265
  }
1201
- createReflectionTexture(size = 1024) {
1266
+ createReflectionTexture(size) {
1202
1267
  this.groundReflectionTexture = this.device.createTexture({
1203
1268
  label: "ground reflection texture",
1204
1269
  size: [size, size],
@@ -1219,34 +1284,88 @@ export class Engine {
1219
1284
  format: "depth24plus-stencil8",
1220
1285
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
1221
1286
  });
1222
- // Create a bind group for the reflection texture that can be used in the ground material
1223
1287
  this.groundReflectionBindGroup = this.device.createBindGroup({
1224
1288
  label: "ground reflection bind group",
1225
1289
  layout: this.groundBindGroupLayout,
1226
1290
  entries: [
1227
1291
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1228
1292
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1229
- { binding: 2, resource: this.groundReflectionResolveTexture.createView() }, // Use resolve texture for sampling
1293
+ { binding: 2, resource: this.groundReflectionResolveTexture.createView() },
1230
1294
  { binding: 3, resource: this.materialSampler },
1231
1295
  { binding: 4, resource: { buffer: this.groundMaterialUniformBuffer } },
1232
1296
  ],
1233
1297
  });
1234
1298
  }
1235
- async setupMaterials(model) {
1299
+ createShadowGroundResources(shadowMapSize, diffuseColor, fadeStart, fadeEnd, shadowStrength) {
1300
+ this.shadowMapTexture = this.device.createTexture({
1301
+ label: "shadow map",
1302
+ size: [shadowMapSize, shadowMapSize],
1303
+ format: "depth32float",
1304
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1305
+ });
1306
+ this.shadowMapDepthView = this.shadowMapTexture.createView();
1307
+ const gb = new Float32Array(8);
1308
+ gb[0] = diffuseColor.x;
1309
+ gb[1] = diffuseColor.y;
1310
+ gb[2] = diffuseColor.z;
1311
+ gb[3] = fadeStart;
1312
+ gb[4] = fadeEnd;
1313
+ gb[5] = shadowStrength;
1314
+ gb[6] = 1.2 / shadowMapSize;
1315
+ gb[7] = 0;
1316
+ this.groundShadowMaterialBuffer = this.device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
1317
+ this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb);
1318
+ this.groundShadowBindGroup = this.device.createBindGroup({
1319
+ label: "ground shadow bind",
1320
+ layout: this.groundShadowBindGroupLayout,
1321
+ entries: [
1322
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1323
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1324
+ { binding: 2, resource: this.shadowMapDepthView },
1325
+ { binding: 3, resource: this.shadowComparisonSampler },
1326
+ { binding: 4, resource: { buffer: this.groundShadowMaterialBuffer } },
1327
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
1328
+ ],
1329
+ });
1330
+ }
1331
+ updateShadowLightVP() {
1332
+ const lx = this.lightData[4];
1333
+ const ly = this.lightData[5];
1334
+ const lz = this.lightData[6];
1335
+ if (lx === this.shadowVPLightX && ly === this.shadowVPLightY && lz === this.shadowVPLightZ)
1336
+ return;
1337
+ this.shadowVPLightX = lx;
1338
+ this.shadowVPLightY = ly;
1339
+ this.shadowVPLightZ = lz;
1340
+ const dir = new Vec3(lx, ly, lz);
1341
+ if (dir.length() < 1e-6) {
1342
+ dir.x = 0.35;
1343
+ dir.y = -1;
1344
+ dir.z = 0.2;
1345
+ }
1346
+ else
1347
+ dir.normalize();
1348
+ const target = new Vec3(0, 11, 0);
1349
+ const eye = new Vec3(target.x - dir.x * 72, target.y - dir.y * 72, target.z - dir.z * 72);
1350
+ const view = Mat4.lookAt(eye, target, new Vec3(0, 1, 0));
1351
+ const proj = Mat4.orthographicLh(-72, 72, -72, 72, 1, 140);
1352
+ const vp = proj.multiply(view);
1353
+ this.shadowLightVPMatrix.set(vp.values);
1354
+ this.device.queue.writeBuffer(this.shadowLightVPBuffer, 0, this.shadowLightVPMatrix);
1355
+ }
1356
+ async setupMaterialsForInstance(inst) {
1357
+ const model = inst.model;
1236
1358
  const materials = model.getMaterials();
1237
- if (materials.length === 0) {
1359
+ if (materials.length === 0)
1238
1360
  throw new Error("Model has no materials");
1239
- }
1240
1361
  const textures = model.getTextures();
1362
+ const prefix = `${inst.name}: `;
1241
1363
  const loadTextureByIndex = async (texIndex) => {
1242
- if (texIndex < 0 || texIndex >= textures.length) {
1364
+ if (texIndex < 0 || texIndex >= textures.length)
1243
1365
  return null;
1244
- }
1245
- const path = this.modelDir + textures[texIndex].path;
1246
- const texture = await this.createTextureFromPath(path);
1247
- return texture;
1366
+ const path = inst.basePath + textures[texIndex].path;
1367
+ return this.createTextureFromPath(path);
1248
1368
  };
1249
- this.drawCalls = [];
1250
1369
  let currentIndexOffset = 0;
1251
1370
  for (const mat of materials) {
1252
1371
  const indexCount = mat.vertexCount;
@@ -1257,124 +1376,87 @@ export class Engine {
1257
1376
  throw new Error(`Material "${mat.name}" has no diffuse texture`);
1258
1377
  const materialAlpha = mat.diffuse[3];
1259
1378
  const isTransparent = materialAlpha < 1.0 - 0.001;
1260
- const materialUniformBuffer = this.createMaterialUniformBuffer(mat.name, materialAlpha, 0.0, [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]], mat.ambient, mat.specular, mat.shininess);
1261
- // Create bind groups using the shared bind group layout - All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
1379
+ 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
1380
  const bindGroup = this.device.createBindGroup({
1263
- label: `material bind group: ${mat.name}`,
1381
+ label: `${prefix}material: ${mat.name}`,
1264
1382
  layout: this.mainBindGroupLayout,
1265
1383
  entries: [
1266
1384
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1267
1385
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1268
1386
  { binding: 2, resource: diffuseTexture.createView() },
1269
1387
  { binding: 3, resource: this.materialSampler },
1270
- { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1388
+ { binding: 4, resource: { buffer: inst.skinMatrixBuffer } },
1271
1389
  { binding: 5, resource: { buffer: materialUniformBuffer } },
1272
1390
  ],
1273
1391
  });
1274
1392
  if (indexCount > 0) {
1275
1393
  if (mat.isEye) {
1276
- this.drawCalls.push({
1277
- type: "eye",
1278
- count: indexCount,
1279
- firstIndex: currentIndexOffset,
1280
- bindGroup,
1281
- materialName: mat.name,
1282
- });
1394
+ inst.drawCalls.push({ type: "eye", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name });
1283
1395
  }
1284
1396
  else if (mat.isHair) {
1285
- // Hair materials: create separate bind groups for over-eyes vs over-non-eyes
1286
1397
  const createHairBindGroup = (isOverEyes) => {
1287
- const buffer = this.createMaterialUniformBuffer(`${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
+ 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);
1288
1399
  return this.device.createBindGroup({
1289
- label: `material bind group (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1400
+ label: `${prefix}hair ${isOverEyes ? "over eyes" : "over non-eyes"}: ${mat.name}`,
1290
1401
  layout: this.mainBindGroupLayout,
1291
1402
  entries: [
1292
1403
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1293
1404
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1294
1405
  { binding: 2, resource: diffuseTexture.createView() },
1295
1406
  { binding: 3, resource: this.materialSampler },
1296
- { binding: 4, resource: { buffer: this.skinMatrixBuffer } },
1297
- { binding: 5, resource: { buffer: buffer } },
1407
+ { binding: 4, resource: { buffer: inst.skinMatrixBuffer } },
1408
+ { binding: 5, resource: { buffer: buf } },
1298
1409
  ],
1299
1410
  });
1300
1411
  };
1301
- const bindGroupOverEyes = createHairBindGroup(true);
1302
- const bindGroupOverNonEyes = createHairBindGroup(false);
1303
- this.drawCalls.push({
1412
+ inst.drawCalls.push({
1304
1413
  type: "hair-over-eyes",
1305
1414
  count: indexCount,
1306
1415
  firstIndex: currentIndexOffset,
1307
- bindGroup: bindGroupOverEyes,
1416
+ bindGroup: createHairBindGroup(true),
1308
1417
  materialName: mat.name,
1309
1418
  });
1310
- this.drawCalls.push({
1419
+ inst.drawCalls.push({
1311
1420
  type: "hair-over-non-eyes",
1312
1421
  count: indexCount,
1313
1422
  firstIndex: currentIndexOffset,
1314
- bindGroup: bindGroupOverNonEyes,
1423
+ bindGroup: createHairBindGroup(false),
1315
1424
  materialName: mat.name,
1316
1425
  });
1317
1426
  }
1318
1427
  else if (isTransparent) {
1319
- this.drawCalls.push({
1320
- type: "transparent",
1321
- count: indexCount,
1322
- firstIndex: currentIndexOffset,
1323
- bindGroup,
1324
- materialName: mat.name,
1325
- });
1428
+ inst.drawCalls.push({ type: "transparent", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name });
1326
1429
  }
1327
1430
  else {
1328
- this.drawCalls.push({
1329
- type: "opaque",
1330
- count: indexCount,
1331
- firstIndex: currentIndexOffset,
1332
- bindGroup,
1333
- materialName: mat.name,
1334
- });
1431
+ inst.drawCalls.push({ type: "opaque", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name });
1335
1432
  }
1336
1433
  }
1337
- // Edge flag is at bit 4 (0x10) in PMX format
1338
1434
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1339
1435
  const materialUniformData = new Float32Array([
1340
- mat.edgeColor[0],
1341
- mat.edgeColor[1],
1342
- mat.edgeColor[2],
1343
- mat.edgeColor[3],
1344
- mat.edgeSize,
1345
- 0,
1346
- 0,
1347
- 0,
1436
+ mat.edgeColor[0], mat.edgeColor[1], mat.edgeColor[2], mat.edgeColor[3],
1437
+ mat.edgeSize, 0, 0, 0,
1348
1438
  ]);
1349
- const materialUniformBuffer = this.createUniformBuffer(`outline material uniform: ${mat.name}`, materialUniformData);
1439
+ const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData);
1350
1440
  const outlineBindGroup = this.device.createBindGroup({
1351
- label: `outline bind group: ${mat.name}`,
1441
+ label: `${prefix}outline: ${mat.name}`,
1352
1442
  layout: this.outlineBindGroupLayout,
1353
1443
  entries: [
1354
1444
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1355
- { binding: 1, resource: { buffer: materialUniformBuffer } },
1356
- { binding: 2, resource: { buffer: this.skinMatrixBuffer } },
1445
+ { binding: 1, resource: { buffer: outlineUniformBuffer } },
1446
+ { binding: 2, resource: { buffer: inst.skinMatrixBuffer } },
1357
1447
  ],
1358
1448
  });
1359
1449
  if (indexCount > 0) {
1360
- const outlineType = mat.isEye
1361
- ? "eye-outline"
1362
- : mat.isHair
1363
- ? "hair-outline"
1364
- : isTransparent
1365
- ? "transparent-outline"
1366
- : "opaque-outline";
1367
- this.drawCalls.push({
1368
- type: outlineType,
1369
- count: indexCount,
1370
- firstIndex: currentIndexOffset,
1371
- bindGroup: outlineBindGroup,
1372
- materialName: mat.name,
1373
- });
1450
+ const outlineType = mat.isEye ? "eye-outline" : mat.isHair ? "hair-outline" : isTransparent ? "transparent-outline" : "opaque-outline";
1451
+ inst.drawCalls.push({ type: outlineType, count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup, materialName: mat.name });
1374
1452
  }
1375
1453
  }
1376
1454
  currentIndexOffset += indexCount;
1377
1455
  }
1456
+ for (const d of inst.drawCalls) {
1457
+ if (d.type === "opaque" || d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes")
1458
+ inst.shadowDrawCalls.push(d);
1459
+ }
1378
1460
  }
1379
1461
  createMaterialUniformBuffer(label, alpha, isOverEyes, diffuseColor, ambientColor, specularColor, shininess) {
1380
1462
  const data = new Float32Array(20);
@@ -1411,8 +1493,8 @@ export class Engine {
1411
1493
  this.device.queue.writeBuffer(buffer, 0, data);
1412
1494
  return buffer;
1413
1495
  }
1414
- shouldRenderDrawCall(drawCall) {
1415
- return !this.hiddenMaterials.has(drawCall.materialName);
1496
+ shouldRenderDrawCall(inst, drawCall) {
1497
+ return !inst.hiddenMaterials.has(drawCall.materialName);
1416
1498
  }
1417
1499
  async createTextureFromPath(path) {
1418
1500
  const cached = this.textureCache.get(path);
@@ -1446,11 +1528,10 @@ export class Engine {
1446
1528
  }
1447
1529
  }
1448
1530
  // Helper: Render eyes with stencil writing (for post-alpha-eye effect)
1449
- renderEyes(pass, useReflectionPipeline = false) {
1531
+ renderEyes(pass, inst, useReflectionPipeline = false) {
1450
1532
  if (useReflectionPipeline) {
1451
- // For reflections, use the basic reflection pipeline instead of specialized eye pipeline
1452
1533
  pass.setPipeline(this.reflectionPipeline);
1453
- for (const draw of this.drawCalls) {
1534
+ for (const draw of inst.drawCalls) {
1454
1535
  if (draw.type === "eye") {
1455
1536
  pass.setBindGroup(0, draw.bindGroup);
1456
1537
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
@@ -1460,8 +1541,8 @@ export class Engine {
1460
1541
  else {
1461
1542
  pass.setPipeline(this.eyePipeline);
1462
1543
  pass.setStencilReference(this.STENCIL_EYE_VALUE);
1463
- for (const draw of this.drawCalls) {
1464
- if (draw.type === "eye" && this.shouldRenderDrawCall(draw)) {
1544
+ for (const draw of inst.drawCalls) {
1545
+ if (draw.type === "eye" && this.shouldRenderDrawCall(inst, draw)) {
1465
1546
  pass.setBindGroup(0, draw.bindGroup);
1466
1547
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1467
1548
  }
@@ -1469,23 +1550,15 @@ export class Engine {
1469
1550
  }
1470
1551
  }
1471
1552
  renderGround(pass) {
1472
- if (!this.groundHasReflections || !this.groundVertexBuffer || !this.groundIndexBuffer) {
1553
+ if (!this.groundHasReflections || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall)
1473
1554
  return;
1474
- }
1475
- if (this.groundReflectionTexture) {
1555
+ if (this.groundMode === "reflection" && this.groundReflectionTexture)
1476
1556
  this.renderReflectionTexture();
1477
- }
1478
- pass.setPipeline(this.groundPipeline);
1557
+ pass.setPipeline(this.groundMode === "reflection" ? this.groundPipeline : this.groundShadowPipeline);
1479
1558
  pass.setVertexBuffer(0, this.groundVertexBuffer);
1480
1559
  pass.setIndexBuffer(this.groundIndexBuffer, "uint16");
1481
- for (const draw of this.drawCalls) {
1482
- if (draw.type === "ground" && this.shouldRenderDrawCall(draw)) {
1483
- pass.setBindGroup(0, draw.bindGroup);
1484
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1485
- }
1486
- }
1487
- // // Restore model index buffer for subsequent rendering
1488
- // pass.setIndexBuffer(this.indexBuffer!, "uint32")
1560
+ pass.setBindGroup(0, this.groundDrawCall.bindGroup);
1561
+ pass.drawIndexed(this.groundDrawCall.count, 1, this.groundDrawCall.firstIndex, 0, 0);
1489
1562
  }
1490
1563
  renderReflectionTexture() {
1491
1564
  if (!this.groundReflectionTexture)
@@ -1515,45 +1588,15 @@ export class Engine {
1515
1588
  },
1516
1589
  };
1517
1590
  const reflectionPass = reflectionEncoder.beginRenderPass(reflectionPassDescriptor);
1518
- if (this.currentModel) {
1519
- reflectionPass.setVertexBuffer(0, this.vertexBuffer);
1520
- reflectionPass.setVertexBuffer(1, this.jointsBuffer);
1521
- reflectionPass.setVertexBuffer(2, this.weightsBuffer);
1522
- reflectionPass.setIndexBuffer(this.indexBuffer, "uint32");
1523
- this.writeMirrorTransformedSkinMatrices(mirrorMatrix);
1524
- reflectionPass.setPipeline(this.reflectionPipeline);
1525
- for (const draw of this.drawCalls) {
1526
- if (draw.type === "opaque" && this.shouldRenderDrawCall(draw)) {
1527
- reflectionPass.setBindGroup(0, draw.bindGroup);
1528
- reflectionPass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1529
- }
1530
- }
1531
- // Render eyes (using reflection pipeline)
1532
- this.renderEyes(reflectionPass, true);
1533
- // Render hair (using reflection pipeline)
1534
- this.renderHair(reflectionPass, true);
1535
- // Render transparent objects
1536
- for (const draw of this.drawCalls) {
1537
- if (draw.type === "transparent" && this.shouldRenderDrawCall(draw)) {
1538
- reflectionPass.setBindGroup(0, draw.bindGroup);
1539
- reflectionPass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1540
- }
1541
- }
1542
- this.drawOutlines(reflectionPass, true, true);
1543
- }
1591
+ this.forEachInstance((inst) => this.renderOneModel(reflectionPass, inst, true, mirrorMatrix));
1544
1592
  reflectionPass.end();
1545
- // Submit reflection rendering commands
1546
- const reflectionCommandBuffer = reflectionEncoder.finish();
1547
- this.device.queue.submit([reflectionCommandBuffer]);
1548
- // Restore original skin matrices
1593
+ this.device.queue.submit([reflectionEncoder.finish()]);
1549
1594
  this.updateSkinMatrices();
1550
1595
  }
1551
- // Helper: Render hair with post-alpha-eye effect (depth pre-pass + stencil-based shading + outlines)
1552
- renderHair(pass, useReflectionPipeline = false) {
1596
+ renderHair(pass, inst, useReflectionPipeline = false) {
1553
1597
  if (useReflectionPipeline) {
1554
- // For reflections, use the basic reflection pipeline for all hair
1555
1598
  pass.setPipeline(this.reflectionPipeline);
1556
- for (const draw of this.drawCalls) {
1599
+ for (const draw of inst.drawCalls) {
1557
1600
  if (draw.type === "hair-over-eyes" || draw.type === "hair-over-non-eyes") {
1558
1601
  pass.setBindGroup(0, draw.bindGroup);
1559
1602
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
@@ -1561,19 +1604,17 @@ export class Engine {
1561
1604
  }
1562
1605
  return;
1563
1606
  }
1564
- // Hair depth pre-pass (reduces overdraw via early depth rejection)
1565
- const hasHair = this.drawCalls.some((d) => (d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(d));
1607
+ const hasHair = inst.drawCalls.some((d) => (d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(inst, d));
1566
1608
  if (hasHair) {
1567
1609
  pass.setPipeline(this.hairDepthPipeline);
1568
- for (const draw of this.drawCalls) {
1569
- if ((draw.type === "hair-over-eyes" || draw.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(draw)) {
1610
+ for (const draw of inst.drawCalls) {
1611
+ if ((draw.type === "hair-over-eyes" || draw.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(inst, draw)) {
1570
1612
  pass.setBindGroup(0, draw.bindGroup);
1571
1613
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1572
1614
  }
1573
1615
  }
1574
1616
  }
1575
- // Hair shading (split by stencil for transparency over eyes)
1576
- const hairOverEyes = this.drawCalls.filter((d) => d.type === "hair-over-eyes" && this.shouldRenderDrawCall(d));
1617
+ const hairOverEyes = inst.drawCalls.filter((d) => d.type === "hair-over-eyes" && this.shouldRenderDrawCall(inst, d));
1577
1618
  if (hairOverEyes.length > 0) {
1578
1619
  pass.setPipeline(this.hairPipelineOverEyes);
1579
1620
  pass.setStencilReference(this.STENCIL_EYE_VALUE);
@@ -1582,7 +1623,7 @@ export class Engine {
1582
1623
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1583
1624
  }
1584
1625
  }
1585
- const hairOverNonEyes = this.drawCalls.filter((d) => d.type === "hair-over-non-eyes" && this.shouldRenderDrawCall(d));
1626
+ const hairOverNonEyes = inst.drawCalls.filter((d) => d.type === "hair-over-non-eyes" && this.shouldRenderDrawCall(inst, d));
1586
1627
  if (hairOverNonEyes.length > 0) {
1587
1628
  pass.setPipeline(this.hairPipelineOverNonEyes);
1588
1629
  pass.setStencilReference(this.STENCIL_EYE_VALUE);
@@ -1591,8 +1632,7 @@ export class Engine {
1591
1632
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1592
1633
  }
1593
1634
  }
1594
- // Hair outlines
1595
- const hairOutlines = this.drawCalls.filter((d) => d.type === "hair-outline" && this.shouldRenderDrawCall(d));
1635
+ const hairOutlines = inst.drawCalls.filter((d) => d.type === "hair-outline" && this.shouldRenderDrawCall(inst, d));
1596
1636
  if (hairOutlines.length > 0) {
1597
1637
  pass.setPipeline(this.hairOutlinePipeline);
1598
1638
  for (const draw of hairOutlines) {
@@ -1602,237 +1642,203 @@ export class Engine {
1602
1642
  }
1603
1643
  }
1604
1644
  performRaycast(screenX, screenY) {
1605
- if (!this.currentModel || !this.onRaycast)
1645
+ if (!this.onRaycast || this.modelInstances.size === 0) {
1646
+ this.onRaycast?.("", null, screenX, screenY);
1606
1647
  return;
1607
- const materials = this.currentModel.getMaterials();
1608
- if (materials.length === 0)
1609
- return;
1610
- // Get camera matrices
1648
+ }
1611
1649
  const viewMatrix = this.camera.getViewMatrix();
1612
1650
  const projectionMatrix = this.camera.getProjectionMatrix();
1613
- // Convert screen coordinates to world space ray
1614
- const canvas = this.canvas;
1615
- const rect = canvas.getBoundingClientRect();
1616
- // Convert to clip space (-1 to 1)
1651
+ const rect = this.canvas.getBoundingClientRect();
1617
1652
  const clipX = (screenX / rect.width) * 2 - 1;
1618
- const clipY = 1 - (screenY / rect.height) * 2; // Flip Y
1619
- // Create ray in clip space at near and far planes
1620
- const clipNear = new Vec3(clipX, clipY, -1); // Near plane
1621
- const clipFar = new Vec3(clipX, clipY, 1); // Far plane
1622
- // Transform to world space using inverse view-projection matrix
1653
+ const clipY = 1 - (screenY / rect.height) * 2;
1623
1654
  const viewProjMatrix = projectionMatrix.multiply(viewMatrix);
1624
1655
  const inverseViewProj = viewProjMatrix.inverse();
1625
- // Transform point through 4x4 matrix with perspective division
1626
1656
  const transformPoint = (matrix, point) => {
1627
1657
  const m = matrix.values;
1628
1658
  const x = point.x, y = point.y, z = point.z;
1629
- // Compute transformed point (matrix * vec4(point, 1.0))
1630
1659
  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]);
1631
- // Perspective division
1632
1660
  const w = m[3] * x + m[7] * y + m[11] * z + m[15];
1633
- const invW = w !== 0 ? 1 / w : 1;
1634
- return result.scale(invW);
1661
+ return result.scale(w !== 0 ? 1 / w : 1);
1635
1662
  };
1636
- const worldNear = transformPoint(inverseViewProj, clipNear);
1637
- const worldFar = transformPoint(inverseViewProj, clipFar);
1638
- // Create ray from camera position through the clicked point
1663
+ const worldNear = transformPoint(inverseViewProj, new Vec3(clipX, clipY, -1));
1664
+ const worldFar = transformPoint(inverseViewProj, new Vec3(clipX, clipY, 1));
1639
1665
  const rayOrigin = this.camera.getPosition();
1640
1666
  const rayDirection = worldFar.subtract(worldNear).normalize();
1641
- // Get model geometry for ray-triangle intersection
1642
- const baseVertices = this.currentModel.getVertices();
1643
- const indices = this.currentModel.getIndices();
1644
- const skinning = this.currentModel.getSkinning();
1645
- if (!baseVertices || !indices || !skinning) {
1646
- if (this.onRaycast) {
1647
- this.onRaycast(null, screenX, screenY);
1648
- }
1649
- return;
1650
- }
1651
- // Use cached skinned vertices if available and up-to-date
1652
- let vertices;
1653
- if (this.cachedSkinnedVertices && this.cachedSkinMatricesVersion === this.skinMatricesVersion) {
1654
- vertices = this.cachedSkinnedVertices;
1655
- }
1656
- else {
1657
- // Apply current skinning transformations to get animated vertex positions
1658
- vertices = new Float32Array(baseVertices.length);
1659
- const skinMatrices = this.currentModel.getSkinMatrices();
1660
- // Helper function to transform point by 4x4 matrix
1661
- const transformByMatrix = (matrix, offset, point) => {
1662
- const m = matrix;
1663
- const x = point.x, y = point.y, z = point.z;
1664
- 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]);
1665
- };
1667
+ const transformByMatrix = (matrix, offset, point) => {
1668
+ const m = matrix, x = point.x, y = point.y, z = point.z;
1669
+ 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]);
1670
+ };
1671
+ let closest = null;
1672
+ const maxDistance = 1000;
1673
+ this.forEachInstance((inst) => {
1674
+ const model = inst.model;
1675
+ const materials = model.getMaterials();
1676
+ if (materials.length === 0)
1677
+ return;
1678
+ const baseVertices = model.getVertices();
1679
+ const indices = model.getIndices();
1680
+ const skinning = model.getSkinning();
1681
+ if (!baseVertices?.length || !indices || !skinning)
1682
+ return;
1683
+ const vertices = new Float32Array(baseVertices.length);
1684
+ const skinMatrices = model.getSkinMatrices();
1666
1685
  for (let i = 0; i < baseVertices.length; i += 8) {
1667
- const vertexIndex = Math.floor(i / 8);
1686
+ const vertexIndex = i / 8;
1668
1687
  const position = new Vec3(baseVertices[i], baseVertices[i + 1], baseVertices[i + 2]);
1669
- // Get bone influences for this vertex
1670
- const jointIndices = [
1671
- skinning.joints[vertexIndex * 4],
1672
- skinning.joints[vertexIndex * 4 + 1],
1673
- skinning.joints[vertexIndex * 4 + 2],
1674
- skinning.joints[vertexIndex * 4 + 3],
1675
- ];
1676
- const weights = [
1677
- skinning.weights[vertexIndex * 4],
1678
- skinning.weights[vertexIndex * 4 + 1],
1679
- skinning.weights[vertexIndex * 4 + 2],
1680
- skinning.weights[vertexIndex * 4 + 3],
1681
- ];
1682
- // Normalize weights (same as shader)
1683
- const weightSum = weights[0] + weights[1] + weights[2] + weights[3];
1684
- const invWeightSum = weightSum > 0.0001 ? 1.0 / weightSum : 1.0;
1685
- const normalizedWeights = weightSum > 0.0001 ? weights.map((w) => w * invWeightSum) : [1.0, 0.0, 0.0, 0.0];
1686
- // Apply skinning transformation (same as shader)
1687
- let skinnedPosition = new Vec3(0, 0, 0);
1688
+ const j0 = skinning.joints[vertexIndex * 4], j1 = skinning.joints[vertexIndex * 4 + 1], j2 = skinning.joints[vertexIndex * 4 + 2], j3 = skinning.joints[vertexIndex * 4 + 3];
1689
+ 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;
1690
+ const ws = w0 + w1 + w2 + w3;
1691
+ const nw = ws > 0.0001 ? [w0 / ws, w1 / ws, w2 / ws, w3 / ws] : [1, 0, 0, 0];
1692
+ let sp = new Vec3(0, 0, 0);
1688
1693
  for (let j = 0; j < 4; j++) {
1689
- const weight = normalizedWeights[j];
1690
- if (weight > 0) {
1691
- const matrixOffset = jointIndices[j] * 16;
1692
- const transformed = transformByMatrix(skinMatrices, matrixOffset, position);
1693
- skinnedPosition = skinnedPosition.add(transformed.scale(weight));
1694
- }
1694
+ if (nw[j] <= 0)
1695
+ continue;
1696
+ const transformed = transformByMatrix(skinMatrices, [j0, j1, j2, j3][j] * 16, position);
1697
+ sp = sp.add(transformed.scale(nw[j]));
1695
1698
  }
1696
- // Store transformed position, copy other attributes unchanged
1697
- vertices[i] = skinnedPosition.x;
1698
- vertices[i + 1] = skinnedPosition.y;
1699
- vertices[i + 2] = skinnedPosition.z;
1700
- vertices[i + 3] = baseVertices[i + 3]; // normal X
1701
- vertices[i + 4] = baseVertices[i + 4]; // normal Y
1702
- vertices[i + 5] = baseVertices[i + 5]; // normal Z
1703
- vertices[i + 6] = baseVertices[i + 6]; // UV X
1704
- vertices[i + 7] = baseVertices[i + 7]; // UV Y
1699
+ vertices[i] = sp.x;
1700
+ vertices[i + 1] = sp.y;
1701
+ vertices[i + 2] = sp.z;
1702
+ vertices[i + 3] = baseVertices[i + 3];
1703
+ vertices[i + 4] = baseVertices[i + 4];
1704
+ vertices[i + 5] = baseVertices[i + 5];
1705
+ vertices[i + 6] = baseVertices[i + 6];
1706
+ vertices[i + 7] = baseVertices[i + 7];
1705
1707
  }
1706
- // Cache the result
1707
- this.cachedSkinnedVertices = vertices;
1708
- this.cachedSkinMatricesVersion = this.skinMatricesVersion;
1709
- }
1710
- let closestHit = null;
1711
- const maxDistance = 1000; // Reasonable max distance
1712
- // Test ray against all triangles (Möller-Trumbore algorithm)
1713
- for (let i = 0; i < indices.length; i += 3) {
1714
- const idx0 = indices[i] * 8; // Each vertex has 8 floats (pos + normal + uv)
1715
- const idx1 = indices[i + 1] * 8;
1716
- const idx2 = indices[i + 2] * 8;
1717
- // Get triangle vertices in world space (first 3 floats are position)
1718
- const v0 = new Vec3(vertices[idx0], vertices[idx0 + 1], vertices[idx0 + 2]);
1719
- const v1 = new Vec3(vertices[idx1], vertices[idx1 + 1], vertices[idx1 + 2]);
1720
- const v2 = new Vec3(vertices[idx2], vertices[idx2 + 1], vertices[idx2 + 2]);
1721
- // Find which material this triangle belongs to
1722
- // Each material has mat.vertexCount indices (3 per triangle)
1723
- let triangleMaterialIndex = -1;
1724
- let indexOffset = 0;
1725
- for (let matIdx = 0; matIdx < materials.length; matIdx++) {
1726
- const mat = materials[matIdx];
1727
- if (i >= indexOffset && i < indexOffset + mat.vertexCount) {
1728
- triangleMaterialIndex = matIdx;
1729
- break;
1708
+ for (let i = 0; i < indices.length; i += 3) {
1709
+ const idx0 = indices[i] * 8, idx1 = indices[i + 1] * 8, idx2 = indices[i + 2] * 8;
1710
+ const v0 = new Vec3(vertices[idx0], vertices[idx0 + 1], vertices[idx0 + 2]);
1711
+ const v1 = new Vec3(vertices[idx1], vertices[idx1 + 1], vertices[idx1 + 2]);
1712
+ const v2 = new Vec3(vertices[idx2], vertices[idx2 + 1], vertices[idx2 + 2]);
1713
+ let triangleMaterialIndex = -1;
1714
+ let indexOffset = 0;
1715
+ for (let matIdx = 0; matIdx < materials.length; matIdx++) {
1716
+ if (i >= indexOffset && i < indexOffset + materials[matIdx].vertexCount) {
1717
+ triangleMaterialIndex = matIdx;
1718
+ break;
1719
+ }
1720
+ indexOffset += materials[matIdx].vertexCount;
1730
1721
  }
1731
- indexOffset += mat.vertexCount;
1732
- }
1733
- if (triangleMaterialIndex === -1)
1734
- continue;
1735
- // Skip invisible materials
1736
- // const materialName = materials[triangleMaterialIndex].name
1737
- // if (this.hiddenMaterials.has(materialName)) continue
1738
- // Ray-triangle intersection test (Möller-Trumbore algorithm)
1739
- const edge1 = v1.subtract(v0);
1740
- const edge2 = v2.subtract(v0);
1741
- const h = rayDirection.cross(edge2);
1742
- const a = edge1.dot(h);
1743
- if (Math.abs(a) < 0.0001)
1744
- continue; // Ray is parallel to triangle
1745
- const f = 1.0 / a;
1746
- const s = rayOrigin.subtract(v0);
1747
- const u = f * s.dot(h);
1748
- if (u < 0.0 || u > 1.0)
1749
- continue;
1750
- const q = s.cross(edge1);
1751
- const v = f * rayDirection.dot(q);
1752
- if (v < 0.0 || u + v > 1.0)
1753
- continue;
1754
- // At this point we have a hit
1755
- const t = f * edge2.dot(q);
1756
- if (t > 0.0001 && t < maxDistance) {
1757
- // Backface culling: only consider front-facing triangles
1722
+ if (triangleMaterialIndex === -1)
1723
+ continue;
1724
+ const edge1 = v1.subtract(v0), edge2 = v2.subtract(v0), h = rayDirection.cross(edge2), a = edge1.dot(h);
1725
+ if (Math.abs(a) < 0.0001)
1726
+ continue;
1727
+ const f = 1 / a, s = rayOrigin.subtract(v0), u = f * s.dot(h);
1728
+ if (u < 0 || u > 1)
1729
+ continue;
1730
+ const q = s.cross(edge1), v = f * rayDirection.dot(q);
1731
+ if (v < 0 || u + v > 1)
1732
+ continue;
1733
+ const t = f * edge2.dot(q);
1734
+ if (t <= 0.0001 || t >= maxDistance)
1735
+ continue;
1758
1736
  const triangleNormal = edge1.cross(edge2).normalize();
1759
- const isFrontFace = triangleNormal.dot(rayDirection) < 0;
1760
- if (isFrontFace) {
1761
- if (!closestHit || t < closestHit.distance) {
1762
- closestHit = {
1763
- materialName: materials[triangleMaterialIndex].name,
1764
- distance: t,
1765
- };
1766
- }
1737
+ if (triangleNormal.dot(rayDirection) >= 0)
1738
+ continue;
1739
+ if (!closest || t < closest.distance) {
1740
+ closest = { modelName: inst.name, materialName: materials[triangleMaterialIndex].name, distance: t };
1767
1741
  }
1768
1742
  }
1769
- }
1770
- // Call the callback with the result
1743
+ });
1771
1744
  if (this.onRaycast) {
1772
- this.onRaycast(closestHit?.materialName || null, screenX, screenY);
1745
+ const hit = closest;
1746
+ this.onRaycast(hit?.modelName ?? "", hit?.materialName ?? null, screenX, screenY);
1773
1747
  }
1774
1748
  }
1775
- // Render strategy: 1) Opaque non-eye/hair 2) Eyes (stencil=1) 3) Hair (depth pre-pass + split by stencil) 4) Transparent
1776
1749
  render() {
1777
- if (this.multisampleTexture && this.camera && this.device) {
1778
- const currentTime = performance.now();
1779
- const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016;
1780
- this.lastFrameTime = currentTime;
1781
- this.updateCameraUniforms();
1782
- this.updateRenderTarget();
1783
- // Update model (handles tweens, animation, physics, IK, and skin matrices)
1784
- if (this.currentModel) {
1785
- const verticesChanged = this.currentModel.update(deltaTime);
1786
- if (verticesChanged) {
1787
- this.vertexBufferNeedsUpdate = true;
1788
- }
1789
- }
1790
- // Update vertex buffer if morphs changed
1791
- if (this.vertexBufferNeedsUpdate) {
1792
- this.updateVertexBuffer();
1793
- this.vertexBufferNeedsUpdate = false;
1794
- }
1795
- // Update skin matrices buffer
1750
+ if (!this.multisampleTexture || !this.camera || !this.device)
1751
+ return;
1752
+ const currentTime = performance.now();
1753
+ const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016;
1754
+ this.lastFrameTime = currentTime;
1755
+ this.updateCameraUniforms();
1756
+ this.updateRenderTarget();
1757
+ const hasModels = this.modelInstances.size > 0;
1758
+ if (hasModels) {
1759
+ this.updateInstances(deltaTime);
1796
1760
  this.updateSkinMatrices();
1797
- // Use single encoder for render
1798
- const encoder = this.device.createCommandEncoder();
1799
- const pass = encoder.beginRenderPass(this.renderPassDescriptor);
1800
- if (this.currentModel) {
1801
- pass.setVertexBuffer(0, this.vertexBuffer);
1802
- pass.setVertexBuffer(1, this.jointsBuffer);
1803
- pass.setVertexBuffer(2, this.weightsBuffer);
1804
- pass.setIndexBuffer(this.indexBuffer, "uint32");
1805
- // Pass 1: Opaque
1806
- pass.setPipeline(this.modelPipeline);
1807
- for (const draw of this.drawCalls) {
1808
- if (draw.type === "opaque" && this.shouldRenderDrawCall(draw)) {
1809
- pass.setBindGroup(0, draw.bindGroup);
1810
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1811
- }
1761
+ }
1762
+ if (this.groundMode === "shadow")
1763
+ this.updateShadowLightVP();
1764
+ const encoder = this.device.createCommandEncoder();
1765
+ if (hasModels && this.groundMode === "shadow" && this.shadowMapDepthView) {
1766
+ const sp = encoder.beginRenderPass({
1767
+ colorAttachments: [],
1768
+ depthStencilAttachment: {
1769
+ view: this.shadowMapDepthView,
1770
+ depthClearValue: 1.0,
1771
+ depthLoadOp: "clear",
1772
+ depthStoreOp: "store",
1773
+ },
1774
+ });
1775
+ sp.setPipeline(this.shadowDepthPipeline);
1776
+ this.forEachInstance((inst) => this.drawInstanceShadow(sp, inst));
1777
+ sp.end();
1778
+ }
1779
+ const pass = encoder.beginRenderPass(this.renderPassDescriptor);
1780
+ if (hasModels)
1781
+ this.forEachInstance((inst) => this.renderOneModel(pass, inst, false));
1782
+ if (this.groundHasReflections)
1783
+ this.renderGround(pass);
1784
+ pass.end();
1785
+ this.device.queue.submit([encoder.finish()]);
1786
+ this.updateStats(performance.now() - currentTime);
1787
+ }
1788
+ drawInstanceShadow(sp, inst) {
1789
+ sp.setBindGroup(0, inst.shadowBindGroup);
1790
+ sp.setVertexBuffer(0, inst.vertexBuffer);
1791
+ sp.setVertexBuffer(1, inst.jointsBuffer);
1792
+ sp.setVertexBuffer(2, inst.weightsBuffer);
1793
+ sp.setIndexBuffer(inst.indexBuffer, "uint32");
1794
+ for (const draw of inst.shadowDrawCalls) {
1795
+ if (this.shouldRenderDrawCall(inst, draw))
1796
+ sp.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1797
+ }
1798
+ }
1799
+ renderOneModel(pass, inst, useReflection, mirrorMatrix) {
1800
+ pass.setVertexBuffer(0, inst.vertexBuffer);
1801
+ pass.setVertexBuffer(1, inst.jointsBuffer);
1802
+ pass.setVertexBuffer(2, inst.weightsBuffer);
1803
+ pass.setIndexBuffer(inst.indexBuffer, "uint32");
1804
+ if (useReflection && mirrorMatrix) {
1805
+ this.writeMirrorTransformedSkinMatrices(inst, mirrorMatrix);
1806
+ pass.setPipeline(this.reflectionPipeline);
1807
+ for (const draw of inst.drawCalls) {
1808
+ if (draw.type === "opaque" && this.shouldRenderDrawCall(inst, draw)) {
1809
+ pass.setBindGroup(0, draw.bindGroup);
1810
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1812
1811
  }
1813
- // Pass 2: Eyes (writes stencil value for hair to test against)
1814
- this.renderEyes(pass);
1815
- this.drawOutlines(pass, false);
1816
- // Pass 3: Hair rendering (depth pre-pass + shading + outlines)
1817
- this.renderHair(pass);
1818
- // Pass 5: Transparent
1819
- pass.setPipeline(this.modelPipeline);
1820
- for (const draw of this.drawCalls) {
1821
- if (draw.type === "transparent" && this.shouldRenderDrawCall(draw)) {
1822
- pass.setBindGroup(0, draw.bindGroup);
1823
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1824
- }
1812
+ }
1813
+ this.renderEyes(pass, inst, true);
1814
+ this.renderHair(pass, inst, true);
1815
+ for (const draw of inst.drawCalls) {
1816
+ if (draw.type === "transparent" && this.shouldRenderDrawCall(inst, draw)) {
1817
+ pass.setBindGroup(0, draw.bindGroup);
1818
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1825
1819
  }
1826
- this.drawOutlines(pass, true);
1827
1820
  }
1828
- // Pass 4: Ground (with reflections)
1829
- if (this.groundHasReflections) {
1830
- this.renderGround(pass);
1821
+ this.drawOutlines(pass, inst, true, true);
1822
+ return;
1823
+ }
1824
+ pass.setPipeline(this.modelPipeline);
1825
+ for (const draw of inst.drawCalls) {
1826
+ if (draw.type === "opaque" && this.shouldRenderDrawCall(inst, draw)) {
1827
+ pass.setBindGroup(0, draw.bindGroup);
1828
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1829
+ }
1830
+ }
1831
+ this.renderEyes(pass, inst, false);
1832
+ this.drawOutlines(pass, inst, false);
1833
+ this.renderHair(pass, inst, false);
1834
+ pass.setPipeline(this.modelPipeline);
1835
+ for (const draw of inst.drawCalls) {
1836
+ if (draw.type === "transparent" && this.shouldRenderDrawCall(inst, draw)) {
1837
+ pass.setBindGroup(0, draw.bindGroup);
1838
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1831
1839
  }
1832
- pass.end();
1833
- this.device.queue.submit([encoder.finish()]);
1834
- this.updateStats(performance.now() - currentTime);
1835
1840
  }
1841
+ this.drawOutlines(pass, inst, true);
1836
1842
  }
1837
1843
  updateCameraUniforms() {
1838
1844
  const viewMatrix = this.camera.getViewMatrix();
@@ -1856,22 +1862,18 @@ export class Engine {
1856
1862
  }
1857
1863
  }
1858
1864
  updateSkinMatrices() {
1859
- if (!this.currentModel || !this.skinMatrixBuffer)
1860
- return;
1861
- const skinMatrices = this.currentModel.getSkinMatrices();
1862
- this.device.queue.writeBuffer(this.skinMatrixBuffer, 0, skinMatrices.buffer, skinMatrices.byteOffset, skinMatrices.byteLength);
1863
- // Increment version to invalidate cached skinned vertices
1864
- this.skinMatricesVersion++;
1865
+ this.forEachInstance((inst) => {
1866
+ const skinMatrices = inst.model.getSkinMatrices();
1867
+ this.device.queue.writeBuffer(inst.skinMatrixBuffer, 0, skinMatrices.buffer, skinMatrices.byteOffset, skinMatrices.byteLength);
1868
+ });
1865
1869
  }
1866
- drawOutlines(pass, transparent, useReflectionPipeline = false) {
1867
- if (useReflectionPipeline) {
1868
- // Skip outlines for reflections - not critical for the effect
1870
+ drawOutlines(pass, inst, transparent, useReflectionPipeline = false) {
1871
+ if (useReflectionPipeline)
1869
1872
  return;
1870
- }
1871
1873
  pass.setPipeline(this.outlinePipeline);
1872
1874
  const outlineType = transparent ? "transparent-outline" : "opaque-outline";
1873
- for (const draw of this.drawCalls) {
1874
- if (draw.type === outlineType && this.shouldRenderDrawCall(draw)) {
1875
+ for (const draw of inst.drawCalls) {
1876
+ if (draw.type === outlineType && this.shouldRenderDrawCall(inst, draw)) {
1875
1877
  pass.setBindGroup(0, draw.bindGroup);
1876
1878
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
1877
1879
  }
@@ -1921,22 +1923,19 @@ export class Engine {
1921
1923
  1,
1922
1924
  ]));
1923
1925
  }
1924
- writeMirrorTransformedSkinMatrices(mirrorMatrix) {
1925
- if (!this.currentModel || !this.skinMatrixBuffer)
1926
- return;
1927
- const originalMatrices = this.currentModel.getSkinMatrices();
1926
+ writeMirrorTransformedSkinMatrices(inst, mirrorMatrix) {
1927
+ const originalMatrices = inst.model.getSkinMatrices();
1928
1928
  const transformedMatrices = new Float32Array(originalMatrices.length);
1929
1929
  for (let i = 0; i < originalMatrices.length; i += 16) {
1930
1930
  const boneMatrixValues = new Float32Array(16);
1931
- for (let j = 0; j < 16; j++) {
1931
+ for (let j = 0; j < 16; j++)
1932
1932
  boneMatrixValues[j] = originalMatrices[i + j];
1933
- }
1934
1933
  const boneMatrix = new Mat4(boneMatrixValues);
1935
1934
  const transformed = mirrorMatrix.multiply(boneMatrix);
1936
- for (let j = 0; j < 16; j++) {
1935
+ for (let j = 0; j < 16; j++)
1937
1936
  transformedMatrices[i + j] = transformed.values[j];
1938
- }
1939
1937
  }
1940
- this.device.queue.writeBuffer(this.skinMatrixBuffer, 0, transformedMatrices);
1938
+ this.device.queue.writeBuffer(inst.skinMatrixBuffer, 0, transformedMatrices);
1941
1939
  }
1942
1940
  }
1941
+ Engine.instance = null;