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/src/engine.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import { Camera } from "./camera"
2
2
  import { Mat4, Quat, Vec3 } from "./math"
3
3
  import { Model } from "./model"
4
- import type { AnimationData } from "./animation"
5
- import { PmxLoader } from "./pmx-loader"
4
+ import { Physics } from "./physics"
6
5
 
7
- export type RaycastCallback = (material: string | null, screenX: number, screenY: number) => void
6
+ export type RaycastCallback = (modelName: string, material: string | null, screenX: number, screenY: number) => void
8
7
 
9
8
  export type EngineOptions = {
10
9
  ambientColor?: Vec3
@@ -15,8 +14,7 @@ export type EngineOptions = {
15
14
  cameraTarget?: Vec3
16
15
  cameraFov?: number
17
16
  onRaycast?: RaycastCallback
18
- disableIK?: boolean
19
- disablePhysics?: boolean
17
+ multisampleCount?: 1 | 4
20
18
  }
21
19
 
22
20
  export type RequiredEngineOptions = Required<Omit<EngineOptions, "onRaycast">> & Pick<EngineOptions, "onRaycast">
@@ -30,8 +28,7 @@ export const DEFAULT_ENGINE_OPTIONS: RequiredEngineOptions = {
30
28
  cameraTarget: new Vec3(0, 12.5, 0),
31
29
  cameraFov: Math.PI / 4,
32
30
  onRaycast: undefined,
33
- disableIK: false,
34
- disablePhysics: false,
31
+ multisampleCount: 4,
35
32
  }
36
33
 
37
34
  export interface EngineStats {
@@ -59,7 +56,33 @@ interface DrawCall {
59
56
  materialName: string
60
57
  }
61
58
 
59
+ interface ModelInstance {
60
+ name: string
61
+ model: Model
62
+ basePath: string
63
+ vertexBuffer: GPUBuffer
64
+ indexBuffer: GPUBuffer
65
+ jointsBuffer: GPUBuffer
66
+ weightsBuffer: GPUBuffer
67
+ skinMatrixBuffer: GPUBuffer
68
+ drawCalls: DrawCall[]
69
+ shadowDrawCalls: DrawCall[]
70
+ shadowBindGroup: GPUBindGroup
71
+ hiddenMaterials: Set<string>
72
+ physics: Physics | null
73
+ vertexBufferNeedsUpdate: boolean
74
+ }
75
+
62
76
  export class Engine {
77
+ private static instance: Engine | null = null
78
+
79
+ public static getInstance(): Engine {
80
+ if (!Engine.instance) {
81
+ throw new Error("Engine not ready: create Engine, await init(), then load models via Model.loadPmx().")
82
+ }
83
+ return Engine.instance
84
+ }
85
+
63
86
  private canvas: HTMLCanvasElement
64
87
  private device!: GPUDevice
65
88
  private context!: GPUCanvasContext
@@ -73,8 +96,6 @@ export class Engine {
73
96
  private lightUniformBuffer!: GPUBuffer
74
97
  private lightData = new Float32Array(64)
75
98
  private lightCount = 0
76
- private vertexBuffer!: GPUBuffer
77
- private indexBuffer?: GPUBuffer
78
99
  private resizeObserver: ResizeObserver | null = null
79
100
  private depthTexture!: GPUTexture
80
101
  // Material rendering pipelines
@@ -92,12 +113,8 @@ export class Engine {
92
113
  private hairOutlinePipeline!: GPURenderPipeline
93
114
  private mainBindGroupLayout!: GPUBindGroupLayout
94
115
  private outlineBindGroupLayout!: GPUBindGroupLayout
95
- private jointsBuffer!: GPUBuffer
96
- private weightsBuffer!: GPUBuffer
97
- private skinMatrixBuffer?: GPUBuffer
98
- private inverseBindMatrixBuffer?: GPUBuffer
99
116
  private multisampleTexture!: GPUTexture
100
- private readonly sampleCount = 4
117
+ private sampleCount: 1 | 4 = 4
101
118
  private renderPassDescriptor!: GPURenderPassDescriptor
102
119
  // Constants
103
120
  private readonly STENCIL_EYE_VALUE = 1
@@ -118,29 +135,32 @@ export class Engine {
118
135
  private groundReflectionBindGroup?: GPUBindGroup
119
136
  private groundMaterialUniformBuffer?: GPUBuffer
120
137
  private groundHasReflections = false
138
+ private groundMode: "reflection" | "shadow" = "reflection"
139
+ private shadowMapTexture?: GPUTexture
140
+ private shadowMapDepthView?: GPUTextureView
141
+ private shadowDepthPipeline!: GPURenderPipeline
142
+ private shadowLightVPBuffer!: GPUBuffer
143
+ private shadowLightVPMatrix = new Float32Array(16)
144
+ private groundShadowPipeline!: GPURenderPipeline
145
+ private groundShadowBindGroupLayout!: GPUBindGroupLayout
146
+ private groundShadowBindGroup?: GPUBindGroup
147
+ private shadowComparisonSampler!: GPUSampler
148
+ private groundShadowMaterialBuffer?: GPUBuffer
149
+ private groundDrawCall: DrawCall | null = null
150
+ private shadowVPLightX = Number.NaN
151
+ private shadowVPLightY = Number.NaN
152
+ private shadowVPLightZ = Number.NaN
121
153
 
122
- // Raycasting
123
154
  private onRaycast?: RaycastCallback
124
- private cachedSkinnedVertices?: Float32Array
125
- private cachedSkinMatricesVersion = -1
126
- private skinMatricesVersion = 0
127
155
  // Double-tap detection
128
156
  private lastTouchTime = 0
129
157
  private readonly DOUBLE_TAP_DELAY = 300 // ms
130
158
 
131
- // IK and Physics flags
132
- private _disableIK = false
133
- private _disablePhysics = false
134
-
135
- private currentModel: Model | null = null
136
- private modelDir: string = ""
159
+ private modelInstances = new Map<string, ModelInstance>()
137
160
  private materialSampler!: GPUSampler
138
161
  private textureCache = new Map<string, GPUTexture>()
139
- private vertexBufferNeedsUpdate = false
140
- // Unified draw call list
141
- private drawCalls: DrawCall[] = []
142
- // Material visibility tracking
143
- private hiddenMaterials = new Set<string>()
162
+ /** Reusable buffer for raycast skinning to avoid per-instance allocations (Three.js/Babylon.js style). */
163
+ private raycastVertexBuffer: Float32Array | null = null
144
164
 
145
165
  private lastFpsUpdate = performance.now()
146
166
  private framesSinceLastUpdate = 0
@@ -156,6 +176,7 @@ export class Engine {
156
176
 
157
177
  constructor(canvas: HTMLCanvasElement, options?: EngineOptions) {
158
178
  this.canvas = canvas
179
+ this.sampleCount = options?.multisampleCount ?? DEFAULT_ENGINE_OPTIONS.multisampleCount
159
180
  if (options) {
160
181
  this.ambientColor = options.ambientColor ?? DEFAULT_ENGINE_OPTIONS.ambientColor!
161
182
  this.directionalLightIntensity =
@@ -166,8 +187,6 @@ export class Engine {
166
187
  this.cameraTarget = options.cameraTarget ?? DEFAULT_ENGINE_OPTIONS.cameraTarget
167
188
  this.cameraFov = options.cameraFov ?? DEFAULT_ENGINE_OPTIONS.cameraFov
168
189
  this.onRaycast = options.onRaycast
169
- this._disableIK = options.disableIK ?? DEFAULT_ENGINE_OPTIONS.disableIK
170
- this._disablePhysics = options.disablePhysics ?? DEFAULT_ENGINE_OPTIONS.disablePhysics
171
190
  }
172
191
  }
173
192
 
@@ -198,6 +217,7 @@ export class Engine {
198
217
  this.setupLighting()
199
218
  this.createPipelines()
200
219
  this.setupResize()
220
+ Engine.instance = this
201
221
  }
202
222
 
203
223
  private createRenderPipeline(config: {
@@ -477,121 +497,61 @@ export class Engine {
477
497
  },
478
498
  })
479
499
 
480
- // Create ground/reflection pipeline with reflection texture support
481
500
  this.groundBindGroupLayout = this.device.createBindGroupLayout({
482
501
  label: "ground bind group layout",
483
502
  entries: [
484
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
485
- { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
486
- { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // reflectionTexture
487
- { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // reflectionSampler
488
- { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // groundMaterial
503
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
504
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
505
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} },
506
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
507
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
489
508
  ],
490
509
  })
491
-
492
510
  const groundPipelineLayout = this.device.createPipelineLayout({
493
511
  label: "ground pipeline layout",
494
512
  bindGroupLayouts: [this.groundBindGroupLayout],
495
513
  })
496
-
497
514
  const groundShaderModule = this.device.createShaderModule({
498
515
  label: "ground shaders",
499
516
  code: /* wgsl */ `
500
- struct CameraUniforms {
501
- view: mat4x4f,
502
- projection: mat4x4f,
503
- viewPos: vec3f,
504
- _padding: f32,
505
- };
506
-
507
- struct LightUniforms {
508
- ambientColor: vec4f,
509
- lights: array<Light, 4>,
510
- };
511
-
512
- struct Light {
513
- direction: vec4f,
514
- color: vec4f,
515
- };
516
-
517
- struct GroundMaterialUniforms {
518
- diffuseColor: vec3f,
519
- reflectionLevel: f32,
520
- fadeStart: f32,
521
- fadeEnd: f32,
522
- _padding1: f32,
523
- _padding2: f32,
524
- };
525
-
517
+ struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
518
+ struct Light { direction: vec4f, color: vec4f, };
519
+ struct LightUniforms { ambientColor: vec4f, lights: array<Light, 4>, };
520
+ struct GroundMaterialUniforms { diffuseColor: vec3f, reflectionLevel: f32, fadeStart: f32, fadeEnd: f32, _a: f32, _b: f32, };
526
521
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
527
522
  @group(0) @binding(1) var<uniform> light: LightUniforms;
528
523
  @group(0) @binding(2) var reflectionTexture: texture_2d<f32>;
529
524
  @group(0) @binding(3) var reflectionSampler: sampler;
530
525
  @group(0) @binding(4) var<uniform> material: GroundMaterialUniforms;
531
-
532
526
  struct VertexOutput {
533
- @builtin(position) position: vec4f,
534
- @location(0) normal: vec3f,
535
- @location(1) uv: vec2f,
536
- @location(2) worldPos: vec3f,
527
+ @builtin(position) position: vec4f, @location(0) normal: vec3f, @location(1) uv: vec2f, @location(2) worldPos: vec3f,
537
528
  };
538
-
539
- @vertex fn vs(
540
- @location(0) position: vec3f,
541
- @location(1) normal: vec3f,
542
- @location(2) uv: vec2f,
543
- ) -> VertexOutput {
544
- var output: VertexOutput;
545
- let worldPos = position;
546
- output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
547
- output.normal = normal;
548
- output.uv = uv;
549
- output.worldPos = worldPos;
550
- return output;
529
+ @vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VertexOutput {
530
+ var o: VertexOutput;
531
+ o.worldPos = position;
532
+ o.position = camera.projection * camera.view * vec4f(position, 1.0);
533
+ o.normal = normal; o.uv = uv; return o;
551
534
  }
552
-
553
535
  @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
554
536
  let n = normalize(input.normal);
555
-
537
+ let centerDist = length(input.worldPos.xz);
538
+ let t = clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0);
539
+ let edgeFade = 1.0 - smoothstep(0.0, 1.0, t);
556
540
  let clipPos = camera.projection * camera.view * vec4f(input.worldPos, 1.0);
557
541
  let ndcPos = clipPos.xyz / clipPos.w;
558
- var reflectionUV = vec2f(ndcPos.x * 0.5 + 0.5, 0.5 - ndcPos.y * 0.5);
559
-
542
+ let reflectionUV = vec2f(ndcPos.x * 0.5 + 0.5, 0.5 - ndcPos.y * 0.5);
560
543
  let sampledReflectionColor = textureSample(reflectionTexture, reflectionSampler, reflectionUV).rgb;
561
- let isValidReflection = clipPos.w > 0.0 &&
562
- all(reflectionUV >= vec2f(0.0)) && all(reflectionUV <= vec2f(1.0));
563
- var reflectionColor = select(vec3f(1.0, 1.0, 1.0), sampledReflectionColor, isValidReflection);
564
-
565
- let distanceFromCamera = length(input.worldPos - camera.viewPos);
566
- let fadeFactor = clamp((distanceFromCamera - 15.0) / 20.0, 0.0, 1.0);
567
- reflectionColor *= (1.0 - fadeFactor * 0.3);
568
-
569
- let diffuseColor = material.diffuseColor;
570
- var finalColor = mix(diffuseColor, reflectionColor, material.reflectionLevel);
571
-
572
- // Ground edge fade effect - smooth fade out at edges based on distance from center
573
- let centerDist = length(input.worldPos.xz); // Distance from ground center in XZ plane
574
-
575
- // Smoothstep for much smoother gradient transition
576
- let t = clamp((centerDist - material.fadeStart) / (material.fadeEnd - material.fadeStart), 0.0, 1.0);
577
- let edgeFade = 1.0 - smoothstep(0.0, 1.0, t);
578
- finalColor *= edgeFade;
579
-
580
- // Single directional light
544
+ let isValidReflection = clipPos.w > 0.0 && all(reflectionUV >= vec2f(0.0)) && all(reflectionUV <= vec2f(1.0));
545
+ let reflectionColor = select(vec3f(1.0, 1.0, 1.0), sampledReflectionColor, isValidReflection);
546
+ let fadeFactor = clamp((length(input.worldPos - camera.viewPos) - 15.0) / 20.0, 0.0, 1.0);
547
+ let refl = reflectionColor * (1.0 - fadeFactor * 0.3);
548
+ var finalColor = mix(material.diffuseColor, refl, material.reflectionLevel) * edgeFade;
581
549
  let l = -light.lights[0].direction.xyz;
582
- let nDotL = max(dot(n, l), 0.0);
583
- let intensity = light.lights[0].color.w;
584
- let radiance = light.lights[0].color.xyz * intensity;
585
- let lightAccum = light.ambientColor.xyz + radiance * nDotL;
586
-
587
- // Apply lighting to the blended color
588
- let litColor = finalColor * lightAccum;
589
-
590
- return vec4f(litColor, edgeFade);
550
+ let lightAccum = light.ambientColor.xyz + light.lights[0].color.xyz * light.lights[0].color.w * max(dot(n, l), 0.0);
551
+ return vec4f(finalColor * lightAccum, edgeFade);
591
552
  }
592
553
  `,
593
554
  })
594
-
595
555
  this.groundPipeline = this.createRenderPipeline({
596
556
  label: "ground pipeline",
597
557
  layout: groundPipelineLayout,
@@ -599,12 +559,124 @@ export class Engine {
599
559
  vertexBuffers: fullVertexBuffers,
600
560
  fragmentTarget: standardBlend,
601
561
  cullMode: "back",
562
+ depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
563
+ })
564
+
565
+ this.shadowLightVPBuffer = this.device.createBuffer({
566
+ size: 64,
567
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
568
+ })
569
+ const shadowBindGroupLayout = this.device.createBindGroupLayout({
570
+ label: "shadow depth bind layout",
571
+ entries: [
572
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
573
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
574
+ ],
575
+ })
576
+ const shadowShader = this.device.createShaderModule({
577
+ label: "shadow depth",
578
+ code: /* wgsl */ `
579
+ struct LightVP { viewProj: mat4x4f, };
580
+ @group(0) @binding(0) var<uniform> lp: LightVP;
581
+ @group(0) @binding(1) var<storage, read> skinMats: array<mat4x4f>;
582
+ @vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f,
583
+ @location(3) joints0: vec4<u32>, @location(4) weights0: vec4<f32>) -> @builtin(position) vec4f {
584
+ let pos4 = vec4f(position, 1.0);
585
+ let ws = weights0.x + weights0.y + weights0.z + weights0.w;
586
+ let inv = select(1.0, 1.0 / ws, ws > 0.0001);
587
+ let nw = select(vec4f(1.0,0.0,0.0,0.0), weights0 * inv, ws > 0.0001);
588
+ var sp = vec4f(0.0);
589
+ for (var i = 0u; i < 4u; i++) { sp += (skinMats[joints0[i]] * pos4) * nw[i]; }
590
+ return lp.viewProj * vec4f(sp.xyz, 1.0);
591
+ }
592
+ `,
593
+ })
594
+ this.shadowDepthPipeline = this.device.createRenderPipeline({
595
+ label: "shadow depth pipeline",
596
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [shadowBindGroupLayout] }),
597
+ vertex: { module: shadowShader, entryPoint: "vs", buffers: fullVertexBuffers as GPUVertexBufferLayout[] },
598
+ primitive: { cullMode: "none" },
602
599
  depthStencil: {
603
- format: "depth24plus-stencil8",
600
+ format: "depth32float",
604
601
  depthWriteEnabled: true,
605
602
  depthCompare: "less-equal",
603
+ depthBias: 2,
604
+ depthBiasSlopeScale: 1.5,
605
+ depthBiasClamp: 0,
606
606
  },
607
607
  })
608
+ this.shadowComparisonSampler = this.device.createSampler({
609
+ compare: "less",
610
+ magFilter: "linear",
611
+ minFilter: "linear",
612
+ })
613
+ this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
614
+ label: "ground shadow layout",
615
+ entries: [
616
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
617
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
618
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
619
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
620
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
621
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
622
+ ],
623
+ })
624
+ const groundShadowShader = this.device.createShaderModule({
625
+ label: "ground shadow",
626
+ code: /* wgsl */ `
627
+ struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
628
+ struct Light { direction: vec4f, color: vec4f, };
629
+ struct LightUniforms { ambientColor: vec4f, lights: array<Light, 4>, };
630
+ struct GroundShadowMat { diffuseColor: vec3f, fadeStart: f32, fadeEnd: f32, shadowStrength: f32, pcfTexel: f32, _y: f32, };
631
+ struct LightVP { viewProj: mat4x4f, };
632
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
633
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
634
+ @group(0) @binding(2) var shadowMap: texture_depth_2d;
635
+ @group(0) @binding(3) var shadowSampler: sampler_comparison;
636
+ @group(0) @binding(4) var<uniform> material: GroundShadowMat;
637
+ @group(0) @binding(5) var<uniform> lightVP: LightVP;
638
+ struct VO { @builtin(position) position: vec4f, @location(0) worldPos: vec3f, @location(1) normal: vec3f, };
639
+ @vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VO {
640
+ var o: VO; o.worldPos = position; o.normal = normal;
641
+ o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;
642
+ }
643
+ @fragment fn fs(i: VO) -> @location(0) vec4f {
644
+ let n = normalize(i.normal);
645
+ let centerDist = length(i.worldPos.xz);
646
+ let edgeFade = 1.0 - smoothstep(0.0, 1.0, clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0));
647
+ let lclip = lightVP.viewProj * vec4f(i.worldPos, 1.0);
648
+ let ndc = lclip.xyz / max(lclip.w, 1e-6);
649
+ let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
650
+ let suv_c = clamp(suv, vec2f(0.02), vec2f(0.98));
651
+ let st = material.pcfTexel;
652
+ let compareZ = ndc.z - 0.0035;
653
+ var vis = 0.0;
654
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(-st, -st), compareZ);
655
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(0.0, -st), compareZ);
656
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(st, -st), compareZ);
657
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(-st, 0.0), compareZ);
658
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c, compareZ);
659
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(st, 0.0), compareZ);
660
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(-st, st), compareZ);
661
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(0.0, st), compareZ);
662
+ vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(st, st), compareZ);
663
+ vis *= 0.1111111;
664
+ 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);
665
+ let dark = (1.0 - vis) * material.shadowStrength;
666
+ let lit = material.diffuseColor * sun * (1.0 - dark * 0.65);
667
+ return vec4f(lit * edgeFade, edgeFade);
668
+ }
669
+ `,
670
+ })
671
+ this.groundShadowPipeline = this.createRenderPipeline({
672
+ label: "ground shadow pipeline",
673
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.groundShadowBindGroupLayout] }),
674
+ shaderModule: groundShadowShader,
675
+ vertexBuffers: fullVertexBuffers,
676
+ fragmentTarget: standardBlend,
677
+ cullMode: "back",
678
+ depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
679
+ })
608
680
 
609
681
  // Create reflection pipeline (multisampled version for higher quality)
610
682
  this.reflectionPipeline = this.createRenderPipeline({
@@ -1029,6 +1101,9 @@ export class Engine {
1029
1101
  reflectionTextureSize?: number
1030
1102
  fadeStart?: number
1031
1103
  fadeEnd?: number
1104
+ mode?: "reflection" | "shadow"
1105
+ shadowMapSize?: number
1106
+ shadowStrength?: number
1032
1107
  }): void {
1033
1108
  const opts = {
1034
1109
  width: 100,
@@ -1038,62 +1113,33 @@ export class Engine {
1038
1113
  reflectionTextureSize: 1024,
1039
1114
  fadeStart: 5.0,
1040
1115
  fadeEnd: 60.0,
1116
+ mode: "reflection" as const,
1117
+ shadowMapSize: 4096,
1118
+ shadowStrength: 1.0,
1041
1119
  ...options,
1042
1120
  }
1043
-
1044
- // Create ground geometry
1121
+ this.groundMode = opts.mode
1045
1122
  this.createGroundGeometry(opts.width, opts.height)
1046
-
1047
- this.createGroundMaterialBuffer(opts.diffuseColor, opts.reflectionLevel, opts.fadeStart, opts.fadeEnd)
1048
- this.createReflectionTexture(opts.reflectionTextureSize)
1123
+ if (opts.mode === "reflection") {
1124
+ this.createGroundMaterialBuffer(opts.diffuseColor, opts.reflectionLevel, opts.fadeStart, opts.fadeEnd)
1125
+ this.createReflectionTexture(opts.reflectionTextureSize)
1126
+ } else {
1127
+ this.createShadowGroundResources(opts.shadowMapSize, opts.diffuseColor, opts.fadeStart, opts.fadeEnd, opts.shadowStrength)
1128
+ }
1049
1129
  this.groundHasReflections = true
1050
-
1051
- this.drawCalls.push({
1130
+ this.groundDrawCall = {
1052
1131
  type: "ground",
1053
- count: 6, // 2 triangles, 3 indices each
1132
+ count: 6,
1054
1133
  firstIndex: 0,
1055
- bindGroup: this.groundReflectionBindGroup!,
1134
+ bindGroup: (opts.mode === "reflection" ? this.groundReflectionBindGroup : this.groundShadowBindGroup)!,
1056
1135
  materialName: "Ground",
1057
- })
1136
+ }
1058
1137
  }
1059
1138
 
1060
1139
  private updateLightBuffer() {
1061
1140
  this.device.queue.writeBuffer(this.lightUniformBuffer, 0, this.lightData)
1062
1141
  }
1063
1142
 
1064
- public async loadAnimation(url: string) {
1065
- if (!this.currentModel) return
1066
- await this.currentModel.loadVmd(url)
1067
- }
1068
-
1069
- public loadAnimationData(data: AnimationData) {
1070
- this.currentModel?.loadAnimationData(data)
1071
- }
1072
-
1073
- public getAnimationData(): AnimationData | null {
1074
- return this.currentModel?.getAnimationData() ?? null
1075
- }
1076
-
1077
- public playAnimation() {
1078
- this.currentModel?.playAnimation()
1079
- }
1080
-
1081
- public stopAnimation() {
1082
- this.currentModel?.stopAnimation()
1083
- }
1084
-
1085
- public pauseAnimation() {
1086
- this.currentModel?.pauseAnimation()
1087
- }
1088
-
1089
- public seekAnimation(time: number) {
1090
- this.currentModel?.seekAnimation(time)
1091
- }
1092
-
1093
- public getAnimationProgress() {
1094
- return this.currentModel?.getAnimationProgress() ?? { current: 0, duration: 0, percentage: 0 }
1095
- }
1096
-
1097
1143
  public getStats(): EngineStats {
1098
1144
  return { ...this.stats }
1099
1145
  }
@@ -1124,7 +1170,8 @@ export class Engine {
1124
1170
 
1125
1171
  public dispose() {
1126
1172
  this.stopRenderLoop()
1127
- this.stopAnimation()
1173
+ this.forEachInstance((inst) => inst.model.stopAnimation())
1174
+ if (Engine.instance === this) Engine.instance = null
1128
1175
  if (this.camera) this.camera.detachControl()
1129
1176
 
1130
1177
  // Remove raycasting event listeners
@@ -1139,189 +1186,201 @@ export class Engine {
1139
1186
  }
1140
1187
  }
1141
1188
 
1142
- // Step 6: Load PMX model file
1143
- public async loadModel(path: string) {
1144
- const pathParts = path.split("/")
1189
+ public async addModel(model: Model, pmxPath: string, name?: string): Promise<string> {
1190
+ const requested = name ?? model.name
1191
+ let key = requested
1192
+ let n = 1
1193
+ while (this.modelInstances.has(key)) {
1194
+ key = `${requested}_${n++}`
1195
+ }
1196
+ const pathParts = pmxPath.split("/")
1145
1197
  pathParts.pop()
1146
- const dir = pathParts.join("/") + "/"
1147
- this.modelDir = dir
1148
-
1149
- const model = await PmxLoader.load(path)
1150
-
1151
- // Clear cached skinned vertices when loading a new model
1152
- this.cachedSkinnedVertices = undefined
1153
- this.cachedSkinMatricesVersion = -1
1154
-
1155
- await this.setupModelBuffers(model)
1198
+ const basePath = pathParts.join("/") + "/"
1199
+ await this.setupModelInstance(key, model, basePath)
1200
+ return key
1156
1201
  }
1157
1202
 
1158
- public rotateBones(boneRotations: Record<string, Quat>, durationMs?: number) {
1159
- this.currentModel?.rotateBones(boneRotations, durationMs)
1203
+ public async registerModel(model: Model, pmxPath: string): Promise<string> {
1204
+ return this.addModel(model, pmxPath)
1160
1205
  }
1161
1206
 
1162
- // moveBones now takes relative translations (VMD-style) by default
1163
- public moveBones(boneTranslations: Record<string, Vec3>, durationMs?: number) {
1164
- this.currentModel?.moveBones(boneTranslations, durationMs)
1207
+ public removeModel(name: string): void {
1208
+ this.modelInstances.delete(name)
1165
1209
  }
1166
1210
 
1167
-
1168
- public resetAllBones() {
1169
- this.currentModel?.resetAllBones()
1211
+ public getModelNames(): string[] {
1212
+ return Array.from(this.modelInstances.keys())
1170
1213
  }
1171
1214
 
1172
- public resetAllMorphs(): void {
1173
- this.currentModel?.resetAllMorphs()
1215
+ public getModel(name: string): Model | null {
1216
+ return this.modelInstances.get(name)?.model ?? null
1174
1217
  }
1175
1218
 
1176
- public setMorphWeight(name: string, weight: number, durationMs?: number): void {
1177
- if (!this.currentModel) return
1178
- this.currentModel.setMorphWeight(name, weight, durationMs)
1179
- if (!durationMs || durationMs === 0) {
1180
- this.vertexBufferNeedsUpdate = true
1219
+ public markVertexBufferDirty(modelNameOrModel?: string | Model): void {
1220
+ if (modelNameOrModel === undefined) return
1221
+ if (typeof modelNameOrModel === "string") {
1222
+ const inst = this.modelInstances.get(modelNameOrModel)
1223
+ if (inst) inst.vertexBufferNeedsUpdate = true
1224
+ return
1181
1225
  }
1182
- }
1183
-
1184
- public setMaterialVisible(name: string, visible: boolean): void {
1185
- if (visible) {
1186
- this.hiddenMaterials.delete(name)
1187
- } else {
1188
- this.hiddenMaterials.add(name)
1226
+ for (const inst of this.modelInstances.values()) {
1227
+ if (inst.model === modelNameOrModel) {
1228
+ inst.vertexBufferNeedsUpdate = true
1229
+ return
1230
+ }
1189
1231
  }
1190
1232
  }
1191
1233
 
1192
- public toggleMaterialVisible(name: string): void {
1193
- if (this.hiddenMaterials.has(name)) {
1194
- this.hiddenMaterials.delete(name)
1195
- } else {
1196
- this.hiddenMaterials.add(name)
1197
- }
1234
+ public setMaterialVisible(modelName: string, materialName: string, visible: boolean): void {
1235
+ const inst = this.modelInstances.get(modelName)
1236
+ if (!inst) return
1237
+ if (visible) inst.hiddenMaterials.delete(materialName)
1238
+ else inst.hiddenMaterials.add(materialName)
1198
1239
  }
1199
1240
 
1200
- public isMaterialVisible(name: string): boolean {
1201
- return !this.hiddenMaterials.has(name)
1241
+ public toggleMaterialVisible(modelName: string, materialName: string): void {
1242
+ const inst = this.modelInstances.get(modelName)
1243
+ if (!inst) return
1244
+ if (inst.hiddenMaterials.has(materialName)) inst.hiddenMaterials.delete(materialName)
1245
+ else inst.hiddenMaterials.add(materialName)
1202
1246
  }
1203
1247
 
1204
- public getBones(): string[] {
1205
- return this.currentModel?.getSkeleton().bones.map((bone) => bone.name) ?? []
1248
+ public isMaterialVisible(modelName: string, materialName: string): boolean {
1249
+ const inst = this.modelInstances.get(modelName)
1250
+ return inst ? !inst.hiddenMaterials.has(materialName) : false
1206
1251
  }
1207
1252
 
1208
- public getMorphs(): string[] {
1209
- return this.currentModel?.getMorphing().morphs.map((morph) => morph.name) ?? []
1253
+ public setModelIKEnabled(modelName: string, enabled: boolean): void {
1254
+ this.modelInstances.get(modelName)?.model.setIKEnabled(enabled)
1210
1255
  }
1211
1256
 
1212
- public getMaterials(): string[] {
1213
- return this.currentModel?.getMaterials().map((material) => material.name) ?? []
1257
+ public setModelPhysicsEnabled(modelName: string, enabled: boolean): void {
1258
+ this.modelInstances.get(modelName)?.model.setPhysicsEnabled(enabled)
1214
1259
  }
1215
1260
 
1216
- // IK control
1217
- public get disableIK(): boolean {
1218
- return this._disableIK
1261
+ public resetPhysics(): void {
1262
+ this.forEachInstance((inst) => {
1263
+ if (!inst.physics) return
1264
+ inst.model.computeWorldMatrices()
1265
+ inst.physics.reset(inst.model.getWorldMatrices(), inst.model.getBoneInverseBindMatrices())
1266
+ })
1219
1267
  }
1220
1268
 
1221
- public set disableIK(value: boolean) {
1222
- this._disableIK = value
1223
- this.currentModel?.setIKEnabled(!value)
1269
+ private instances(): IterableIterator<ModelInstance> {
1270
+ return this.modelInstances.values()
1224
1271
  }
1225
1272
 
1226
- // Physics control
1227
- public get disablePhysics(): boolean {
1228
- return this._disablePhysics
1273
+ private forEachInstance(fn: (inst: ModelInstance) => void): void {
1274
+ for (const inst of this.instances()) fn(inst)
1229
1275
  }
1230
1276
 
1231
- public set disablePhysics(value: boolean) {
1232
- this._disablePhysics = value
1233
- this.currentModel?.setPhysicsEnabled(!value)
1277
+ private updateInstances(deltaTime: number): void {
1278
+ this.forEachInstance((inst) => {
1279
+ const verticesChanged = inst.model.update(deltaTime)
1280
+ if (verticesChanged) inst.vertexBufferNeedsUpdate = true
1281
+ if (inst.physics && inst.model.getPhysicsEnabled()) {
1282
+ inst.physics.step(
1283
+ deltaTime,
1284
+ inst.model.getWorldMatrices(),
1285
+ inst.model.getBoneInverseBindMatrices()
1286
+ )
1287
+ }
1288
+ if (inst.vertexBufferNeedsUpdate) this.updateVertexBuffer(inst)
1289
+ })
1234
1290
  }
1235
1291
 
1236
- private updateVertexBuffer(): void {
1237
- if (!this.currentModel || !this.vertexBuffer) return
1238
- const vertices = this.currentModel.getVertices()
1239
- if (!vertices || vertices.length === 0) return
1240
- this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices)
1292
+ private updateVertexBuffer(inst: ModelInstance): void {
1293
+ const vertices = inst.model.getVertices()
1294
+ if (!vertices?.length) return
1295
+ this.device.queue.writeBuffer(inst.vertexBuffer, 0, vertices)
1296
+ inst.vertexBufferNeedsUpdate = false
1241
1297
  }
1242
1298
 
1243
- // Step 7: Create vertex, index, and joint buffers
1244
- private async setupModelBuffers(model: Model) {
1245
- this.currentModel = model
1246
-
1247
- // Apply IK and Physics flags from engine options
1248
- model.setIKEnabled(!this._disableIK)
1249
- model.setPhysicsEnabled(!this._disablePhysics)
1250
-
1299
+ private async setupModelInstance(name: string, model: Model, basePath: string): Promise<void> {
1251
1300
  const vertices = model.getVertices()
1252
1301
  const skinning = model.getSkinning()
1253
1302
  const skeleton = model.getSkeleton()
1303
+ const boneCount = skeleton.bones.length
1304
+ const matrixSize = boneCount * 16 * 4
1254
1305
 
1255
- this.vertexBuffer = this.device.createBuffer({
1256
- label: "model vertex buffer",
1306
+ const vertexBuffer = this.device.createBuffer({
1307
+ label: `${name}: vertex buffer`,
1257
1308
  size: vertices.byteLength,
1258
1309
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1259
1310
  })
1260
- this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices)
1311
+ this.device.queue.writeBuffer(vertexBuffer, 0, vertices)
1261
1312
 
1262
- this.jointsBuffer = this.device.createBuffer({
1263
- label: "joints buffer",
1313
+ const jointsBuffer = this.device.createBuffer({
1314
+ label: `${name}: joints buffer`,
1264
1315
  size: skinning.joints.byteLength,
1265
1316
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1266
1317
  })
1267
1318
  this.device.queue.writeBuffer(
1268
- this.jointsBuffer,
1319
+ jointsBuffer,
1269
1320
  0,
1270
1321
  skinning.joints.buffer,
1271
1322
  skinning.joints.byteOffset,
1272
1323
  skinning.joints.byteLength
1273
1324
  )
1274
1325
 
1275
- this.weightsBuffer = this.device.createBuffer({
1276
- label: "weights buffer",
1326
+ const weightsBuffer = this.device.createBuffer({
1327
+ label: `${name}: weights buffer`,
1277
1328
  size: skinning.weights.byteLength,
1278
1329
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1279
1330
  })
1280
1331
  this.device.queue.writeBuffer(
1281
- this.weightsBuffer,
1332
+ weightsBuffer,
1282
1333
  0,
1283
1334
  skinning.weights.buffer,
1284
1335
  skinning.weights.byteOffset,
1285
1336
  skinning.weights.byteLength
1286
1337
  )
1287
1338
 
1288
- const boneCount = skeleton.bones.length
1289
- const matrixSize = boneCount * 16 * 4
1290
-
1291
- this.skinMatrixBuffer = this.device.createBuffer({
1292
- label: "skin matrices",
1339
+ const skinMatrixBuffer = this.device.createBuffer({
1340
+ label: `${name}: skin matrices`,
1293
1341
  size: Math.max(256, matrixSize),
1294
1342
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1295
1343
  })
1296
1344
 
1297
- this.inverseBindMatrixBuffer = this.device.createBuffer({
1298
- label: "inverse bind matrices",
1299
- size: Math.max(256, matrixSize),
1300
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1345
+ const indices = model.getIndices()
1346
+ if (!indices) throw new Error("Model has no index buffer")
1347
+ const indexBuffer = this.device.createBuffer({
1348
+ label: `${name}: index buffer`,
1349
+ size: indices.byteLength,
1350
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
1301
1351
  })
1352
+ this.device.queue.writeBuffer(indexBuffer, 0, indices)
1302
1353
 
1303
- const invBindMatrices = skeleton.inverseBindMatrices
1304
- this.device.queue.writeBuffer(
1305
- this.inverseBindMatrixBuffer,
1306
- 0,
1307
- invBindMatrices.buffer,
1308
- invBindMatrices.byteOffset,
1309
- invBindMatrices.byteLength
1310
- )
1354
+ const rbs = model.getRigidbodies()
1355
+ const physics = rbs.length > 0 ? new Physics(rbs, model.getJoints()) : null
1311
1356
 
1312
- const indices = model.getIndices()
1313
- if (indices) {
1314
- this.indexBuffer = this.device.createBuffer({
1315
- label: "model index buffer",
1316
- size: indices.byteLength,
1317
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
1318
- })
1319
- this.device.queue.writeBuffer(this.indexBuffer, 0, indices)
1320
- } else {
1321
- throw new Error("Model has no index buffer")
1322
- }
1357
+ const shadowBindGroup = this.device.createBindGroup({
1358
+ label: `${name}: shadow bind`,
1359
+ layout: this.shadowDepthPipeline.getBindGroupLayout(0),
1360
+ entries: [
1361
+ { binding: 0, resource: { buffer: this.shadowLightVPBuffer } },
1362
+ { binding: 1, resource: { buffer: skinMatrixBuffer } },
1363
+ ],
1364
+ })
1323
1365
 
1324
- await this.setupMaterials(model)
1366
+ const inst: ModelInstance = {
1367
+ name,
1368
+ model,
1369
+ basePath,
1370
+ vertexBuffer,
1371
+ indexBuffer,
1372
+ jointsBuffer,
1373
+ weightsBuffer,
1374
+ skinMatrixBuffer,
1375
+ drawCalls: [],
1376
+ shadowDrawCalls: [],
1377
+ shadowBindGroup,
1378
+ hiddenMaterials: new Set(),
1379
+ physics,
1380
+ vertexBufferNeedsUpdate: false,
1381
+ }
1382
+ await this.setupMaterialsForInstance(inst)
1383
+ this.modelInstances.set(name, inst)
1325
1384
  }
1326
1385
 
1327
1386
  private createGroundGeometry(width: number = 100, height: number = 100) {
@@ -1396,34 +1455,25 @@ export class Engine {
1396
1455
  this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices)
1397
1456
  }
1398
1457
 
1399
- private createGroundMaterialBuffer(
1400
- diffuseColor: Vec3 = new Vec3(1, 1, 1),
1401
- reflectionLevel: number = 0.5,
1402
- fadeStart: number = 5.0,
1403
- fadeEnd: number = 60.0
1404
- ) {
1405
- const materialData = new Float32Array([
1406
- diffuseColor.x,
1407
- diffuseColor.y,
1408
- diffuseColor.z, // diffuseColor (12 bytes)
1409
- reflectionLevel, // reflectionLevel (4 bytes)
1410
- fadeStart, // fadeStart (4 bytes)
1411
- fadeEnd, // fadeEnd (4 bytes)
1412
- 0, // padding (4 bytes)
1413
- 0, // padding (4 bytes)
1414
- 0, // padding (4 bytes)
1415
- 0, // padding (4 bytes)
1416
- ])
1417
-
1458
+ private createGroundMaterialBuffer(diffuseColor: Vec3, reflectionLevel: number, fadeStart: number, fadeEnd: number) {
1459
+ const u = new Float32Array(8)
1460
+ u[0] = diffuseColor.x
1461
+ u[1] = diffuseColor.y
1462
+ u[2] = diffuseColor.z
1463
+ u[3] = reflectionLevel
1464
+ u[4] = fadeStart
1465
+ u[5] = fadeEnd
1466
+ u[6] = 0
1467
+ u[7] = 0
1418
1468
  this.groundMaterialUniformBuffer = this.device.createBuffer({
1419
1469
  label: "ground material uniform buffer",
1420
- size: materialData.byteLength,
1470
+ size: 64,
1421
1471
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1422
1472
  })
1423
- this.device.queue.writeBuffer(this.groundMaterialUniformBuffer, 0, materialData)
1473
+ this.device.queue.writeBuffer(this.groundMaterialUniformBuffer, 0, u)
1424
1474
  }
1425
1475
 
1426
- private createReflectionTexture(size: number = 1024) {
1476
+ private createReflectionTexture(size: number) {
1427
1477
  this.groundReflectionTexture = this.device.createTexture({
1428
1478
  label: "ground reflection texture",
1429
1479
  size: [size, size],
@@ -1431,14 +1481,12 @@ export class Engine {
1431
1481
  format: this.presentationFormat,
1432
1482
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
1433
1483
  })
1434
-
1435
1484
  this.groundReflectionResolveTexture = this.device.createTexture({
1436
1485
  label: "ground reflection resolve texture",
1437
1486
  size: [size, size],
1438
1487
  format: this.presentationFormat,
1439
1488
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1440
1489
  })
1441
-
1442
1490
  this.groundReflectionDepthTexture = this.device.createTexture({
1443
1491
  label: "ground reflection depth texture",
1444
1492
  size: [size, size],
@@ -1446,42 +1494,95 @@ export class Engine {
1446
1494
  format: "depth24plus-stencil8",
1447
1495
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
1448
1496
  })
1449
-
1450
- // Create a bind group for the reflection texture that can be used in the ground material
1451
1497
  this.groundReflectionBindGroup = this.device.createBindGroup({
1452
1498
  label: "ground reflection bind group",
1453
1499
  layout: this.groundBindGroupLayout,
1454
1500
  entries: [
1455
1501
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1456
1502
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1457
- { binding: 2, resource: this.groundReflectionResolveTexture!.createView() }, // Use resolve texture for sampling
1503
+ { binding: 2, resource: this.groundReflectionResolveTexture.createView() },
1458
1504
  { binding: 3, resource: this.materialSampler },
1459
1505
  { binding: 4, resource: { buffer: this.groundMaterialUniformBuffer! } },
1460
1506
  ],
1461
1507
  })
1462
1508
  }
1463
1509
 
1464
- private async setupMaterials(model: Model) {
1465
- const materials = model.getMaterials()
1466
- if (materials.length === 0) {
1467
- throw new Error("Model has no materials")
1468
- }
1510
+ private createShadowGroundResources(
1511
+ shadowMapSize: number,
1512
+ diffuseColor: Vec3,
1513
+ fadeStart: number,
1514
+ fadeEnd: number,
1515
+ shadowStrength: number
1516
+ ) {
1517
+ this.shadowMapTexture = this.device.createTexture({
1518
+ label: "shadow map",
1519
+ size: [shadowMapSize, shadowMapSize],
1520
+ format: "depth32float",
1521
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1522
+ })
1523
+ this.shadowMapDepthView = this.shadowMapTexture.createView()
1524
+ const gb = new Float32Array(8)
1525
+ gb[0] = diffuseColor.x
1526
+ gb[1] = diffuseColor.y
1527
+ gb[2] = diffuseColor.z
1528
+ gb[3] = fadeStart
1529
+ gb[4] = fadeEnd
1530
+ gb[5] = shadowStrength
1531
+ gb[6] = 1.2 / shadowMapSize
1532
+ gb[7] = 0
1533
+ this.groundShadowMaterialBuffer = this.device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST })
1534
+ this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb)
1535
+ this.groundShadowBindGroup = this.device.createBindGroup({
1536
+ label: "ground shadow bind",
1537
+ layout: this.groundShadowBindGroupLayout,
1538
+ entries: [
1539
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1540
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1541
+ { binding: 2, resource: this.shadowMapDepthView },
1542
+ { binding: 3, resource: this.shadowComparisonSampler },
1543
+ { binding: 4, resource: { buffer: this.groundShadowMaterialBuffer } },
1544
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
1545
+ ],
1546
+ })
1547
+ }
1469
1548
 
1549
+ private updateShadowLightVP() {
1550
+ const lx = this.lightData[4]
1551
+ const ly = this.lightData[5]
1552
+ const lz = this.lightData[6]
1553
+ if (lx === this.shadowVPLightX && ly === this.shadowVPLightY && lz === this.shadowVPLightZ) return
1554
+ this.shadowVPLightX = lx
1555
+ this.shadowVPLightY = ly
1556
+ this.shadowVPLightZ = lz
1557
+ const dir = new Vec3(lx, ly, lz)
1558
+ if (dir.length() < 1e-6) {
1559
+ dir.x = 0.35
1560
+ dir.y = -1
1561
+ dir.z = 0.2
1562
+ } else dir.normalize()
1563
+ const target = new Vec3(0, 11, 0)
1564
+ const eye = new Vec3(target.x - dir.x * 72, target.y - dir.y * 72, target.z - dir.z * 72)
1565
+ const view = Mat4.lookAt(eye, target, new Vec3(0, 1, 0))
1566
+ const proj = Mat4.orthographicLh(-72, 72, -72, 72, 1, 140)
1567
+ const vp = proj.multiply(view)
1568
+ this.shadowLightVPMatrix.set(vp.values)
1569
+ this.device.queue.writeBuffer(this.shadowLightVPBuffer, 0, this.shadowLightVPMatrix)
1570
+ }
1571
+
1572
+ private async setupMaterialsForInstance(inst: ModelInstance): Promise<void> {
1573
+ const model = inst.model
1574
+ const materials = model.getMaterials()
1575
+ if (materials.length === 0) throw new Error("Model has no materials")
1470
1576
  const textures = model.getTextures()
1577
+ const prefix = `${inst.name}: `
1471
1578
 
1472
1579
  const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
1473
- if (texIndex < 0 || texIndex >= textures.length) {
1474
- return null
1475
- }
1476
-
1477
- const path = this.modelDir + textures[texIndex].path
1478
- const texture = await this.createTextureFromPath(path)
1479
- return texture
1580
+ if (texIndex < 0 || texIndex >= textures.length) return null
1581
+ const path = inst.basePath + textures[texIndex].path
1582
+ return this.createTextureFromPath(path)
1480
1583
  }
1481
1584
 
1482
- this.drawCalls = []
1483
1585
  let currentIndexOffset = 0
1484
-
1485
1586
  for (const mat of materials) {
1486
1587
  const indexCount = mat.vertexCount
1487
1588
  if (indexCount === 0) continue
@@ -1493,7 +1594,7 @@ export class Engine {
1493
1594
  const isTransparent = materialAlpha < 1.0 - 0.001
1494
1595
 
1495
1596
  const materialUniformBuffer = this.createMaterialUniformBuffer(
1496
- mat.name,
1597
+ prefix + mat.name,
1497
1598
  materialAlpha,
1498
1599
  0.0,
1499
1600
  [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]],
@@ -1502,34 +1603,26 @@ export class Engine {
1502
1603
  mat.shininess
1503
1604
  )
1504
1605
 
1505
- // Create bind groups using the shared bind group layout - All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
1506
1606
  const bindGroup = this.device.createBindGroup({
1507
- label: `material bind group: ${mat.name}`,
1607
+ label: `${prefix}material: ${mat.name}`,
1508
1608
  layout: this.mainBindGroupLayout,
1509
1609
  entries: [
1510
1610
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1511
1611
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1512
1612
  { binding: 2, resource: diffuseTexture.createView() },
1513
1613
  { binding: 3, resource: this.materialSampler },
1514
- { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
1614
+ { binding: 4, resource: { buffer: inst.skinMatrixBuffer } },
1515
1615
  { binding: 5, resource: { buffer: materialUniformBuffer } },
1516
1616
  ],
1517
1617
  })
1518
1618
 
1519
1619
  if (indexCount > 0) {
1520
1620
  if (mat.isEye) {
1521
- this.drawCalls.push({
1522
- type: "eye",
1523
- count: indexCount,
1524
- firstIndex: currentIndexOffset,
1525
- bindGroup,
1526
- materialName: mat.name,
1527
- })
1621
+ inst.drawCalls.push({ type: "eye", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
1528
1622
  } else if (mat.isHair) {
1529
- // Hair materials: create separate bind groups for over-eyes vs over-non-eyes
1530
1623
  const createHairBindGroup = (isOverEyes: boolean) => {
1531
- const buffer = this.createMaterialUniformBuffer(
1532
- `${mat.name} (${isOverEyes ? "over eyes" : "over non-eyes"})`,
1624
+ const buf = this.createMaterialUniformBuffer(
1625
+ `${prefix}${mat.name} (${isOverEyes ? "over eyes" : "over non-eyes"})`,
1533
1626
  materialAlpha,
1534
1627
  isOverEyes ? 1.0 : 0.0,
1535
1628
  [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]],
@@ -1537,104 +1630,68 @@ export class Engine {
1537
1630
  mat.specular,
1538
1631
  mat.shininess
1539
1632
  )
1540
-
1541
1633
  return this.device.createBindGroup({
1542
- label: `material bind group (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1634
+ label: `${prefix}hair ${isOverEyes ? "over eyes" : "over non-eyes"}: ${mat.name}`,
1543
1635
  layout: this.mainBindGroupLayout,
1544
1636
  entries: [
1545
1637
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1546
1638
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1547
1639
  { binding: 2, resource: diffuseTexture.createView() },
1548
1640
  { binding: 3, resource: this.materialSampler },
1549
- { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
1550
- { binding: 5, resource: { buffer: buffer } },
1641
+ { binding: 4, resource: { buffer: inst.skinMatrixBuffer } },
1642
+ { binding: 5, resource: { buffer: buf } },
1551
1643
  ],
1552
1644
  })
1553
1645
  }
1554
-
1555
- const bindGroupOverEyes = createHairBindGroup(true)
1556
- const bindGroupOverNonEyes = createHairBindGroup(false)
1557
-
1558
- this.drawCalls.push({
1646
+ inst.drawCalls.push({
1559
1647
  type: "hair-over-eyes",
1560
1648
  count: indexCount,
1561
1649
  firstIndex: currentIndexOffset,
1562
- bindGroup: bindGroupOverEyes,
1650
+ bindGroup: createHairBindGroup(true),
1563
1651
  materialName: mat.name,
1564
1652
  })
1565
- this.drawCalls.push({
1653
+ inst.drawCalls.push({
1566
1654
  type: "hair-over-non-eyes",
1567
1655
  count: indexCount,
1568
1656
  firstIndex: currentIndexOffset,
1569
- bindGroup: bindGroupOverNonEyes,
1657
+ bindGroup: createHairBindGroup(false),
1570
1658
  materialName: mat.name,
1571
1659
  })
1572
1660
  } else if (isTransparent) {
1573
- this.drawCalls.push({
1574
- type: "transparent",
1575
- count: indexCount,
1576
- firstIndex: currentIndexOffset,
1577
- bindGroup,
1578
- materialName: mat.name,
1579
- })
1661
+ inst.drawCalls.push({ type: "transparent", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
1580
1662
  } else {
1581
- this.drawCalls.push({
1582
- type: "opaque",
1583
- count: indexCount,
1584
- firstIndex: currentIndexOffset,
1585
- bindGroup,
1586
- materialName: mat.name,
1587
- })
1663
+ inst.drawCalls.push({ type: "opaque", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
1588
1664
  }
1589
1665
  }
1590
1666
 
1591
- // Edge flag is at bit 4 (0x10) in PMX format
1592
1667
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1593
1668
  const materialUniformData = new Float32Array([
1594
- mat.edgeColor[0],
1595
- mat.edgeColor[1],
1596
- mat.edgeColor[2],
1597
- mat.edgeColor[3],
1598
- mat.edgeSize,
1599
- 0,
1600
- 0,
1601
- 0,
1669
+ mat.edgeColor[0], mat.edgeColor[1], mat.edgeColor[2], mat.edgeColor[3],
1670
+ mat.edgeSize, 0, 0, 0,
1602
1671
  ])
1603
- const materialUniformBuffer = this.createUniformBuffer(
1604
- `outline material uniform: ${mat.name}`,
1605
- materialUniformData
1606
- )
1607
-
1672
+ const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData)
1608
1673
  const outlineBindGroup = this.device.createBindGroup({
1609
- label: `outline bind group: ${mat.name}`,
1674
+ label: `${prefix}outline: ${mat.name}`,
1610
1675
  layout: this.outlineBindGroupLayout,
1611
1676
  entries: [
1612
1677
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1613
- { binding: 1, resource: { buffer: materialUniformBuffer } },
1614
- { binding: 2, resource: { buffer: this.skinMatrixBuffer! } },
1678
+ { binding: 1, resource: { buffer: outlineUniformBuffer } },
1679
+ { binding: 2, resource: { buffer: inst.skinMatrixBuffer } },
1615
1680
  ],
1616
1681
  })
1617
-
1618
1682
  if (indexCount > 0) {
1619
- const outlineType: DrawCallType = mat.isEye
1620
- ? "eye-outline"
1621
- : mat.isHair
1622
- ? "hair-outline"
1623
- : isTransparent
1624
- ? "transparent-outline"
1625
- : "opaque-outline"
1626
- this.drawCalls.push({
1627
- type: outlineType,
1628
- count: indexCount,
1629
- firstIndex: currentIndexOffset,
1630
- bindGroup: outlineBindGroup,
1631
- materialName: mat.name,
1632
- })
1683
+ const outlineType: DrawCallType = mat.isEye ? "eye-outline" : mat.isHair ? "hair-outline" : isTransparent ? "transparent-outline" : "opaque-outline"
1684
+ inst.drawCalls.push({ type: outlineType, count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup, materialName: mat.name })
1633
1685
  }
1634
1686
  }
1635
1687
 
1636
1688
  currentIndexOffset += indexCount
1637
1689
  }
1690
+
1691
+ for (const d of inst.drawCalls) {
1692
+ if (d.type === "opaque" || d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes")
1693
+ inst.shadowDrawCalls.push(d)
1694
+ }
1638
1695
  }
1639
1696
 
1640
1697
  private createMaterialUniformBuffer(
@@ -1682,8 +1739,8 @@ export class Engine {
1682
1739
  return buffer
1683
1740
  }
1684
1741
 
1685
- private shouldRenderDrawCall(drawCall: DrawCall): boolean {
1686
- return !this.hiddenMaterials.has(drawCall.materialName)
1742
+ private shouldRenderDrawCall(inst: ModelInstance, drawCall: DrawCall): boolean {
1743
+ return !inst.hiddenMaterials.has(drawCall.materialName)
1687
1744
  }
1688
1745
 
1689
1746
  private async createTextureFromPath(path: string): Promise<GPUTexture | null> {
@@ -1721,11 +1778,10 @@ export class Engine {
1721
1778
  }
1722
1779
 
1723
1780
  // Helper: Render eyes with stencil writing (for post-alpha-eye effect)
1724
- private renderEyes(pass: GPURenderPassEncoder, useReflectionPipeline: boolean = false) {
1781
+ private renderEyes(pass: GPURenderPassEncoder, inst: ModelInstance, useReflectionPipeline = false) {
1725
1782
  if (useReflectionPipeline) {
1726
- // For reflections, use the basic reflection pipeline instead of specialized eye pipeline
1727
1783
  pass.setPipeline(this.reflectionPipeline)
1728
- for (const draw of this.drawCalls) {
1784
+ for (const draw of inst.drawCalls) {
1729
1785
  if (draw.type === "eye") {
1730
1786
  pass.setBindGroup(0, draw.bindGroup)
1731
1787
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
@@ -1734,8 +1790,8 @@ export class Engine {
1734
1790
  } else {
1735
1791
  pass.setPipeline(this.eyePipeline)
1736
1792
  pass.setStencilReference(this.STENCIL_EYE_VALUE)
1737
- for (const draw of this.drawCalls) {
1738
- if (draw.type === "eye" && this.shouldRenderDrawCall(draw)) {
1793
+ for (const draw of inst.drawCalls) {
1794
+ if (draw.type === "eye" && this.shouldRenderDrawCall(inst, draw)) {
1739
1795
  pass.setBindGroup(0, draw.bindGroup)
1740
1796
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1741
1797
  }
@@ -1744,26 +1800,13 @@ export class Engine {
1744
1800
  }
1745
1801
 
1746
1802
  private renderGround(pass: GPURenderPassEncoder) {
1747
- if (!this.groundHasReflections || !this.groundVertexBuffer || !this.groundIndexBuffer) {
1748
- return
1749
- }
1750
-
1751
- if (this.groundReflectionTexture) {
1752
- this.renderReflectionTexture()
1753
- }
1754
- pass.setPipeline(this.groundPipeline)
1803
+ if (!this.groundHasReflections || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
1804
+ if (this.groundMode === "reflection" && this.groundReflectionTexture) this.renderReflectionTexture()
1805
+ pass.setPipeline(this.groundMode === "reflection" ? this.groundPipeline : this.groundShadowPipeline)
1755
1806
  pass.setVertexBuffer(0, this.groundVertexBuffer)
1756
1807
  pass.setIndexBuffer(this.groundIndexBuffer, "uint16")
1757
-
1758
- for (const draw of this.drawCalls) {
1759
- if (draw.type === "ground" && this.shouldRenderDrawCall(draw)) {
1760
- pass.setBindGroup(0, draw.bindGroup)
1761
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1762
- }
1763
- }
1764
-
1765
- // // Restore model index buffer for subsequent rendering
1766
- // pass.setIndexBuffer(this.indexBuffer!, "uint32")
1808
+ pass.setBindGroup(0, this.groundDrawCall.bindGroup)
1809
+ pass.drawIndexed(this.groundDrawCall.count, 1, this.groundDrawCall.firstIndex, 0, 0)
1767
1810
  }
1768
1811
 
1769
1812
  private renderReflectionTexture() {
@@ -1796,55 +1839,16 @@ export class Engine {
1796
1839
  }
1797
1840
 
1798
1841
  const reflectionPass = reflectionEncoder.beginRenderPass(reflectionPassDescriptor)
1799
-
1800
- if (this.currentModel) {
1801
- reflectionPass.setVertexBuffer(0, this.vertexBuffer)
1802
- reflectionPass.setVertexBuffer(1, this.jointsBuffer)
1803
- reflectionPass.setVertexBuffer(2, this.weightsBuffer)
1804
- reflectionPass.setIndexBuffer(this.indexBuffer!, "uint32")
1805
-
1806
- this.writeMirrorTransformedSkinMatrices(mirrorMatrix)
1807
- reflectionPass.setPipeline(this.reflectionPipeline)
1808
- for (const draw of this.drawCalls) {
1809
- if (draw.type === "opaque" && this.shouldRenderDrawCall(draw)) {
1810
- reflectionPass.setBindGroup(0, draw.bindGroup)
1811
- reflectionPass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1812
- }
1813
- }
1814
-
1815
- // Render eyes (using reflection pipeline)
1816
- this.renderEyes(reflectionPass, true)
1817
-
1818
- // Render hair (using reflection pipeline)
1819
- this.renderHair(reflectionPass, true)
1820
-
1821
- // Render transparent objects
1822
- for (const draw of this.drawCalls) {
1823
- if (draw.type === "transparent" && this.shouldRenderDrawCall(draw)) {
1824
- reflectionPass.setBindGroup(0, draw.bindGroup)
1825
- reflectionPass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1826
- }
1827
- }
1828
-
1829
- this.drawOutlines(reflectionPass, true, true)
1830
- }
1831
-
1842
+ this.forEachInstance((inst) => this.renderOneModel(reflectionPass, inst, true, mirrorMatrix))
1832
1843
  reflectionPass.end()
1833
-
1834
- // Submit reflection rendering commands
1835
- const reflectionCommandBuffer = reflectionEncoder.finish()
1836
- this.device.queue.submit([reflectionCommandBuffer])
1837
-
1838
- // Restore original skin matrices
1844
+ this.device.queue.submit([reflectionEncoder.finish()])
1839
1845
  this.updateSkinMatrices()
1840
1846
  }
1841
1847
 
1842
- // Helper: Render hair with post-alpha-eye effect (depth pre-pass + stencil-based shading + outlines)
1843
- private renderHair(pass: GPURenderPassEncoder, useReflectionPipeline: boolean = false) {
1848
+ private renderHair(pass: GPURenderPassEncoder, inst: ModelInstance, useReflectionPipeline = false) {
1844
1849
  if (useReflectionPipeline) {
1845
- // For reflections, use the basic reflection pipeline for all hair
1846
1850
  pass.setPipeline(this.reflectionPipeline)
1847
- for (const draw of this.drawCalls) {
1851
+ for (const draw of inst.drawCalls) {
1848
1852
  if (draw.type === "hair-over-eyes" || draw.type === "hair-over-non-eyes") {
1849
1853
  pass.setBindGroup(0, draw.bindGroup)
1850
1854
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
@@ -1853,22 +1857,20 @@ export class Engine {
1853
1857
  return
1854
1858
  }
1855
1859
 
1856
- // Hair depth pre-pass (reduces overdraw via early depth rejection)
1857
- const hasHair = this.drawCalls.some(
1858
- (d) => (d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(d)
1860
+ const hasHair = inst.drawCalls.some(
1861
+ (d) => (d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(inst, d)
1859
1862
  )
1860
1863
  if (hasHair) {
1861
1864
  pass.setPipeline(this.hairDepthPipeline)
1862
- for (const draw of this.drawCalls) {
1863
- if ((draw.type === "hair-over-eyes" || draw.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(draw)) {
1865
+ for (const draw of inst.drawCalls) {
1866
+ if ((draw.type === "hair-over-eyes" || draw.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(inst, draw)) {
1864
1867
  pass.setBindGroup(0, draw.bindGroup)
1865
1868
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1866
1869
  }
1867
1870
  }
1868
1871
  }
1869
1872
 
1870
- // Hair shading (split by stencil for transparency over eyes)
1871
- const hairOverEyes = this.drawCalls.filter((d) => d.type === "hair-over-eyes" && this.shouldRenderDrawCall(d))
1873
+ const hairOverEyes = inst.drawCalls.filter((d) => d.type === "hair-over-eyes" && this.shouldRenderDrawCall(inst, d))
1872
1874
  if (hairOverEyes.length > 0) {
1873
1875
  pass.setPipeline(this.hairPipelineOverEyes)
1874
1876
  pass.setStencilReference(this.STENCIL_EYE_VALUE)
@@ -1878,9 +1880,7 @@ export class Engine {
1878
1880
  }
1879
1881
  }
1880
1882
 
1881
- const hairOverNonEyes = this.drawCalls.filter(
1882
- (d) => d.type === "hair-over-non-eyes" && this.shouldRenderDrawCall(d)
1883
- )
1883
+ const hairOverNonEyes = inst.drawCalls.filter((d) => d.type === "hair-over-non-eyes" && this.shouldRenderDrawCall(inst, d))
1884
1884
  if (hairOverNonEyes.length > 0) {
1885
1885
  pass.setPipeline(this.hairPipelineOverNonEyes)
1886
1886
  pass.setStencilReference(this.STENCIL_EYE_VALUE)
@@ -1890,8 +1890,7 @@ export class Engine {
1890
1890
  }
1891
1891
  }
1892
1892
 
1893
- // Hair outlines
1894
- const hairOutlines = this.drawCalls.filter((d) => d.type === "hair-outline" && this.shouldRenderDrawCall(d))
1893
+ const hairOutlines = inst.drawCalls.filter((d) => d.type === "hair-outline" && this.shouldRenderDrawCall(inst, d))
1895
1894
  if (hairOutlines.length > 0) {
1896
1895
  pass.setPipeline(this.hairOutlinePipeline)
1897
1896
  for (const draw of hairOutlines) {
@@ -1902,17 +1901,13 @@ export class Engine {
1902
1901
  }
1903
1902
 
1904
1903
  private handleCanvasDoubleClick = (event: MouseEvent) => {
1905
- if (!this.onRaycast || !this.currentModel) return
1906
-
1904
+ if (!this.onRaycast || this.modelInstances.size === 0) return
1907
1905
  const rect = this.canvas.getBoundingClientRect()
1908
- const x = event.clientX - rect.left
1909
- const y = event.clientY - rect.top
1910
-
1911
- this.performRaycast(x, y)
1906
+ this.performRaycast(event.clientX - rect.left, event.clientY - rect.top)
1912
1907
  }
1913
1908
 
1914
1909
  private handleCanvasTouch = (event: TouchEvent) => {
1915
- if (!this.onRaycast || !this.currentModel) return
1910
+ if (!this.onRaycast || this.modelInstances.size === 0) return
1916
1911
 
1917
1912
  // Prevent default to avoid triggering mouse events
1918
1913
  event.preventDefault()
@@ -1940,299 +1935,215 @@ export class Engine {
1940
1935
  }
1941
1936
 
1942
1937
  private performRaycast(screenX: number, screenY: number) {
1943
- if (!this.currentModel || !this.onRaycast) return
1944
-
1945
- const materials = this.currentModel.getMaterials()
1946
- if (materials.length === 0) return
1938
+ if (!this.onRaycast || this.modelInstances.size === 0) {
1939
+ this.onRaycast?.("", null, screenX, screenY)
1940
+ return
1941
+ }
1947
1942
 
1948
- // Get camera matrices
1949
1943
  const viewMatrix = this.camera.getViewMatrix()
1950
1944
  const projectionMatrix = this.camera.getProjectionMatrix()
1951
-
1952
- // Convert screen coordinates to world space ray
1953
- const canvas = this.canvas
1954
- const rect = canvas.getBoundingClientRect()
1955
-
1956
- // Convert to clip space (-1 to 1)
1945
+ const rect = this.canvas.getBoundingClientRect()
1957
1946
  const clipX = (screenX / rect.width) * 2 - 1
1958
- const clipY = 1 - (screenY / rect.height) * 2 // Flip Y
1959
-
1960
- // Create ray in clip space at near and far planes
1961
- const clipNear = new Vec3(clipX, clipY, -1) // Near plane
1962
- const clipFar = new Vec3(clipX, clipY, 1) // Far plane
1963
-
1964
- // Transform to world space using inverse view-projection matrix
1947
+ const clipY = 1 - (screenY / rect.height) * 2
1965
1948
  const viewProjMatrix = projectionMatrix.multiply(viewMatrix)
1966
1949
  const inverseViewProj = viewProjMatrix.inverse()
1967
-
1968
- // Transform point through 4x4 matrix with perspective division
1969
1950
  const transformPoint = (matrix: Mat4, point: Vec3): Vec3 => {
1970
1951
  const m = matrix.values
1971
- const x = point.x,
1972
- y = point.y,
1973
- z = point.z
1974
-
1975
- // Compute transformed point (matrix * vec4(point, 1.0))
1952
+ const x = point.x, y = point.y, z = point.z
1976
1953
  const result = new Vec3(
1977
1954
  m[0] * x + m[4] * y + m[8] * z + m[12],
1978
1955
  m[1] * x + m[5] * y + m[9] * z + m[13],
1979
1956
  m[2] * x + m[6] * y + m[10] * z + m[14]
1980
1957
  )
1981
-
1982
- // Perspective division
1983
1958
  const w = m[3] * x + m[7] * y + m[11] * z + m[15]
1984
- const invW = w !== 0 ? 1 / w : 1
1985
-
1986
- return result.scale(invW)
1959
+ return result.scale(w !== 0 ? 1 / w : 1)
1987
1960
  }
1988
-
1989
- const worldNear = transformPoint(inverseViewProj, clipNear)
1990
- const worldFar = transformPoint(inverseViewProj, clipFar)
1991
-
1992
- // Create ray from camera position through the clicked point
1961
+ const worldNear = transformPoint(inverseViewProj, new Vec3(clipX, clipY, -1))
1962
+ const worldFar = transformPoint(inverseViewProj, new Vec3(clipX, clipY, 1))
1993
1963
  const rayOrigin = this.camera.getPosition()
1994
1964
  const rayDirection = worldFar.subtract(worldNear).normalize()
1995
1965
 
1996
- // Get model geometry for ray-triangle intersection
1997
- const baseVertices = this.currentModel.getVertices()
1998
- const indices = this.currentModel.getIndices()
1999
- const skinning = this.currentModel.getSkinning()
2000
-
2001
- if (!baseVertices || !indices || !skinning) {
2002
- if (this.onRaycast) {
2003
- this.onRaycast(null, screenX, screenY)
2004
- }
2005
- return
1966
+ const transformByMatrix = (matrix: Float32Array, offset: number, point: Vec3): Vec3 => {
1967
+ const m = matrix, x = point.x, y = point.y, z = point.z
1968
+ return new Vec3(
1969
+ m[offset + 0] * x + m[offset + 4] * y + m[offset + 8] * z + m[offset + 12],
1970
+ m[offset + 1] * x + m[offset + 5] * y + m[offset + 9] * z + m[offset + 13],
1971
+ m[offset + 2] * x + m[offset + 6] * y + m[offset + 10] * z + m[offset + 14]
1972
+ )
2006
1973
  }
2007
1974
 
2008
- // Use cached skinned vertices if available and up-to-date
2009
- let vertices: Float32Array
2010
- if (this.cachedSkinnedVertices && this.cachedSkinMatricesVersion === this.skinMatricesVersion) {
2011
- vertices = this.cachedSkinnedVertices
2012
- } else {
2013
- // Apply current skinning transformations to get animated vertex positions
2014
- vertices = new Float32Array(baseVertices.length)
2015
- const skinMatrices = this.currentModel.getSkinMatrices()
2016
-
2017
- // Helper function to transform point by 4x4 matrix
2018
- const transformByMatrix = (matrix: Float32Array, offset: number, point: Vec3): Vec3 => {
2019
- const m = matrix
2020
- const x = point.x,
2021
- y = point.y,
2022
- z = point.z
2023
- return new Vec3(
2024
- m[offset + 0] * x + m[offset + 4] * y + m[offset + 8] * z + m[offset + 12],
2025
- m[offset + 1] * x + m[offset + 5] * y + m[offset + 9] * z + m[offset + 13],
2026
- m[offset + 2] * x + m[offset + 6] * y + m[offset + 10] * z + m[offset + 14]
2027
- )
2028
- }
1975
+ let closest: { modelName: string; materialName: string; distance: number } | null = null
1976
+ const maxDistance = 1000
1977
+
1978
+ this.forEachInstance((inst) => {
1979
+ const model = inst.model
1980
+ const materials = model.getMaterials()
1981
+ if (materials.length === 0) return
1982
+ const baseVertices = model.getVertices()
1983
+ const indices = model.getIndices()
1984
+ const skinning = model.getSkinning()
1985
+ if (!baseVertices?.length || !indices || !skinning) return
2029
1986
 
1987
+ const vertices = new Float32Array(baseVertices.length)
1988
+ const skinMatrices = model.getSkinMatrices()
2030
1989
  for (let i = 0; i < baseVertices.length; i += 8) {
2031
- const vertexIndex = Math.floor(i / 8)
1990
+ const vertexIndex = i / 8
2032
1991
  const position = new Vec3(baseVertices[i], baseVertices[i + 1], baseVertices[i + 2])
2033
-
2034
- // Get bone influences for this vertex
2035
- const jointIndices = [
2036
- skinning.joints[vertexIndex * 4],
2037
- skinning.joints[vertexIndex * 4 + 1],
2038
- skinning.joints[vertexIndex * 4 + 2],
2039
- skinning.joints[vertexIndex * 4 + 3],
2040
- ]
2041
-
2042
- const weights = [
2043
- skinning.weights[vertexIndex * 4],
2044
- skinning.weights[vertexIndex * 4 + 1],
2045
- skinning.weights[vertexIndex * 4 + 2],
2046
- skinning.weights[vertexIndex * 4 + 3],
2047
- ]
2048
-
2049
- // Normalize weights (same as shader)
2050
- const weightSum = weights[0] + weights[1] + weights[2] + weights[3]
2051
- const invWeightSum = weightSum > 0.0001 ? 1.0 / weightSum : 1.0
2052
- const normalizedWeights = weightSum > 0.0001 ? weights.map((w) => w * invWeightSum) : [1.0, 0.0, 0.0, 0.0]
2053
-
2054
- // Apply skinning transformation (same as shader)
2055
- let skinnedPosition = new Vec3(0, 0, 0)
2056
-
1992
+ const j0 = skinning.joints[vertexIndex * 4], j1 = skinning.joints[vertexIndex * 4 + 1], j2 = skinning.joints[vertexIndex * 4 + 2], j3 = skinning.joints[vertexIndex * 4 + 3]
1993
+ 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
1994
+ const ws = w0 + w1 + w2 + w3
1995
+ const nw = ws > 0.0001 ? [w0 / ws, w1 / ws, w2 / ws, w3 / ws] : [1, 0, 0, 0]
1996
+ let sp = new Vec3(0, 0, 0)
2057
1997
  for (let j = 0; j < 4; j++) {
2058
- const weight = normalizedWeights[j]
2059
- if (weight > 0) {
2060
- const matrixOffset = jointIndices[j] * 16
2061
- const transformed = transformByMatrix(skinMatrices, matrixOffset, position)
2062
- skinnedPosition = skinnedPosition.add(transformed.scale(weight))
2063
- }
1998
+ if (nw[j] <= 0) continue
1999
+ const transformed = transformByMatrix(skinMatrices, [j0, j1, j2, j3][j] * 16, position)
2000
+ sp = sp.add(transformed.scale(nw[j]))
2064
2001
  }
2065
-
2066
- // Store transformed position, copy other attributes unchanged
2067
- vertices[i] = skinnedPosition.x
2068
- vertices[i + 1] = skinnedPosition.y
2069
- vertices[i + 2] = skinnedPosition.z
2070
- vertices[i + 3] = baseVertices[i + 3] // normal X
2071
- vertices[i + 4] = baseVertices[i + 4] // normal Y
2072
- vertices[i + 5] = baseVertices[i + 5] // normal Z
2073
- vertices[i + 6] = baseVertices[i + 6] // UV X
2074
- vertices[i + 7] = baseVertices[i + 7] // UV Y
2002
+ vertices[i] = sp.x
2003
+ vertices[i + 1] = sp.y
2004
+ vertices[i + 2] = sp.z
2005
+ vertices[i + 3] = baseVertices[i + 3]
2006
+ vertices[i + 4] = baseVertices[i + 4]
2007
+ vertices[i + 5] = baseVertices[i + 5]
2008
+ vertices[i + 6] = baseVertices[i + 6]
2009
+ vertices[i + 7] = baseVertices[i + 7]
2075
2010
  }
2076
2011
 
2077
- // Cache the result
2078
- this.cachedSkinnedVertices = vertices
2079
- this.cachedSkinMatricesVersion = this.skinMatricesVersion
2080
- }
2081
-
2082
- let closestHit: { materialName: string; distance: number } | null = null
2083
- const maxDistance = 1000 // Reasonable max distance
2084
-
2085
- // Test ray against all triangles (Möller-Trumbore algorithm)
2086
- for (let i = 0; i < indices.length; i += 3) {
2087
- const idx0 = indices[i] * 8 // Each vertex has 8 floats (pos + normal + uv)
2088
- const idx1 = indices[i + 1] * 8
2089
- const idx2 = indices[i + 2] * 8
2090
-
2091
- // Get triangle vertices in world space (first 3 floats are position)
2092
- const v0 = new Vec3(vertices[idx0], vertices[idx0 + 1], vertices[idx0 + 2])
2093
- const v1 = new Vec3(vertices[idx1], vertices[idx1 + 1], vertices[idx1 + 2])
2094
- const v2 = new Vec3(vertices[idx2], vertices[idx2 + 1], vertices[idx2 + 2])
2095
-
2096
- // Find which material this triangle belongs to
2097
- // Each material has mat.vertexCount indices (3 per triangle)
2098
- let triangleMaterialIndex = -1
2099
- let indexOffset = 0
2100
- for (let matIdx = 0; matIdx < materials.length; matIdx++) {
2101
- const mat = materials[matIdx]
2102
- if (i >= indexOffset && i < indexOffset + mat.vertexCount) {
2103
- triangleMaterialIndex = matIdx
2104
- break
2012
+ for (let i = 0; i < indices.length; i += 3) {
2013
+ const idx0 = indices[i] * 8, idx1 = indices[i + 1] * 8, idx2 = indices[i + 2] * 8
2014
+ const v0 = new Vec3(vertices[idx0], vertices[idx0 + 1], vertices[idx0 + 2])
2015
+ const v1 = new Vec3(vertices[idx1], vertices[idx1 + 1], vertices[idx1 + 2])
2016
+ const v2 = new Vec3(vertices[idx2], vertices[idx2 + 1], vertices[idx2 + 2])
2017
+ let triangleMaterialIndex = -1
2018
+ let indexOffset = 0
2019
+ for (let matIdx = 0; matIdx < materials.length; matIdx++) {
2020
+ if (i >= indexOffset && i < indexOffset + materials[matIdx].vertexCount) {
2021
+ triangleMaterialIndex = matIdx
2022
+ break
2023
+ }
2024
+ indexOffset += materials[matIdx].vertexCount
2105
2025
  }
2106
- indexOffset += mat.vertexCount
2107
- }
2108
-
2109
- if (triangleMaterialIndex === -1) continue
2110
-
2111
- // Skip invisible materials
2112
- // const materialName = materials[triangleMaterialIndex].name
2113
- // if (this.hiddenMaterials.has(materialName)) continue
2114
-
2115
- // Ray-triangle intersection test (Möller-Trumbore algorithm)
2116
- const edge1 = v1.subtract(v0)
2117
- const edge2 = v2.subtract(v0)
2118
- const h = rayDirection.cross(edge2)
2119
- const a = edge1.dot(h)
2120
-
2121
- if (Math.abs(a) < 0.0001) continue // Ray is parallel to triangle
2122
-
2123
- const f = 1.0 / a
2124
- const s = rayOrigin.subtract(v0)
2125
- const u = f * s.dot(h)
2126
-
2127
- if (u < 0.0 || u > 1.0) continue
2128
-
2129
- const q = s.cross(edge1)
2130
- const v = f * rayDirection.dot(q)
2131
-
2132
- if (v < 0.0 || u + v > 1.0) continue
2133
-
2134
- // At this point we have a hit
2135
- const t = f * edge2.dot(q)
2136
-
2137
- if (t > 0.0001 && t < maxDistance) {
2138
- // Backface culling: only consider front-facing triangles
2026
+ if (triangleMaterialIndex === -1) continue
2027
+ const edge1 = v1.subtract(v0), edge2 = v2.subtract(v0), h = rayDirection.cross(edge2), a = edge1.dot(h)
2028
+ if (Math.abs(a) < 0.0001) continue
2029
+ const f = 1 / a, s = rayOrigin.subtract(v0), u = f * s.dot(h)
2030
+ if (u < 0 || u > 1) continue
2031
+ const q = s.cross(edge1), v = f * rayDirection.dot(q)
2032
+ if (v < 0 || u + v > 1) continue
2033
+ const t = f * edge2.dot(q)
2034
+ if (t <= 0.0001 || t >= maxDistance) continue
2139
2035
  const triangleNormal = edge1.cross(edge2).normalize()
2140
- const isFrontFace = triangleNormal.dot(rayDirection) < 0
2141
-
2142
- if (isFrontFace) {
2143
- if (!closestHit || t < closestHit.distance) {
2144
- closestHit = {
2145
- materialName: materials[triangleMaterialIndex].name,
2146
- distance: t,
2147
- }
2148
- }
2036
+ if (triangleNormal.dot(rayDirection) >= 0) continue
2037
+ if (!closest || t < closest.distance) {
2038
+ closest = { modelName: inst.name, materialName: materials[triangleMaterialIndex].name, distance: t }
2149
2039
  }
2150
2040
  }
2151
- }
2041
+ })
2152
2042
 
2153
- // Call the callback with the result
2154
2043
  if (this.onRaycast) {
2155
- this.onRaycast(closestHit?.materialName || null, screenX, screenY)
2044
+ const hit = closest as { modelName: string; materialName: string; distance: number } | null
2045
+ this.onRaycast(hit?.modelName ?? "", hit?.materialName ?? null, screenX, screenY)
2156
2046
  }
2157
2047
  }
2158
2048
 
2159
- // Render strategy: 1) Opaque non-eye/hair 2) Eyes (stencil=1) 3) Hair (depth pre-pass + split by stencil) 4) Transparent
2160
2049
  public render() {
2161
- if (this.multisampleTexture && this.camera && this.device) {
2162
- const currentTime = performance.now()
2163
- const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
2164
- this.lastFrameTime = currentTime
2165
-
2166
- this.updateCameraUniforms()
2167
- this.updateRenderTarget()
2168
-
2169
- // Update model (handles tweens, animation, physics, IK, and skin matrices)
2170
- if (this.currentModel) {
2171
- const verticesChanged = this.currentModel.update(deltaTime)
2172
- if (verticesChanged) {
2173
- this.vertexBufferNeedsUpdate = true
2174
- }
2175
- }
2050
+ if (!this.multisampleTexture || !this.camera || !this.device) return
2176
2051
 
2177
- // Update vertex buffer if morphs changed
2178
- if (this.vertexBufferNeedsUpdate) {
2179
- this.updateVertexBuffer()
2180
- this.vertexBufferNeedsUpdate = false
2181
- }
2182
-
2183
- // Update skin matrices buffer
2184
- this.updateSkinMatrices()
2052
+ const currentTime = performance.now()
2053
+ const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
2054
+ this.lastFrameTime = currentTime
2185
2055
 
2186
- // Use single encoder for render
2187
- const encoder = this.device.createCommandEncoder()
2056
+ this.updateCameraUniforms()
2057
+ this.updateRenderTarget()
2188
2058
 
2189
- const pass = encoder.beginRenderPass(this.renderPassDescriptor)
2059
+ const hasModels = this.modelInstances.size > 0
2060
+ if (hasModels) {
2061
+ this.updateInstances(deltaTime)
2062
+ this.updateSkinMatrices()
2063
+ }
2064
+ if (this.groundMode === "shadow") this.updateShadowLightVP()
2190
2065
 
2191
- if (this.currentModel) {
2192
- pass.setVertexBuffer(0, this.vertexBuffer)
2193
- pass.setVertexBuffer(1, this.jointsBuffer)
2194
- pass.setVertexBuffer(2, this.weightsBuffer)
2195
- pass.setIndexBuffer(this.indexBuffer!, "uint32")
2066
+ const encoder = this.device.createCommandEncoder()
2067
+ if (hasModels && this.groundMode === "shadow" && this.shadowMapDepthView) {
2068
+ const sp = encoder.beginRenderPass({
2069
+ colorAttachments: [],
2070
+ depthStencilAttachment: {
2071
+ view: this.shadowMapDepthView,
2072
+ depthClearValue: 1.0,
2073
+ depthLoadOp: "clear",
2074
+ depthStoreOp: "store",
2075
+ },
2076
+ })
2077
+ sp.setPipeline(this.shadowDepthPipeline)
2078
+ this.forEachInstance((inst) => this.drawInstanceShadow(sp, inst))
2079
+ sp.end()
2080
+ }
2196
2081
 
2197
- // Pass 1: Opaque
2198
- pass.setPipeline(this.modelPipeline)
2199
- for (const draw of this.drawCalls) {
2200
- if (draw.type === "opaque" && this.shouldRenderDrawCall(draw)) {
2201
- pass.setBindGroup(0, draw.bindGroup)
2202
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2203
- }
2204
- }
2082
+ const pass = encoder.beginRenderPass(this.renderPassDescriptor)
2083
+ if (hasModels) this.forEachInstance((inst) => this.renderOneModel(pass, inst, false))
2084
+ if (this.groundHasReflections) this.renderGround(pass)
2205
2085
 
2206
- // Pass 2: Eyes (writes stencil value for hair to test against)
2207
- this.renderEyes(pass)
2086
+ pass.end()
2087
+ this.device.queue.submit([encoder.finish()])
2088
+ this.updateStats(performance.now() - currentTime)
2089
+ }
2208
2090
 
2209
- this.drawOutlines(pass, false)
2091
+ private drawInstanceShadow(sp: GPURenderPassEncoder, inst: ModelInstance): void {
2092
+ sp.setBindGroup(0, inst.shadowBindGroup)
2093
+ sp.setVertexBuffer(0, inst.vertexBuffer)
2094
+ sp.setVertexBuffer(1, inst.jointsBuffer)
2095
+ sp.setVertexBuffer(2, inst.weightsBuffer)
2096
+ sp.setIndexBuffer(inst.indexBuffer, "uint32")
2097
+ for (const draw of inst.shadowDrawCalls) {
2098
+ if (this.shouldRenderDrawCall(inst, draw)) sp.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2099
+ }
2100
+ }
2210
2101
 
2211
- // Pass 3: Hair rendering (depth pre-pass + shading + outlines)
2212
- this.renderHair(pass)
2102
+ private renderOneModel(pass: GPURenderPassEncoder, inst: ModelInstance, useReflection: boolean, mirrorMatrix?: Mat4): void {
2103
+ pass.setVertexBuffer(0, inst.vertexBuffer)
2104
+ pass.setVertexBuffer(1, inst.jointsBuffer)
2105
+ pass.setVertexBuffer(2, inst.weightsBuffer)
2106
+ pass.setIndexBuffer(inst.indexBuffer, "uint32")
2213
2107
 
2214
- // Pass 5: Transparent
2215
- pass.setPipeline(this.modelPipeline)
2216
- for (const draw of this.drawCalls) {
2217
- if (draw.type === "transparent" && this.shouldRenderDrawCall(draw)) {
2218
- pass.setBindGroup(0, draw.bindGroup)
2219
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2220
- }
2108
+ if (useReflection && mirrorMatrix) {
2109
+ this.writeMirrorTransformedSkinMatrices(inst, mirrorMatrix)
2110
+ pass.setPipeline(this.reflectionPipeline)
2111
+ for (const draw of inst.drawCalls) {
2112
+ if (draw.type === "opaque" && this.shouldRenderDrawCall(inst, draw)) {
2113
+ pass.setBindGroup(0, draw.bindGroup)
2114
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2221
2115
  }
2222
-
2223
- this.drawOutlines(pass, true)
2224
2116
  }
2225
-
2226
- // Pass 4: Ground (with reflections)
2227
- if (this.groundHasReflections) {
2228
- this.renderGround(pass)
2117
+ this.renderEyes(pass, inst, true)
2118
+ this.renderHair(pass, inst, true)
2119
+ for (const draw of inst.drawCalls) {
2120
+ if (draw.type === "transparent" && this.shouldRenderDrawCall(inst, draw)) {
2121
+ pass.setBindGroup(0, draw.bindGroup)
2122
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2123
+ }
2229
2124
  }
2125
+ this.drawOutlines(pass, inst, true, true)
2126
+ return
2127
+ }
2230
2128
 
2231
- pass.end()
2232
- this.device.queue.submit([encoder.finish()])
2233
-
2234
- this.updateStats(performance.now() - currentTime)
2129
+ pass.setPipeline(this.modelPipeline)
2130
+ for (const draw of inst.drawCalls) {
2131
+ if (draw.type === "opaque" && this.shouldRenderDrawCall(inst, draw)) {
2132
+ pass.setBindGroup(0, draw.bindGroup)
2133
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2134
+ }
2235
2135
  }
2136
+ this.renderEyes(pass, inst, false)
2137
+ this.drawOutlines(pass, inst, false)
2138
+ this.renderHair(pass, inst, false)
2139
+ pass.setPipeline(this.modelPipeline)
2140
+ for (const draw of inst.drawCalls) {
2141
+ if (draw.type === "transparent" && this.shouldRenderDrawCall(inst, draw)) {
2142
+ pass.setBindGroup(0, draw.bindGroup)
2143
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2144
+ }
2145
+ }
2146
+ this.drawOutlines(pass, inst, true)
2236
2147
  }
2237
2148
 
2238
2149
 
@@ -2259,31 +2170,24 @@ export class Engine {
2259
2170
  }
2260
2171
 
2261
2172
  private updateSkinMatrices() {
2262
- if (!this.currentModel || !this.skinMatrixBuffer) return
2263
-
2264
- const skinMatrices = this.currentModel.getSkinMatrices()
2265
- this.device.queue.writeBuffer(
2266
- this.skinMatrixBuffer,
2267
- 0,
2268
- skinMatrices.buffer,
2269
- skinMatrices.byteOffset,
2270
- skinMatrices.byteLength
2271
- )
2272
-
2273
- // Increment version to invalidate cached skinned vertices
2274
- this.skinMatricesVersion++
2173
+ this.forEachInstance((inst) => {
2174
+ const skinMatrices = inst.model.getSkinMatrices()
2175
+ this.device.queue.writeBuffer(
2176
+ inst.skinMatrixBuffer,
2177
+ 0,
2178
+ skinMatrices.buffer,
2179
+ skinMatrices.byteOffset,
2180
+ skinMatrices.byteLength
2181
+ )
2182
+ })
2275
2183
  }
2276
2184
 
2277
- private drawOutlines(pass: GPURenderPassEncoder, transparent: boolean, useReflectionPipeline: boolean = false) {
2278
- if (useReflectionPipeline) {
2279
- // Skip outlines for reflections - not critical for the effect
2280
- return
2281
- }
2282
-
2185
+ private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, transparent: boolean, useReflectionPipeline = false) {
2186
+ if (useReflectionPipeline) return
2283
2187
  pass.setPipeline(this.outlinePipeline)
2284
2188
  const outlineType: DrawCallType = transparent ? "transparent-outline" : "opaque-outline"
2285
- for (const draw of this.drawCalls) {
2286
- if (draw.type === outlineType && this.shouldRenderDrawCall(draw)) {
2189
+ for (const draw of inst.drawCalls) {
2190
+ if (draw.type === outlineType && this.shouldRenderDrawCall(inst, draw)) {
2287
2191
  pass.setBindGroup(0, draw.bindGroup)
2288
2192
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2289
2193
  }
@@ -2341,24 +2245,16 @@ export class Engine {
2341
2245
  )
2342
2246
  }
2343
2247
 
2344
- private writeMirrorTransformedSkinMatrices(mirrorMatrix: Mat4) {
2345
- if (!this.currentModel || !this.skinMatrixBuffer) return
2346
-
2347
- const originalMatrices = this.currentModel.getSkinMatrices()
2248
+ private writeMirrorTransformedSkinMatrices(inst: ModelInstance, mirrorMatrix: Mat4) {
2249
+ const originalMatrices = inst.model.getSkinMatrices()
2348
2250
  const transformedMatrices = new Float32Array(originalMatrices.length)
2349
-
2350
2251
  for (let i = 0; i < originalMatrices.length; i += 16) {
2351
2252
  const boneMatrixValues = new Float32Array(16)
2352
- for (let j = 0; j < 16; j++) {
2353
- boneMatrixValues[j] = originalMatrices[i + j]
2354
- }
2253
+ for (let j = 0; j < 16; j++) boneMatrixValues[j] = originalMatrices[i + j]
2355
2254
  const boneMatrix = new Mat4(boneMatrixValues)
2356
2255
  const transformed = mirrorMatrix.multiply(boneMatrix)
2357
- for (let j = 0; j < 16; j++) {
2358
- transformedMatrices[i + j] = transformed.values[j]
2359
- }
2256
+ for (let j = 0; j < 16; j++) transformedMatrices[i + j] = transformed.values[j]
2360
2257
  }
2361
-
2362
- this.device.queue.writeBuffer(this.skinMatrixBuffer, 0, transformedMatrices)
2258
+ this.device.queue.writeBuffer(inst.skinMatrixBuffer, 0, transformedMatrices)
2363
2259
  }
2364
2260
  }