reze-engine 0.8.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,8 +1,9 @@
1
1
  import { Camera } from "./camera"
2
2
  import { Mat4, Quat, Vec3 } from "./math"
3
3
  import { Model } from "./model"
4
+ import { Physics } from "./physics"
4
5
 
5
- 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
6
7
 
7
8
  export type EngineOptions = {
8
9
  ambientColor?: Vec3
@@ -13,9 +14,6 @@ export type EngineOptions = {
13
14
  cameraTarget?: Vec3
14
15
  cameraFov?: number
15
16
  onRaycast?: RaycastCallback
16
- disableIK?: boolean
17
- disablePhysics?: boolean
18
- // multisampleCount: 1 | 4 (default 4)
19
17
  multisampleCount?: 1 | 4
20
18
  }
21
19
 
@@ -30,8 +28,6 @@ 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,
35
31
  multisampleCount: 4,
36
32
  }
37
33
 
@@ -60,6 +56,23 @@ interface DrawCall {
60
56
  materialName: string
61
57
  }
62
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
+
63
76
  export class Engine {
64
77
  private static instance: Engine | null = null
65
78
 
@@ -83,8 +96,6 @@ export class Engine {
83
96
  private lightUniformBuffer!: GPUBuffer
84
97
  private lightData = new Float32Array(64)
85
98
  private lightCount = 0
86
- private vertexBuffer!: GPUBuffer
87
- private indexBuffer?: GPUBuffer
88
99
  private resizeObserver: ResizeObserver | null = null
89
100
  private depthTexture!: GPUTexture
90
101
  // Material rendering pipelines
@@ -102,10 +113,6 @@ export class Engine {
102
113
  private hairOutlinePipeline!: GPURenderPipeline
103
114
  private mainBindGroupLayout!: GPUBindGroupLayout
104
115
  private outlineBindGroupLayout!: GPUBindGroupLayout
105
- private jointsBuffer!: GPUBuffer
106
- private weightsBuffer!: GPUBuffer
107
- private skinMatrixBuffer?: GPUBuffer
108
- private inverseBindMatrixBuffer?: GPUBuffer
109
116
  private multisampleTexture!: GPUTexture
110
117
  private sampleCount: 1 | 4 = 4
111
118
  private renderPassDescriptor!: GPURenderPassDescriptor
@@ -132,7 +139,6 @@ export class Engine {
132
139
  private shadowMapTexture?: GPUTexture
133
140
  private shadowMapDepthView?: GPUTextureView
134
141
  private shadowDepthPipeline!: GPURenderPipeline
135
- private shadowBindGroup?: GPUBindGroup
136
142
  private shadowLightVPBuffer!: GPUBuffer
137
143
  private shadowLightVPMatrix = new Float32Array(16)
138
144
  private groundShadowPipeline!: GPURenderPipeline
@@ -140,33 +146,21 @@ export class Engine {
140
146
  private groundShadowBindGroup?: GPUBindGroup
141
147
  private shadowComparisonSampler!: GPUSampler
142
148
  private groundShadowMaterialBuffer?: GPUBuffer
143
- private shadowDrawCalls: DrawCall[] = []
149
+ private groundDrawCall: DrawCall | null = null
144
150
  private shadowVPLightX = Number.NaN
145
151
  private shadowVPLightY = Number.NaN
146
152
  private shadowVPLightZ = Number.NaN
147
153
 
148
- // Raycasting
149
154
  private onRaycast?: RaycastCallback
150
- private cachedSkinnedVertices?: Float32Array
151
- private cachedSkinMatricesVersion = -1
152
- private skinMatricesVersion = 0
153
155
  // Double-tap detection
154
156
  private lastTouchTime = 0
155
157
  private readonly DOUBLE_TAP_DELAY = 300 // ms
156
158
 
157
- // IK and Physics flags
158
- private _disableIK = false
159
- private _disablePhysics = false
160
-
161
- private currentModel: Model | null = null
162
- private modelDir: string = ""
159
+ private modelInstances = new Map<string, ModelInstance>()
163
160
  private materialSampler!: GPUSampler
164
161
  private textureCache = new Map<string, GPUTexture>()
165
- private vertexBufferNeedsUpdate = false
166
- // Unified draw call list
167
- private drawCalls: DrawCall[] = []
168
- // Material visibility tracking
169
- 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
170
164
 
171
165
  private lastFpsUpdate = performance.now()
172
166
  private framesSinceLastUpdate = 0
@@ -193,8 +187,6 @@ export class Engine {
193
187
  this.cameraTarget = options.cameraTarget ?? DEFAULT_ENGINE_OPTIONS.cameraTarget
194
188
  this.cameraFov = options.cameraFov ?? DEFAULT_ENGINE_OPTIONS.cameraFov
195
189
  this.onRaycast = options.onRaycast
196
- this._disableIK = options.disableIK ?? DEFAULT_ENGINE_OPTIONS.disableIK
197
- this._disablePhysics = options.disablePhysics ?? DEFAULT_ENGINE_OPTIONS.disablePhysics
198
190
  }
199
191
  }
200
192
 
@@ -1127,7 +1119,6 @@ export class Engine {
1127
1119
  ...options,
1128
1120
  }
1129
1121
  this.groundMode = opts.mode
1130
- this.drawCalls = this.drawCalls.filter((d) => d.type !== "ground")
1131
1122
  this.createGroundGeometry(opts.width, opts.height)
1132
1123
  if (opts.mode === "reflection") {
1133
1124
  this.createGroundMaterialBuffer(opts.diffuseColor, opts.reflectionLevel, opts.fadeStart, opts.fadeEnd)
@@ -1136,13 +1127,13 @@ export class Engine {
1136
1127
  this.createShadowGroundResources(opts.shadowMapSize, opts.diffuseColor, opts.fadeStart, opts.fadeEnd, opts.shadowStrength)
1137
1128
  }
1138
1129
  this.groundHasReflections = true
1139
- this.drawCalls.push({
1130
+ this.groundDrawCall = {
1140
1131
  type: "ground",
1141
1132
  count: 6,
1142
1133
  firstIndex: 0,
1143
1134
  bindGroup: (opts.mode === "reflection" ? this.groundReflectionBindGroup : this.groundShadowBindGroup)!,
1144
1135
  materialName: "Ground",
1145
- })
1136
+ }
1146
1137
  }
1147
1138
 
1148
1139
  private updateLightBuffer() {
@@ -1179,7 +1170,7 @@ export class Engine {
1179
1170
 
1180
1171
  public dispose() {
1181
1172
  this.stopRenderLoop()
1182
- this.currentModel?.stopAnimation()
1173
+ this.forEachInstance((inst) => inst.model.stopAnimation())
1183
1174
  if (Engine.instance === this) Engine.instance = null
1184
1175
  if (this.camera) this.camera.detachControl()
1185
1176
 
@@ -1195,150 +1186,201 @@ export class Engine {
1195
1186
  }
1196
1187
  }
1197
1188
 
1198
- // Single active model; prefer Model.loadPmx() so load + register stay paired
1199
- public async registerModel(model: Model, pmxPath: string): Promise<void> {
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
+ }
1200
1196
  const pathParts = pmxPath.split("/")
1201
1197
  pathParts.pop()
1202
- this.modelDir = pathParts.join("/") + "/"
1203
- this.cachedSkinnedVertices = undefined
1204
- this.cachedSkinMatricesVersion = -1
1205
- await this.setupModelBuffers(model)
1198
+ const basePath = pathParts.join("/") + "/"
1199
+ await this.setupModelInstance(key, model, basePath)
1200
+ return key
1206
1201
  }
1207
1202
 
1208
- // After morph/vertex edits, queues GPU vertex upload on next frame
1209
- public markVertexBufferDirty(): void {
1210
- this.vertexBufferNeedsUpdate = true
1203
+ public async registerModel(model: Model, pmxPath: string): Promise<string> {
1204
+ return this.addModel(model, pmxPath)
1211
1205
  }
1212
1206
 
1213
- public setMaterialVisible(name: string, visible: boolean): void {
1214
- if (visible) {
1215
- this.hiddenMaterials.delete(name)
1216
- } else {
1217
- this.hiddenMaterials.add(name)
1218
- }
1207
+ public removeModel(name: string): void {
1208
+ this.modelInstances.delete(name)
1219
1209
  }
1220
1210
 
1221
- public toggleMaterialVisible(name: string): void {
1222
- if (this.hiddenMaterials.has(name)) {
1223
- this.hiddenMaterials.delete(name)
1224
- } else {
1225
- this.hiddenMaterials.add(name)
1211
+ public getModelNames(): string[] {
1212
+ return Array.from(this.modelInstances.keys())
1213
+ }
1214
+
1215
+ public getModel(name: string): Model | null {
1216
+ return this.modelInstances.get(name)?.model ?? null
1217
+ }
1218
+
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
1225
+ }
1226
+ for (const inst of this.modelInstances.values()) {
1227
+ if (inst.model === modelNameOrModel) {
1228
+ inst.vertexBufferNeedsUpdate = true
1229
+ return
1230
+ }
1226
1231
  }
1227
1232
  }
1228
1233
 
1229
- public isMaterialVisible(name: string): boolean {
1230
- return !this.hiddenMaterials.has(name)
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)
1231
1239
  }
1232
1240
 
1233
- // IK control
1234
- public get disableIK(): boolean {
1235
- return this._disableIK
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)
1236
1246
  }
1237
1247
 
1238
- public set disableIK(value: boolean) {
1239
- this._disableIK = value
1240
- this.currentModel?.setIKEnabled(!value)
1248
+ public isMaterialVisible(modelName: string, materialName: string): boolean {
1249
+ const inst = this.modelInstances.get(modelName)
1250
+ return inst ? !inst.hiddenMaterials.has(materialName) : false
1241
1251
  }
1242
1252
 
1243
- // Physics control
1244
- public get disablePhysics(): boolean {
1245
- return this._disablePhysics
1253
+ public setModelIKEnabled(modelName: string, enabled: boolean): void {
1254
+ this.modelInstances.get(modelName)?.model.setIKEnabled(enabled)
1246
1255
  }
1247
1256
 
1248
- public set disablePhysics(value: boolean) {
1249
- this._disablePhysics = value
1250
- this.currentModel?.setPhysicsEnabled(!value)
1257
+ public setModelPhysicsEnabled(modelName: string, enabled: boolean): void {
1258
+ this.modelInstances.get(modelName)?.model.setPhysicsEnabled(enabled)
1251
1259
  }
1252
1260
 
1253
- private updateVertexBuffer(): void {
1254
- if (!this.currentModel || !this.vertexBuffer) return
1255
- const vertices = this.currentModel.getVertices()
1256
- if (!vertices || vertices.length === 0) return
1257
- this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices)
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
+ })
1258
1267
  }
1259
1268
 
1260
- // Step 7: Create vertex, index, and joint buffers
1261
- private async setupModelBuffers(model: Model) {
1262
- this.currentModel = model
1269
+ private instances(): IterableIterator<ModelInstance> {
1270
+ return this.modelInstances.values()
1271
+ }
1272
+
1273
+ private forEachInstance(fn: (inst: ModelInstance) => void): void {
1274
+ for (const inst of this.instances()) fn(inst)
1275
+ }
1263
1276
 
1264
- // Apply IK and Physics flags from engine options
1265
- model.setIKEnabled(!this._disableIK)
1266
- model.setPhysicsEnabled(!this._disablePhysics)
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
+ })
1290
+ }
1291
+
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
1297
+ }
1267
1298
 
1299
+ private async setupModelInstance(name: string, model: Model, basePath: string): Promise<void> {
1268
1300
  const vertices = model.getVertices()
1269
1301
  const skinning = model.getSkinning()
1270
1302
  const skeleton = model.getSkeleton()
1303
+ const boneCount = skeleton.bones.length
1304
+ const matrixSize = boneCount * 16 * 4
1271
1305
 
1272
- this.vertexBuffer = this.device.createBuffer({
1273
- label: "model vertex buffer",
1306
+ const vertexBuffer = this.device.createBuffer({
1307
+ label: `${name}: vertex buffer`,
1274
1308
  size: vertices.byteLength,
1275
1309
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1276
1310
  })
1277
- this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices)
1311
+ this.device.queue.writeBuffer(vertexBuffer, 0, vertices)
1278
1312
 
1279
- this.jointsBuffer = this.device.createBuffer({
1280
- label: "joints buffer",
1313
+ const jointsBuffer = this.device.createBuffer({
1314
+ label: `${name}: joints buffer`,
1281
1315
  size: skinning.joints.byteLength,
1282
1316
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1283
1317
  })
1284
1318
  this.device.queue.writeBuffer(
1285
- this.jointsBuffer,
1319
+ jointsBuffer,
1286
1320
  0,
1287
1321
  skinning.joints.buffer,
1288
1322
  skinning.joints.byteOffset,
1289
1323
  skinning.joints.byteLength
1290
1324
  )
1291
1325
 
1292
- this.weightsBuffer = this.device.createBuffer({
1293
- label: "weights buffer",
1326
+ const weightsBuffer = this.device.createBuffer({
1327
+ label: `${name}: weights buffer`,
1294
1328
  size: skinning.weights.byteLength,
1295
1329
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1296
1330
  })
1297
1331
  this.device.queue.writeBuffer(
1298
- this.weightsBuffer,
1332
+ weightsBuffer,
1299
1333
  0,
1300
1334
  skinning.weights.buffer,
1301
1335
  skinning.weights.byteOffset,
1302
1336
  skinning.weights.byteLength
1303
1337
  )
1304
1338
 
1305
- const boneCount = skeleton.bones.length
1306
- const matrixSize = boneCount * 16 * 4
1307
-
1308
- this.skinMatrixBuffer = this.device.createBuffer({
1309
- label: "skin matrices",
1339
+ const skinMatrixBuffer = this.device.createBuffer({
1340
+ label: `${name}: skin matrices`,
1310
1341
  size: Math.max(256, matrixSize),
1311
1342
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1312
1343
  })
1313
1344
 
1314
- this.inverseBindMatrixBuffer = this.device.createBuffer({
1315
- label: "inverse bind matrices",
1316
- size: Math.max(256, matrixSize),
1317
- 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,
1318
1351
  })
1352
+ this.device.queue.writeBuffer(indexBuffer, 0, indices)
1319
1353
 
1320
- const invBindMatrices = skeleton.inverseBindMatrices
1321
- this.device.queue.writeBuffer(
1322
- this.inverseBindMatrixBuffer,
1323
- 0,
1324
- invBindMatrices.buffer,
1325
- invBindMatrices.byteOffset,
1326
- invBindMatrices.byteLength
1327
- )
1354
+ const rbs = model.getRigidbodies()
1355
+ const physics = rbs.length > 0 ? new Physics(rbs, model.getJoints()) : null
1328
1356
 
1329
- const indices = model.getIndices()
1330
- if (indices) {
1331
- this.indexBuffer = this.device.createBuffer({
1332
- label: "model index buffer",
1333
- size: indices.byteLength,
1334
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
1335
- })
1336
- this.device.queue.writeBuffer(this.indexBuffer, 0, indices)
1337
- } else {
1338
- throw new Error("Model has no index buffer")
1339
- }
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
+ })
1340
1365
 
1341
- 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)
1342
1384
  }
1343
1385
 
1344
1386
  private createGroundGeometry(width: number = 100, height: number = 100) {
@@ -1490,14 +1532,6 @@ export class Engine {
1490
1532
  gb[7] = 0
1491
1533
  this.groundShadowMaterialBuffer = this.device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST })
1492
1534
  this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb)
1493
- this.shadowBindGroup = this.device.createBindGroup({
1494
- label: "shadow bind",
1495
- layout: this.shadowDepthPipeline.getBindGroupLayout(0),
1496
- entries: [
1497
- { binding: 0, resource: { buffer: this.shadowLightVPBuffer } },
1498
- { binding: 1, resource: { buffer: this.skinMatrixBuffer! } },
1499
- ],
1500
- })
1501
1535
  this.groundShadowBindGroup = this.device.createBindGroup({
1502
1536
  label: "ground shadow bind",
1503
1537
  layout: this.groundShadowBindGroupLayout,
@@ -1535,27 +1569,20 @@ export class Engine {
1535
1569
  this.device.queue.writeBuffer(this.shadowLightVPBuffer, 0, this.shadowLightVPMatrix)
1536
1570
  }
1537
1571
 
1538
- private async setupMaterials(model: Model) {
1572
+ private async setupMaterialsForInstance(inst: ModelInstance): Promise<void> {
1573
+ const model = inst.model
1539
1574
  const materials = model.getMaterials()
1540
- if (materials.length === 0) {
1541
- throw new Error("Model has no materials")
1542
- }
1543
-
1575
+ if (materials.length === 0) throw new Error("Model has no materials")
1544
1576
  const textures = model.getTextures()
1577
+ const prefix = `${inst.name}: `
1545
1578
 
1546
1579
  const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
1547
- if (texIndex < 0 || texIndex >= textures.length) {
1548
- return null
1549
- }
1550
-
1551
- const path = this.modelDir + textures[texIndex].path
1552
- const texture = await this.createTextureFromPath(path)
1553
- return texture
1580
+ if (texIndex < 0 || texIndex >= textures.length) return null
1581
+ const path = inst.basePath + textures[texIndex].path
1582
+ return this.createTextureFromPath(path)
1554
1583
  }
1555
1584
 
1556
- this.drawCalls = []
1557
1585
  let currentIndexOffset = 0
1558
-
1559
1586
  for (const mat of materials) {
1560
1587
  const indexCount = mat.vertexCount
1561
1588
  if (indexCount === 0) continue
@@ -1567,7 +1594,7 @@ export class Engine {
1567
1594
  const isTransparent = materialAlpha < 1.0 - 0.001
1568
1595
 
1569
1596
  const materialUniformBuffer = this.createMaterialUniformBuffer(
1570
- mat.name,
1597
+ prefix + mat.name,
1571
1598
  materialAlpha,
1572
1599
  0.0,
1573
1600
  [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]],
@@ -1576,34 +1603,26 @@ export class Engine {
1576
1603
  mat.shininess
1577
1604
  )
1578
1605
 
1579
- // Create bind groups using the shared bind group layout - All pipelines (main, eye, hair multiply, hair opaque) use the same shader and layout
1580
1606
  const bindGroup = this.device.createBindGroup({
1581
- label: `material bind group: ${mat.name}`,
1607
+ label: `${prefix}material: ${mat.name}`,
1582
1608
  layout: this.mainBindGroupLayout,
1583
1609
  entries: [
1584
1610
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1585
1611
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1586
1612
  { binding: 2, resource: diffuseTexture.createView() },
1587
1613
  { binding: 3, resource: this.materialSampler },
1588
- { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
1614
+ { binding: 4, resource: { buffer: inst.skinMatrixBuffer } },
1589
1615
  { binding: 5, resource: { buffer: materialUniformBuffer } },
1590
1616
  ],
1591
1617
  })
1592
1618
 
1593
1619
  if (indexCount > 0) {
1594
1620
  if (mat.isEye) {
1595
- this.drawCalls.push({
1596
- type: "eye",
1597
- count: indexCount,
1598
- firstIndex: currentIndexOffset,
1599
- bindGroup,
1600
- materialName: mat.name,
1601
- })
1621
+ inst.drawCalls.push({ type: "eye", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
1602
1622
  } else if (mat.isHair) {
1603
- // Hair materials: create separate bind groups for over-eyes vs over-non-eyes
1604
1623
  const createHairBindGroup = (isOverEyes: boolean) => {
1605
- const buffer = this.createMaterialUniformBuffer(
1606
- `${mat.name} (${isOverEyes ? "over eyes" : "over non-eyes"})`,
1624
+ const buf = this.createMaterialUniformBuffer(
1625
+ `${prefix}${mat.name} (${isOverEyes ? "over eyes" : "over non-eyes"})`,
1607
1626
  materialAlpha,
1608
1627
  isOverEyes ? 1.0 : 0.0,
1609
1628
  [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]],
@@ -1611,108 +1630,67 @@ export class Engine {
1611
1630
  mat.specular,
1612
1631
  mat.shininess
1613
1632
  )
1614
-
1615
1633
  return this.device.createBindGroup({
1616
- label: `material bind group (${isOverEyes ? "over eyes" : "over non-eyes"}): ${mat.name}`,
1634
+ label: `${prefix}hair ${isOverEyes ? "over eyes" : "over non-eyes"}: ${mat.name}`,
1617
1635
  layout: this.mainBindGroupLayout,
1618
1636
  entries: [
1619
1637
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1620
1638
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1621
1639
  { binding: 2, resource: diffuseTexture.createView() },
1622
1640
  { binding: 3, resource: this.materialSampler },
1623
- { binding: 4, resource: { buffer: this.skinMatrixBuffer! } },
1624
- { binding: 5, resource: { buffer: buffer } },
1641
+ { binding: 4, resource: { buffer: inst.skinMatrixBuffer } },
1642
+ { binding: 5, resource: { buffer: buf } },
1625
1643
  ],
1626
1644
  })
1627
1645
  }
1628
-
1629
- const bindGroupOverEyes = createHairBindGroup(true)
1630
- const bindGroupOverNonEyes = createHairBindGroup(false)
1631
-
1632
- this.drawCalls.push({
1646
+ inst.drawCalls.push({
1633
1647
  type: "hair-over-eyes",
1634
1648
  count: indexCount,
1635
1649
  firstIndex: currentIndexOffset,
1636
- bindGroup: bindGroupOverEyes,
1650
+ bindGroup: createHairBindGroup(true),
1637
1651
  materialName: mat.name,
1638
1652
  })
1639
- this.drawCalls.push({
1653
+ inst.drawCalls.push({
1640
1654
  type: "hair-over-non-eyes",
1641
1655
  count: indexCount,
1642
1656
  firstIndex: currentIndexOffset,
1643
- bindGroup: bindGroupOverNonEyes,
1657
+ bindGroup: createHairBindGroup(false),
1644
1658
  materialName: mat.name,
1645
1659
  })
1646
1660
  } else if (isTransparent) {
1647
- this.drawCalls.push({
1648
- type: "transparent",
1649
- count: indexCount,
1650
- firstIndex: currentIndexOffset,
1651
- bindGroup,
1652
- materialName: mat.name,
1653
- })
1661
+ inst.drawCalls.push({ type: "transparent", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
1654
1662
  } else {
1655
- this.drawCalls.push({
1656
- type: "opaque",
1657
- count: indexCount,
1658
- firstIndex: currentIndexOffset,
1659
- bindGroup,
1660
- materialName: mat.name,
1661
- })
1663
+ inst.drawCalls.push({ type: "opaque", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
1662
1664
  }
1663
1665
  }
1664
1666
 
1665
- // Edge flag is at bit 4 (0x10) in PMX format
1666
1667
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1667
1668
  const materialUniformData = new Float32Array([
1668
- mat.edgeColor[0],
1669
- mat.edgeColor[1],
1670
- mat.edgeColor[2],
1671
- mat.edgeColor[3],
1672
- mat.edgeSize,
1673
- 0,
1674
- 0,
1675
- 0,
1669
+ mat.edgeColor[0], mat.edgeColor[1], mat.edgeColor[2], mat.edgeColor[3],
1670
+ mat.edgeSize, 0, 0, 0,
1676
1671
  ])
1677
- const materialUniformBuffer = this.createUniformBuffer(
1678
- `outline material uniform: ${mat.name}`,
1679
- materialUniformData
1680
- )
1681
-
1672
+ const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData)
1682
1673
  const outlineBindGroup = this.device.createBindGroup({
1683
- label: `outline bind group: ${mat.name}`,
1674
+ label: `${prefix}outline: ${mat.name}`,
1684
1675
  layout: this.outlineBindGroupLayout,
1685
1676
  entries: [
1686
1677
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1687
- { binding: 1, resource: { buffer: materialUniformBuffer } },
1688
- { binding: 2, resource: { buffer: this.skinMatrixBuffer! } },
1678
+ { binding: 1, resource: { buffer: outlineUniformBuffer } },
1679
+ { binding: 2, resource: { buffer: inst.skinMatrixBuffer } },
1689
1680
  ],
1690
1681
  })
1691
-
1692
1682
  if (indexCount > 0) {
1693
- const outlineType: DrawCallType = mat.isEye
1694
- ? "eye-outline"
1695
- : mat.isHair
1696
- ? "hair-outline"
1697
- : isTransparent
1698
- ? "transparent-outline"
1699
- : "opaque-outline"
1700
- this.drawCalls.push({
1701
- type: outlineType,
1702
- count: indexCount,
1703
- firstIndex: currentIndexOffset,
1704
- bindGroup: outlineBindGroup,
1705
- materialName: mat.name,
1706
- })
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 })
1707
1685
  }
1708
1686
  }
1709
1687
 
1710
1688
  currentIndexOffset += indexCount
1711
1689
  }
1712
- this.shadowDrawCalls.length = 0
1713
- for (const d of this.drawCalls) {
1690
+
1691
+ for (const d of inst.drawCalls) {
1714
1692
  if (d.type === "opaque" || d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes")
1715
- this.shadowDrawCalls.push(d)
1693
+ inst.shadowDrawCalls.push(d)
1716
1694
  }
1717
1695
  }
1718
1696
 
@@ -1761,8 +1739,8 @@ export class Engine {
1761
1739
  return buffer
1762
1740
  }
1763
1741
 
1764
- private shouldRenderDrawCall(drawCall: DrawCall): boolean {
1765
- return !this.hiddenMaterials.has(drawCall.materialName)
1742
+ private shouldRenderDrawCall(inst: ModelInstance, drawCall: DrawCall): boolean {
1743
+ return !inst.hiddenMaterials.has(drawCall.materialName)
1766
1744
  }
1767
1745
 
1768
1746
  private async createTextureFromPath(path: string): Promise<GPUTexture | null> {
@@ -1800,11 +1778,10 @@ export class Engine {
1800
1778
  }
1801
1779
 
1802
1780
  // Helper: Render eyes with stencil writing (for post-alpha-eye effect)
1803
- private renderEyes(pass: GPURenderPassEncoder, useReflectionPipeline: boolean = false) {
1781
+ private renderEyes(pass: GPURenderPassEncoder, inst: ModelInstance, useReflectionPipeline = false) {
1804
1782
  if (useReflectionPipeline) {
1805
- // For reflections, use the basic reflection pipeline instead of specialized eye pipeline
1806
1783
  pass.setPipeline(this.reflectionPipeline)
1807
- for (const draw of this.drawCalls) {
1784
+ for (const draw of inst.drawCalls) {
1808
1785
  if (draw.type === "eye") {
1809
1786
  pass.setBindGroup(0, draw.bindGroup)
1810
1787
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
@@ -1813,8 +1790,8 @@ export class Engine {
1813
1790
  } else {
1814
1791
  pass.setPipeline(this.eyePipeline)
1815
1792
  pass.setStencilReference(this.STENCIL_EYE_VALUE)
1816
- for (const draw of this.drawCalls) {
1817
- if (draw.type === "eye" && this.shouldRenderDrawCall(draw)) {
1793
+ for (const draw of inst.drawCalls) {
1794
+ if (draw.type === "eye" && this.shouldRenderDrawCall(inst, draw)) {
1818
1795
  pass.setBindGroup(0, draw.bindGroup)
1819
1796
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1820
1797
  }
@@ -1823,17 +1800,13 @@ export class Engine {
1823
1800
  }
1824
1801
 
1825
1802
  private renderGround(pass: GPURenderPassEncoder) {
1826
- if (!this.groundHasReflections || !this.groundVertexBuffer || !this.groundIndexBuffer) return
1803
+ if (!this.groundHasReflections || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
1827
1804
  if (this.groundMode === "reflection" && this.groundReflectionTexture) this.renderReflectionTexture()
1828
1805
  pass.setPipeline(this.groundMode === "reflection" ? this.groundPipeline : this.groundShadowPipeline)
1829
1806
  pass.setVertexBuffer(0, this.groundVertexBuffer)
1830
1807
  pass.setIndexBuffer(this.groundIndexBuffer, "uint16")
1831
- for (const draw of this.drawCalls) {
1832
- if (draw.type === "ground" && this.shouldRenderDrawCall(draw)) {
1833
- pass.setBindGroup(0, draw.bindGroup)
1834
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1835
- }
1836
- }
1808
+ pass.setBindGroup(0, this.groundDrawCall.bindGroup)
1809
+ pass.drawIndexed(this.groundDrawCall.count, 1, this.groundDrawCall.firstIndex, 0, 0)
1837
1810
  }
1838
1811
 
1839
1812
  private renderReflectionTexture() {
@@ -1866,55 +1839,16 @@ export class Engine {
1866
1839
  }
1867
1840
 
1868
1841
  const reflectionPass = reflectionEncoder.beginRenderPass(reflectionPassDescriptor)
1869
-
1870
- if (this.currentModel) {
1871
- reflectionPass.setVertexBuffer(0, this.vertexBuffer)
1872
- reflectionPass.setVertexBuffer(1, this.jointsBuffer)
1873
- reflectionPass.setVertexBuffer(2, this.weightsBuffer)
1874
- reflectionPass.setIndexBuffer(this.indexBuffer!, "uint32")
1875
-
1876
- this.writeMirrorTransformedSkinMatrices(mirrorMatrix)
1877
- reflectionPass.setPipeline(this.reflectionPipeline)
1878
- for (const draw of this.drawCalls) {
1879
- if (draw.type === "opaque" && this.shouldRenderDrawCall(draw)) {
1880
- reflectionPass.setBindGroup(0, draw.bindGroup)
1881
- reflectionPass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1882
- }
1883
- }
1884
-
1885
- // Render eyes (using reflection pipeline)
1886
- this.renderEyes(reflectionPass, true)
1887
-
1888
- // Render hair (using reflection pipeline)
1889
- this.renderHair(reflectionPass, true)
1890
-
1891
- // Render transparent objects
1892
- for (const draw of this.drawCalls) {
1893
- if (draw.type === "transparent" && this.shouldRenderDrawCall(draw)) {
1894
- reflectionPass.setBindGroup(0, draw.bindGroup)
1895
- reflectionPass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1896
- }
1897
- }
1898
-
1899
- this.drawOutlines(reflectionPass, true, true)
1900
- }
1901
-
1842
+ this.forEachInstance((inst) => this.renderOneModel(reflectionPass, inst, true, mirrorMatrix))
1902
1843
  reflectionPass.end()
1903
-
1904
- // Submit reflection rendering commands
1905
- const reflectionCommandBuffer = reflectionEncoder.finish()
1906
- this.device.queue.submit([reflectionCommandBuffer])
1907
-
1908
- // Restore original skin matrices
1844
+ this.device.queue.submit([reflectionEncoder.finish()])
1909
1845
  this.updateSkinMatrices()
1910
1846
  }
1911
1847
 
1912
- // Helper: Render hair with post-alpha-eye effect (depth pre-pass + stencil-based shading + outlines)
1913
- private renderHair(pass: GPURenderPassEncoder, useReflectionPipeline: boolean = false) {
1848
+ private renderHair(pass: GPURenderPassEncoder, inst: ModelInstance, useReflectionPipeline = false) {
1914
1849
  if (useReflectionPipeline) {
1915
- // For reflections, use the basic reflection pipeline for all hair
1916
1850
  pass.setPipeline(this.reflectionPipeline)
1917
- for (const draw of this.drawCalls) {
1851
+ for (const draw of inst.drawCalls) {
1918
1852
  if (draw.type === "hair-over-eyes" || draw.type === "hair-over-non-eyes") {
1919
1853
  pass.setBindGroup(0, draw.bindGroup)
1920
1854
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
@@ -1923,22 +1857,20 @@ export class Engine {
1923
1857
  return
1924
1858
  }
1925
1859
 
1926
- // Hair depth pre-pass (reduces overdraw via early depth rejection)
1927
- const hasHair = this.drawCalls.some(
1928
- (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)
1929
1862
  )
1930
1863
  if (hasHair) {
1931
1864
  pass.setPipeline(this.hairDepthPipeline)
1932
- for (const draw of this.drawCalls) {
1933
- 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)) {
1934
1867
  pass.setBindGroup(0, draw.bindGroup)
1935
1868
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1936
1869
  }
1937
1870
  }
1938
1871
  }
1939
1872
 
1940
- // Hair shading (split by stencil for transparency over eyes)
1941
- 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))
1942
1874
  if (hairOverEyes.length > 0) {
1943
1875
  pass.setPipeline(this.hairPipelineOverEyes)
1944
1876
  pass.setStencilReference(this.STENCIL_EYE_VALUE)
@@ -1948,9 +1880,7 @@ export class Engine {
1948
1880
  }
1949
1881
  }
1950
1882
 
1951
- const hairOverNonEyes = this.drawCalls.filter(
1952
- (d) => d.type === "hair-over-non-eyes" && this.shouldRenderDrawCall(d)
1953
- )
1883
+ const hairOverNonEyes = inst.drawCalls.filter((d) => d.type === "hair-over-non-eyes" && this.shouldRenderDrawCall(inst, d))
1954
1884
  if (hairOverNonEyes.length > 0) {
1955
1885
  pass.setPipeline(this.hairPipelineOverNonEyes)
1956
1886
  pass.setStencilReference(this.STENCIL_EYE_VALUE)
@@ -1960,8 +1890,7 @@ export class Engine {
1960
1890
  }
1961
1891
  }
1962
1892
 
1963
- // Hair outlines
1964
- 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))
1965
1894
  if (hairOutlines.length > 0) {
1966
1895
  pass.setPipeline(this.hairOutlinePipeline)
1967
1896
  for (const draw of hairOutlines) {
@@ -1972,17 +1901,13 @@ export class Engine {
1972
1901
  }
1973
1902
 
1974
1903
  private handleCanvasDoubleClick = (event: MouseEvent) => {
1975
- if (!this.onRaycast || !this.currentModel) return
1976
-
1904
+ if (!this.onRaycast || this.modelInstances.size === 0) return
1977
1905
  const rect = this.canvas.getBoundingClientRect()
1978
- const x = event.clientX - rect.left
1979
- const y = event.clientY - rect.top
1980
-
1981
- this.performRaycast(x, y)
1906
+ this.performRaycast(event.clientX - rect.left, event.clientY - rect.top)
1982
1907
  }
1983
1908
 
1984
1909
  private handleCanvasTouch = (event: TouchEvent) => {
1985
- if (!this.onRaycast || !this.currentModel) return
1910
+ if (!this.onRaycast || this.modelInstances.size === 0) return
1986
1911
 
1987
1912
  // Prevent default to avoid triggering mouse events
1988
1913
  event.preventDefault()
@@ -2010,324 +1935,215 @@ export class Engine {
2010
1935
  }
2011
1936
 
2012
1937
  private performRaycast(screenX: number, screenY: number) {
2013
- if (!this.currentModel || !this.onRaycast) return
2014
-
2015
- const materials = this.currentModel.getMaterials()
2016
- if (materials.length === 0) return
1938
+ if (!this.onRaycast || this.modelInstances.size === 0) {
1939
+ this.onRaycast?.("", null, screenX, screenY)
1940
+ return
1941
+ }
2017
1942
 
2018
- // Get camera matrices
2019
1943
  const viewMatrix = this.camera.getViewMatrix()
2020
1944
  const projectionMatrix = this.camera.getProjectionMatrix()
2021
-
2022
- // Convert screen coordinates to world space ray
2023
- const canvas = this.canvas
2024
- const rect = canvas.getBoundingClientRect()
2025
-
2026
- // Convert to clip space (-1 to 1)
1945
+ const rect = this.canvas.getBoundingClientRect()
2027
1946
  const clipX = (screenX / rect.width) * 2 - 1
2028
- const clipY = 1 - (screenY / rect.height) * 2 // Flip Y
2029
-
2030
- // Create ray in clip space at near and far planes
2031
- const clipNear = new Vec3(clipX, clipY, -1) // Near plane
2032
- const clipFar = new Vec3(clipX, clipY, 1) // Far plane
2033
-
2034
- // Transform to world space using inverse view-projection matrix
1947
+ const clipY = 1 - (screenY / rect.height) * 2
2035
1948
  const viewProjMatrix = projectionMatrix.multiply(viewMatrix)
2036
1949
  const inverseViewProj = viewProjMatrix.inverse()
2037
-
2038
- // Transform point through 4x4 matrix with perspective division
2039
1950
  const transformPoint = (matrix: Mat4, point: Vec3): Vec3 => {
2040
1951
  const m = matrix.values
2041
- const x = point.x,
2042
- y = point.y,
2043
- z = point.z
2044
-
2045
- // Compute transformed point (matrix * vec4(point, 1.0))
1952
+ const x = point.x, y = point.y, z = point.z
2046
1953
  const result = new Vec3(
2047
1954
  m[0] * x + m[4] * y + m[8] * z + m[12],
2048
1955
  m[1] * x + m[5] * y + m[9] * z + m[13],
2049
1956
  m[2] * x + m[6] * y + m[10] * z + m[14]
2050
1957
  )
2051
-
2052
- // Perspective division
2053
1958
  const w = m[3] * x + m[7] * y + m[11] * z + m[15]
2054
- const invW = w !== 0 ? 1 / w : 1
2055
-
2056
- return result.scale(invW)
1959
+ return result.scale(w !== 0 ? 1 / w : 1)
2057
1960
  }
2058
-
2059
- const worldNear = transformPoint(inverseViewProj, clipNear)
2060
- const worldFar = transformPoint(inverseViewProj, clipFar)
2061
-
2062
- // 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))
2063
1963
  const rayOrigin = this.camera.getPosition()
2064
1964
  const rayDirection = worldFar.subtract(worldNear).normalize()
2065
1965
 
2066
- // Get model geometry for ray-triangle intersection
2067
- const baseVertices = this.currentModel.getVertices()
2068
- const indices = this.currentModel.getIndices()
2069
- const skinning = this.currentModel.getSkinning()
2070
-
2071
- if (!baseVertices || !indices || !skinning) {
2072
- if (this.onRaycast) {
2073
- this.onRaycast(null, screenX, screenY)
2074
- }
2075
- 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
+ )
2076
1973
  }
2077
1974
 
2078
- // Use cached skinned vertices if available and up-to-date
2079
- let vertices: Float32Array
2080
- if (this.cachedSkinnedVertices && this.cachedSkinMatricesVersion === this.skinMatricesVersion) {
2081
- vertices = this.cachedSkinnedVertices
2082
- } else {
2083
- // Apply current skinning transformations to get animated vertex positions
2084
- vertices = new Float32Array(baseVertices.length)
2085
- const skinMatrices = this.currentModel.getSkinMatrices()
2086
-
2087
- // Helper function to transform point by 4x4 matrix
2088
- const transformByMatrix = (matrix: Float32Array, offset: number, point: Vec3): Vec3 => {
2089
- const m = matrix
2090
- const x = point.x,
2091
- y = point.y,
2092
- z = point.z
2093
- return new Vec3(
2094
- m[offset + 0] * x + m[offset + 4] * y + m[offset + 8] * z + m[offset + 12],
2095
- m[offset + 1] * x + m[offset + 5] * y + m[offset + 9] * z + m[offset + 13],
2096
- m[offset + 2] * x + m[offset + 6] * y + m[offset + 10] * z + m[offset + 14]
2097
- )
2098
- }
1975
+ let closest: { modelName: string; materialName: string; distance: number } | null = null
1976
+ const maxDistance = 1000
2099
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
1986
+
1987
+ const vertices = new Float32Array(baseVertices.length)
1988
+ const skinMatrices = model.getSkinMatrices()
2100
1989
  for (let i = 0; i < baseVertices.length; i += 8) {
2101
- const vertexIndex = Math.floor(i / 8)
1990
+ const vertexIndex = i / 8
2102
1991
  const position = new Vec3(baseVertices[i], baseVertices[i + 1], baseVertices[i + 2])
2103
-
2104
- // Get bone influences for this vertex
2105
- const jointIndices = [
2106
- skinning.joints[vertexIndex * 4],
2107
- skinning.joints[vertexIndex * 4 + 1],
2108
- skinning.joints[vertexIndex * 4 + 2],
2109
- skinning.joints[vertexIndex * 4 + 3],
2110
- ]
2111
-
2112
- const weights = [
2113
- skinning.weights[vertexIndex * 4],
2114
- skinning.weights[vertexIndex * 4 + 1],
2115
- skinning.weights[vertexIndex * 4 + 2],
2116
- skinning.weights[vertexIndex * 4 + 3],
2117
- ]
2118
-
2119
- // Normalize weights (same as shader)
2120
- const weightSum = weights[0] + weights[1] + weights[2] + weights[3]
2121
- const invWeightSum = weightSum > 0.0001 ? 1.0 / weightSum : 1.0
2122
- const normalizedWeights = weightSum > 0.0001 ? weights.map((w) => w * invWeightSum) : [1.0, 0.0, 0.0, 0.0]
2123
-
2124
- // Apply skinning transformation (same as shader)
2125
- let skinnedPosition = new Vec3(0, 0, 0)
2126
-
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)
2127
1997
  for (let j = 0; j < 4; j++) {
2128
- const weight = normalizedWeights[j]
2129
- if (weight > 0) {
2130
- const matrixOffset = jointIndices[j] * 16
2131
- const transformed = transformByMatrix(skinMatrices, matrixOffset, position)
2132
- skinnedPosition = skinnedPosition.add(transformed.scale(weight))
2133
- }
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]))
2134
2001
  }
2135
-
2136
- // Store transformed position, copy other attributes unchanged
2137
- vertices[i] = skinnedPosition.x
2138
- vertices[i + 1] = skinnedPosition.y
2139
- vertices[i + 2] = skinnedPosition.z
2140
- vertices[i + 3] = baseVertices[i + 3] // normal X
2141
- vertices[i + 4] = baseVertices[i + 4] // normal Y
2142
- vertices[i + 5] = baseVertices[i + 5] // normal Z
2143
- vertices[i + 6] = baseVertices[i + 6] // UV X
2144
- 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]
2145
2010
  }
2146
2011
 
2147
- // Cache the result
2148
- this.cachedSkinnedVertices = vertices
2149
- this.cachedSkinMatricesVersion = this.skinMatricesVersion
2150
- }
2151
-
2152
- let closestHit: { materialName: string; distance: number } | null = null
2153
- const maxDistance = 1000 // Reasonable max distance
2154
-
2155
- // Test ray against all triangles (Möller-Trumbore algorithm)
2156
- for (let i = 0; i < indices.length; i += 3) {
2157
- const idx0 = indices[i] * 8 // Each vertex has 8 floats (pos + normal + uv)
2158
- const idx1 = indices[i + 1] * 8
2159
- const idx2 = indices[i + 2] * 8
2160
-
2161
- // Get triangle vertices in world space (first 3 floats are position)
2162
- const v0 = new Vec3(vertices[idx0], vertices[idx0 + 1], vertices[idx0 + 2])
2163
- const v1 = new Vec3(vertices[idx1], vertices[idx1 + 1], vertices[idx1 + 2])
2164
- const v2 = new Vec3(vertices[idx2], vertices[idx2 + 1], vertices[idx2 + 2])
2165
-
2166
- // Find which material this triangle belongs to
2167
- // Each material has mat.vertexCount indices (3 per triangle)
2168
- let triangleMaterialIndex = -1
2169
- let indexOffset = 0
2170
- for (let matIdx = 0; matIdx < materials.length; matIdx++) {
2171
- const mat = materials[matIdx]
2172
- if (i >= indexOffset && i < indexOffset + mat.vertexCount) {
2173
- triangleMaterialIndex = matIdx
2174
- 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
2175
2025
  }
2176
- indexOffset += mat.vertexCount
2177
- }
2178
-
2179
- if (triangleMaterialIndex === -1) continue
2180
-
2181
- // Skip invisible materials
2182
- // const materialName = materials[triangleMaterialIndex].name
2183
- // if (this.hiddenMaterials.has(materialName)) continue
2184
-
2185
- // Ray-triangle intersection test (Möller-Trumbore algorithm)
2186
- const edge1 = v1.subtract(v0)
2187
- const edge2 = v2.subtract(v0)
2188
- const h = rayDirection.cross(edge2)
2189
- const a = edge1.dot(h)
2190
-
2191
- if (Math.abs(a) < 0.0001) continue // Ray is parallel to triangle
2192
-
2193
- const f = 1.0 / a
2194
- const s = rayOrigin.subtract(v0)
2195
- const u = f * s.dot(h)
2196
-
2197
- if (u < 0.0 || u > 1.0) continue
2198
-
2199
- const q = s.cross(edge1)
2200
- const v = f * rayDirection.dot(q)
2201
-
2202
- if (v < 0.0 || u + v > 1.0) continue
2203
-
2204
- // At this point we have a hit
2205
- const t = f * edge2.dot(q)
2206
-
2207
- if (t > 0.0001 && t < maxDistance) {
2208
- // 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
2209
2035
  const triangleNormal = edge1.cross(edge2).normalize()
2210
- const isFrontFace = triangleNormal.dot(rayDirection) < 0
2211
-
2212
- if (isFrontFace) {
2213
- if (!closestHit || t < closestHit.distance) {
2214
- closestHit = {
2215
- materialName: materials[triangleMaterialIndex].name,
2216
- distance: t,
2217
- }
2218
- }
2036
+ if (triangleNormal.dot(rayDirection) >= 0) continue
2037
+ if (!closest || t < closest.distance) {
2038
+ closest = { modelName: inst.name, materialName: materials[triangleMaterialIndex].name, distance: t }
2219
2039
  }
2220
2040
  }
2221
- }
2041
+ })
2222
2042
 
2223
- // Call the callback with the result
2224
2043
  if (this.onRaycast) {
2225
- 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)
2226
2046
  }
2227
2047
  }
2228
2048
 
2229
- // Render strategy: 1) Opaque non-eye/hair 2) Eyes (stencil=1) 3) Hair (depth pre-pass + split by stencil) 4) Transparent
2230
2049
  public render() {
2231
- if (this.multisampleTexture && this.camera && this.device) {
2232
- const currentTime = performance.now()
2233
- const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
2234
- this.lastFrameTime = currentTime
2235
-
2236
- this.updateCameraUniforms()
2237
- this.updateRenderTarget()
2238
-
2239
- // Update model (handles tweens, animation, physics, IK, and skin matrices)
2240
- if (this.currentModel) {
2241
- const verticesChanged = this.currentModel.update(deltaTime)
2242
- if (verticesChanged) {
2243
- this.vertexBufferNeedsUpdate = true
2244
- }
2245
- }
2050
+ if (!this.multisampleTexture || !this.camera || !this.device) return
2246
2051
 
2247
- // Update vertex buffer if morphs changed
2248
- if (this.vertexBufferNeedsUpdate) {
2249
- this.updateVertexBuffer()
2250
- this.vertexBufferNeedsUpdate = false
2251
- }
2052
+ const currentTime = performance.now()
2053
+ const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
2054
+ this.lastFrameTime = currentTime
2252
2055
 
2253
- this.updateSkinMatrices()
2254
- if (this.groundMode === "shadow") this.updateShadowLightVP()
2255
-
2256
- const encoder = this.device.createCommandEncoder()
2257
- if (
2258
- this.groundMode === "shadow" &&
2259
- this.currentModel &&
2260
- this.shadowMapDepthView &&
2261
- this.shadowBindGroup
2262
- ) {
2263
- const sp = encoder.beginRenderPass({
2264
- colorAttachments: [],
2265
- depthStencilAttachment: {
2266
- view: this.shadowMapDepthView,
2267
- depthClearValue: 1.0,
2268
- depthLoadOp: "clear",
2269
- depthStoreOp: "store",
2270
- },
2271
- })
2272
- sp.setPipeline(this.shadowDepthPipeline)
2273
- sp.setBindGroup(0, this.shadowBindGroup)
2274
- sp.setVertexBuffer(0, this.vertexBuffer)
2275
- sp.setVertexBuffer(1, this.jointsBuffer)
2276
- sp.setVertexBuffer(2, this.weightsBuffer)
2277
- sp.setIndexBuffer(this.indexBuffer!, "uint32")
2278
- for (const draw of this.shadowDrawCalls) {
2279
- if (this.shouldRenderDrawCall(draw))
2280
- sp.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2281
- }
2282
- sp.end()
2283
- }
2056
+ this.updateCameraUniforms()
2057
+ this.updateRenderTarget()
2284
2058
 
2285
- 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()
2286
2065
 
2287
- if (this.currentModel) {
2288
- pass.setVertexBuffer(0, this.vertexBuffer)
2289
- pass.setVertexBuffer(1, this.jointsBuffer)
2290
- pass.setVertexBuffer(2, this.weightsBuffer)
2291
- 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
+ }
2292
2081
 
2293
- // Pass 1: Opaque
2294
- pass.setPipeline(this.modelPipeline)
2295
- for (const draw of this.drawCalls) {
2296
- if (draw.type === "opaque" && this.shouldRenderDrawCall(draw)) {
2297
- pass.setBindGroup(0, draw.bindGroup)
2298
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2299
- }
2300
- }
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)
2301
2085
 
2302
- // Pass 2: Eyes (writes stencil value for hair to test against)
2303
- this.renderEyes(pass)
2086
+ pass.end()
2087
+ this.device.queue.submit([encoder.finish()])
2088
+ this.updateStats(performance.now() - currentTime)
2089
+ }
2304
2090
 
2305
- 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
+ }
2306
2101
 
2307
- // Pass 3: Hair rendering (depth pre-pass + shading + outlines)
2308
- 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")
2309
2107
 
2310
- // Pass 5: Transparent
2311
- pass.setPipeline(this.modelPipeline)
2312
- for (const draw of this.drawCalls) {
2313
- if (draw.type === "transparent" && this.shouldRenderDrawCall(draw)) {
2314
- pass.setBindGroup(0, draw.bindGroup)
2315
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2316
- }
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)
2317
2115
  }
2318
-
2319
- this.drawOutlines(pass, true)
2320
2116
  }
2321
-
2322
- if (this.groundHasReflections) {
2323
- 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
+ }
2324
2124
  }
2125
+ this.drawOutlines(pass, inst, true, true)
2126
+ return
2127
+ }
2325
2128
 
2326
- pass.end()
2327
- this.device.queue.submit([encoder.finish()])
2328
-
2329
- 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
+ }
2330
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)
2331
2147
  }
2332
2148
 
2333
2149
 
@@ -2354,31 +2170,24 @@ export class Engine {
2354
2170
  }
2355
2171
 
2356
2172
  private updateSkinMatrices() {
2357
- if (!this.currentModel || !this.skinMatrixBuffer) return
2358
-
2359
- const skinMatrices = this.currentModel.getSkinMatrices()
2360
- this.device.queue.writeBuffer(
2361
- this.skinMatrixBuffer,
2362
- 0,
2363
- skinMatrices.buffer,
2364
- skinMatrices.byteOffset,
2365
- skinMatrices.byteLength
2366
- )
2367
-
2368
- // Increment version to invalidate cached skinned vertices
2369
- 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
+ })
2370
2183
  }
2371
2184
 
2372
- private drawOutlines(pass: GPURenderPassEncoder, transparent: boolean, useReflectionPipeline: boolean = false) {
2373
- if (useReflectionPipeline) {
2374
- // Skip outlines for reflections - not critical for the effect
2375
- return
2376
- }
2377
-
2185
+ private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, transparent: boolean, useReflectionPipeline = false) {
2186
+ if (useReflectionPipeline) return
2378
2187
  pass.setPipeline(this.outlinePipeline)
2379
2188
  const outlineType: DrawCallType = transparent ? "transparent-outline" : "opaque-outline"
2380
- for (const draw of this.drawCalls) {
2381
- if (draw.type === outlineType && this.shouldRenderDrawCall(draw)) {
2189
+ for (const draw of inst.drawCalls) {
2190
+ if (draw.type === outlineType && this.shouldRenderDrawCall(inst, draw)) {
2382
2191
  pass.setBindGroup(0, draw.bindGroup)
2383
2192
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2384
2193
  }
@@ -2436,24 +2245,16 @@ export class Engine {
2436
2245
  )
2437
2246
  }
2438
2247
 
2439
- private writeMirrorTransformedSkinMatrices(mirrorMatrix: Mat4) {
2440
- if (!this.currentModel || !this.skinMatrixBuffer) return
2441
-
2442
- const originalMatrices = this.currentModel.getSkinMatrices()
2248
+ private writeMirrorTransformedSkinMatrices(inst: ModelInstance, mirrorMatrix: Mat4) {
2249
+ const originalMatrices = inst.model.getSkinMatrices()
2443
2250
  const transformedMatrices = new Float32Array(originalMatrices.length)
2444
-
2445
2251
  for (let i = 0; i < originalMatrices.length; i += 16) {
2446
2252
  const boneMatrixValues = new Float32Array(16)
2447
- for (let j = 0; j < 16; j++) {
2448
- boneMatrixValues[j] = originalMatrices[i + j]
2449
- }
2253
+ for (let j = 0; j < 16; j++) boneMatrixValues[j] = originalMatrices[i + j]
2450
2254
  const boneMatrix = new Mat4(boneMatrixValues)
2451
2255
  const transformed = mirrorMatrix.multiply(boneMatrix)
2452
- for (let j = 0; j < 16; j++) {
2453
- transformedMatrices[i + j] = transformed.values[j]
2454
- }
2256
+ for (let j = 0; j < 16; j++) transformedMatrices[i + j] = transformed.values[j]
2455
2257
  }
2456
-
2457
- this.device.queue.writeBuffer(this.skinMatrixBuffer, 0, transformedMatrices)
2258
+ this.device.queue.writeBuffer(inst.skinMatrixBuffer, 0, transformedMatrices)
2458
2259
  }
2459
2260
  }