reze-engine 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/engine.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Camera } from "./camera"
2
- import { Mat4, Quat, Vec3 } from "./math"
2
+ import { Mat4, Vec3 } from "./math"
3
3
  import { Model } from "./model"
4
+ import { PmxLoader } from "./pmx-loader"
4
5
  import { Physics } from "./physics"
5
6
 
6
7
  export type RaycastCallback = (modelName: string, material: string | null, screenX: number, screenY: number) => void
@@ -14,7 +15,6 @@ export type EngineOptions = {
14
15
  cameraTarget?: Vec3
15
16
  cameraFov?: number
16
17
  onRaycast?: RaycastCallback
17
- multisampleCount?: 1 | 4
18
18
  }
19
19
 
20
20
  export type RequiredEngineOptions = Required<Omit<EngineOptions, "onRaycast">> & Pick<EngineOptions, "onRaycast">
@@ -28,7 +28,6 @@ export const DEFAULT_ENGINE_OPTIONS: RequiredEngineOptions = {
28
28
  cameraTarget: new Vec3(0, 12.5, 0),
29
29
  cameraFov: Math.PI / 4,
30
30
  onRaycast: undefined,
31
- multisampleCount: 4,
32
31
  }
33
32
 
34
33
  export interface EngineStats {
@@ -38,14 +37,9 @@ export interface EngineStats {
38
37
 
39
38
  type DrawCallType =
40
39
  | "opaque"
41
- | "eye"
42
- | "hair-over-eyes"
43
- | "hair-over-non-eyes"
44
40
  | "transparent"
45
41
  | "ground"
46
42
  | "opaque-outline"
47
- | "eye-outline"
48
- | "hair-outline"
49
43
  | "transparent-outline"
50
44
 
51
45
  interface DrawCall {
@@ -56,6 +50,12 @@ interface DrawCall {
56
50
  materialName: string
57
51
  }
58
52
 
53
+ interface PickDrawCall {
54
+ count: number
55
+ firstIndex: number
56
+ bindGroup: GPUBindGroup
57
+ }
58
+
59
59
  interface ModelInstance {
60
60
  name: string
61
61
  model: Model
@@ -68,6 +68,9 @@ interface ModelInstance {
68
68
  drawCalls: DrawCall[]
69
69
  shadowDrawCalls: DrawCall[]
70
70
  shadowBindGroup: GPUBindGroup
71
+ mainPerInstanceBindGroup: GPUBindGroup
72
+ pickPerInstanceBindGroup: GPUBindGroup
73
+ pickDrawCalls: PickDrawCall[]
71
74
  hiddenMaterials: Set<string>
72
75
  physics: Physics | null
73
76
  vertexBufferNeedsUpdate: boolean
@@ -78,7 +81,7 @@ export class Engine {
78
81
 
79
82
  public static getInstance(): Engine {
80
83
  if (!Engine.instance) {
81
- throw new Error("Engine not ready: create Engine, await init(), then load models via Model.loadFrom().")
84
+ throw new Error("Engine not ready: create Engine, await init(), then load models via engine.loadModel().")
82
85
  }
83
86
  return Engine.instance
84
87
  }
@@ -98,26 +101,20 @@ export class Engine {
98
101
  private lightCount = 0
99
102
  private resizeObserver: ResizeObserver | null = null
100
103
  private depthTexture!: GPUTexture
101
- // Material rendering pipelines
102
104
  private modelPipeline!: GPURenderPipeline
103
- private eyePipeline!: GPURenderPipeline
104
- private hairPipelineOverEyes!: GPURenderPipeline
105
- private hairPipelineOverNonEyes!: GPURenderPipeline
106
- private hairDepthPipeline!: GPURenderPipeline
107
- // Ground/reflection pipeline
108
- private groundPipeline!: GPURenderPipeline
109
- private groundBindGroupLayout!: GPUBindGroupLayout
110
- private reflectionPipeline!: GPURenderPipeline
111
- // Outline pipelines
105
+ private groundShadowPipeline!: GPURenderPipeline
106
+ private groundShadowBindGroupLayout!: GPUBindGroupLayout
112
107
  private outlinePipeline!: GPURenderPipeline
113
- private hairOutlinePipeline!: GPURenderPipeline
114
- private mainBindGroupLayout!: GPUBindGroupLayout
115
- private outlineBindGroupLayout!: GPUBindGroupLayout
108
+ private mainPerFrameBindGroupLayout!: GPUBindGroupLayout
109
+ private mainPerInstanceBindGroupLayout!: GPUBindGroupLayout
110
+ private mainPerMaterialBindGroupLayout!: GPUBindGroupLayout
111
+ private outlinePerFrameBindGroupLayout!: GPUBindGroupLayout
112
+ private outlinePerMaterialBindGroupLayout!: GPUBindGroupLayout
113
+ private perFrameBindGroup!: GPUBindGroup
114
+ private outlinePerFrameBindGroup!: GPUBindGroup
116
115
  private multisampleTexture!: GPUTexture
117
- private sampleCount: 1 | 4 = 4
116
+ private static readonly MULTISAMPLE_COUNT = 4
118
117
  private renderPassDescriptor!: GPURenderPassDescriptor
119
- // Constants
120
- private readonly STENCIL_EYE_VALUE = 1
121
118
 
122
119
  // Ambient light settings
123
120
  private ambientColor!: Vec3
@@ -126,23 +123,15 @@ export class Engine {
126
123
  // Rim light settings
127
124
  private rimLightIntensity!: number
128
125
 
129
- // Ground/reflection properties
126
+ // Ground properties (shadow only)
130
127
  private groundVertexBuffer?: GPUBuffer
131
128
  private groundIndexBuffer?: GPUBuffer
132
- private groundReflectionTexture?: GPUTexture
133
- private groundReflectionResolveTexture?: GPUTexture // Resolve target for multisampled texture
134
- private groundReflectionDepthTexture?: GPUTexture
135
- private groundReflectionBindGroup?: GPUBindGroup
136
- private groundMaterialUniformBuffer?: GPUBuffer
137
- private groundHasReflections = false
138
- private groundMode: "reflection" | "shadow" = "reflection"
129
+ private hasGround = false
139
130
  private shadowMapTexture?: GPUTexture
140
131
  private shadowMapDepthView?: GPUTextureView
141
132
  private shadowDepthPipeline!: GPURenderPipeline
142
133
  private shadowLightVPBuffer!: GPUBuffer
143
134
  private shadowLightVPMatrix = new Float32Array(16)
144
- private groundShadowPipeline!: GPURenderPipeline
145
- private groundShadowBindGroupLayout!: GPUBindGroupLayout
146
135
  private groundShadowBindGroup?: GPUBindGroup
147
136
  private shadowComparisonSampler!: GPUSampler
148
137
  private groundShadowMaterialBuffer?: GPUBuffer
@@ -152,14 +141,32 @@ export class Engine {
152
141
  private shadowVPLightZ = Number.NaN
153
142
 
154
143
  private onRaycast?: RaycastCallback
155
- // Double-tap detection
156
144
  private lastTouchTime = 0
157
- private readonly DOUBLE_TAP_DELAY = 300 // ms
145
+ private readonly DOUBLE_TAP_DELAY = 300
146
+ // GPU picking
147
+ private pickPipeline!: GPURenderPipeline
148
+ private pickPerFrameBindGroupLayout!: GPUBindGroupLayout
149
+ private pickPerInstanceBindGroupLayout!: GPUBindGroupLayout
150
+ private pickPerMaterialBindGroupLayout!: GPUBindGroupLayout
151
+ private pickPerFrameBindGroup!: GPUBindGroup
152
+ private pickTexture!: GPUTexture
153
+ private pickDepthTexture!: GPUTexture
154
+ private pickReadbackBuffer!: GPUBuffer
155
+ private pendingPick: { x: number; y: number } | null = null
158
156
 
159
157
  private modelInstances = new Map<string, ModelInstance>()
160
158
  private materialSampler!: GPUSampler
161
159
  private textureCache = new Map<string, GPUTexture>()
162
- private raycastVertexBuffer: Float32Array | null = null
160
+ private _nextDefaultModelId = 0
161
+
162
+ // IK and physics enabled at engine level (same for all models)
163
+ private ikEnabled = true
164
+ private physicsEnabled = true
165
+
166
+ // Camera target binding (Babylon/Three style: camera follows model)
167
+ private cameraTargetModel: Model | null = null
168
+ private cameraTargetBoneName = "全ての親"
169
+ private cameraTargetOffset: Vec3 = new Vec3(0, 0, 0)
163
170
 
164
171
  private lastFpsUpdate = performance.now()
165
172
  private framesSinceLastUpdate = 0
@@ -175,7 +182,6 @@ export class Engine {
175
182
 
176
183
  constructor(canvas: HTMLCanvasElement, options?: EngineOptions) {
177
184
  this.canvas = canvas
178
- this.sampleCount = options?.multisampleCount ?? DEFAULT_ENGINE_OPTIONS.multisampleCount
179
185
  if (options) {
180
186
  this.ambientColor = options.ambientColor ?? DEFAULT_ENGINE_OPTIONS.ambientColor!
181
187
  this.directionalLightIntensity =
@@ -246,7 +252,7 @@ export class Engine {
246
252
  : undefined,
247
253
  primitive: { cullMode: config.cullMode ?? "none" },
248
254
  depthStencil: config.depthStencil,
249
- multisample: config.multisample ?? { count: this.sampleCount },
255
+ multisample: config.multisample ?? { count: Engine.MULTISAMPLE_COUNT },
250
256
  })
251
257
  }
252
258
 
@@ -296,24 +302,6 @@ export class Engine {
296
302
  },
297
303
  ]
298
304
 
299
- const depthOnlyVertexBuffers: GPUVertexBufferLayout[] = [
300
- {
301
- arrayStride: 8 * 4,
302
- attributes: [
303
- { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
304
- { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
305
- ],
306
- },
307
- {
308
- arrayStride: 4 * 2,
309
- attributes: [{ shaderLocation: 3, offset: 0, format: "uint16x4" as GPUVertexFormat }],
310
- },
311
- {
312
- arrayStride: 4,
313
- attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" as GPUVertexFormat }],
314
- },
315
- ]
316
-
317
305
  const standardBlend: GPUColorTargetState = {
318
306
  format: this.presentationFormat,
319
307
  blend: {
@@ -352,17 +340,17 @@ export class Engine {
352
340
 
353
341
  struct MaterialUniforms {
354
342
  alpha: f32,
355
- alphaMultiplier: f32,
356
343
  rimIntensity: f32,
357
344
  shininess: f32,
345
+ _padding1: f32,
358
346
  rimColor: vec3f,
359
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise
360
- diffuseColor: vec3f,
361
347
  _padding2: f32,
362
- ambientColor: vec3f,
348
+ diffuseColor: vec3f,
363
349
  _padding3: f32,
364
- specularColor: vec3f,
350
+ ambientColor: vec3f,
365
351
  _padding4: f32,
352
+ specularColor: vec3f,
353
+ _padding5: f32,
366
354
  };
367
355
 
368
356
  struct VertexOutput {
@@ -372,12 +360,15 @@ export class Engine {
372
360
  @location(2) worldPos: vec3f,
373
361
  };
374
362
 
363
+ // group 0: per-frame (bound once per pass)
375
364
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
376
365
  @group(0) @binding(1) var<uniform> light: LightUniforms;
377
- @group(0) @binding(2) var diffuseTexture: texture_2d<f32>;
378
- @group(0) @binding(3) var diffuseSampler: sampler;
379
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
380
- @group(0) @binding(5) var<uniform> material: MaterialUniforms;
366
+ @group(0) @binding(2) var diffuseSampler: sampler;
367
+ // group 1: per-instance (bound once per model)
368
+ @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
369
+ // group 2: per-material (bound per draw call)
370
+ @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
371
+ @group(2) @binding(1) var<uniform> material: MaterialUniforms;
381
372
 
382
373
  @vertex fn vs(
383
374
  @location(0) position: vec3f,
@@ -389,7 +380,6 @@ export class Engine {
389
380
  var output: VertexOutput;
390
381
  let pos4 = vec4f(position, 1.0);
391
382
 
392
- // Branchless weight normalization (avoids GPU branch divergence)
393
383
  let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
394
384
  let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
395
385
  let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
@@ -413,11 +403,7 @@ export class Engine {
413
403
  }
414
404
 
415
405
  @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
416
- // Early alpha test - discard before expensive calculations
417
- var finalAlpha = material.alpha * material.alphaMultiplier;
418
- if (material.isOverEyes > 0.5) {
419
- finalAlpha *= 0.5; // Hair over eyes gets 50% alpha
420
- }
406
+ let finalAlpha = material.alpha;
421
407
  if (finalAlpha < 0.001) {
422
408
  discard;
423
409
  }
@@ -425,18 +411,14 @@ export class Engine {
425
411
  let n = normalize(input.normal);
426
412
  let textureColor = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
427
413
 
428
- // View direction for specular and rim
429
414
  let viewDir = normalize(camera.viewPos - input.worldPos);
430
415
 
431
- // Simple lighting: global ambient + diffuse lighting
432
416
  let albedo = textureColor * material.diffuseColor;
433
417
 
434
- // Precompute material values
435
418
  let minSpec = light.ambientColor.w;
436
419
  let effectiveSpecular = max(material.specularColor, vec3f(minSpec));
437
420
  let specPower = max(material.shininess, 1.0);
438
421
 
439
- // Single directional light
440
422
  let l = -light.lights[0].direction.xyz;
441
423
  let nDotL = max(dot(n, l), 0.0);
442
424
  let intensity = light.lights[0].color.w;
@@ -444,7 +426,6 @@ export class Engine {
444
426
 
445
427
  let lightAccum = light.ambientColor.xyz + radiance * nDotL;
446
428
 
447
- // Blinn-Phong specular
448
429
  let h = normalize(l + viewDir);
449
430
  let nDotH = max(dot(n, h), 0.0);
450
431
  let specFactor = pow(nDotH, specPower);
@@ -452,9 +433,8 @@ export class Engine {
452
433
 
453
434
  let litColor = albedo * lightAccum;
454
435
 
455
- // Rim light calculation - proper Fresnel for edge-only highlights
456
436
  let fresnel = 1.0 - abs(dot(n, viewDir));
457
- let rimFactor = pow(fresnel, 4.0); // Higher power for sharper edge-only effect
437
+ let rimFactor = pow(fresnel, 4.0);
458
438
  let rimLight = material.rimColor * material.rimIntensity * rimFactor;
459
439
 
460
440
  let color = litColor + specularAccum + rimLight;
@@ -464,22 +444,44 @@ export class Engine {
464
444
  `,
465
445
  })
466
446
 
467
- // Create explicit bind group layout for all pipelines using the main shader
468
- this.mainBindGroupLayout = this.device.createBindGroupLayout({
469
- label: "main material bind group layout",
447
+ // group 0: per-frame (camera + light + sampler) bound once per pass
448
+ this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
449
+ label: "main per-frame bind group layout",
450
+ entries: [
451
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
452
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
453
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
454
+ ],
455
+ })
456
+ // group 1: per-instance (skinMats) — bound once per model
457
+ this.mainPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
458
+ label: "main per-instance bind group layout",
470
459
  entries: [
471
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
472
- { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // light
473
- { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // diffuseTexture
474
- { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // diffuseSampler
475
- { binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
476
- { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
460
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
461
+ ],
462
+ })
463
+ // group 2: per-material (texture + material uniforms) bound per draw call
464
+ this.mainPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
465
+ label: "main per-material bind group layout",
466
+ entries: [
467
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
468
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
477
469
  ],
478
470
  })
479
471
 
480
472
  const mainPipelineLayout = this.device.createPipelineLayout({
481
473
  label: "main pipeline layout",
482
- bindGroupLayouts: [this.mainBindGroupLayout],
474
+ bindGroupLayouts: [this.mainPerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout, this.mainPerMaterialBindGroupLayout],
475
+ })
476
+
477
+ this.perFrameBindGroup = this.device.createBindGroup({
478
+ label: "main per-frame bind group",
479
+ layout: this.mainPerFrameBindGroupLayout,
480
+ entries: [
481
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
482
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
483
+ { binding: 2, resource: this.materialSampler },
484
+ ],
483
485
  })
484
486
 
485
487
  this.modelPipeline = this.createRenderPipeline({
@@ -496,71 +498,6 @@ export class Engine {
496
498
  },
497
499
  })
498
500
 
499
- this.groundBindGroupLayout = this.device.createBindGroupLayout({
500
- label: "ground bind group layout",
501
- entries: [
502
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
503
- { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
504
- { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} },
505
- { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
506
- { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
507
- ],
508
- })
509
- const groundPipelineLayout = this.device.createPipelineLayout({
510
- label: "ground pipeline layout",
511
- bindGroupLayouts: [this.groundBindGroupLayout],
512
- })
513
- const groundShaderModule = this.device.createShaderModule({
514
- label: "ground shaders",
515
- code: /* wgsl */ `
516
- struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
517
- struct Light { direction: vec4f, color: vec4f, };
518
- struct LightUniforms { ambientColor: vec4f, lights: array<Light, 4>, };
519
- struct GroundMaterialUniforms { diffuseColor: vec3f, reflectionLevel: f32, fadeStart: f32, fadeEnd: f32, _a: f32, _b: f32, };
520
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
521
- @group(0) @binding(1) var<uniform> light: LightUniforms;
522
- @group(0) @binding(2) var reflectionTexture: texture_2d<f32>;
523
- @group(0) @binding(3) var reflectionSampler: sampler;
524
- @group(0) @binding(4) var<uniform> material: GroundMaterialUniforms;
525
- struct VertexOutput {
526
- @builtin(position) position: vec4f, @location(0) normal: vec3f, @location(1) uv: vec2f, @location(2) worldPos: vec3f,
527
- };
528
- @vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VertexOutput {
529
- var o: VertexOutput;
530
- o.worldPos = position;
531
- o.position = camera.projection * camera.view * vec4f(position, 1.0);
532
- o.normal = normal; o.uv = uv; return o;
533
- }
534
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
535
- let n = normalize(input.normal);
536
- let centerDist = length(input.worldPos.xz);
537
- let t = clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0);
538
- let edgeFade = 1.0 - smoothstep(0.0, 1.0, t);
539
- let clipPos = camera.projection * camera.view * vec4f(input.worldPos, 1.0);
540
- let ndcPos = clipPos.xyz / clipPos.w;
541
- let reflectionUV = vec2f(ndcPos.x * 0.5 + 0.5, 0.5 - ndcPos.y * 0.5);
542
- let sampledReflectionColor = textureSample(reflectionTexture, reflectionSampler, reflectionUV).rgb;
543
- let isValidReflection = clipPos.w > 0.0 && all(reflectionUV >= vec2f(0.0)) && all(reflectionUV <= vec2f(1.0));
544
- let reflectionColor = select(vec3f(1.0, 1.0, 1.0), sampledReflectionColor, isValidReflection);
545
- let fadeFactor = clamp((length(input.worldPos - camera.viewPos) - 15.0) / 20.0, 0.0, 1.0);
546
- let refl = reflectionColor * (1.0 - fadeFactor * 0.3);
547
- var finalColor = mix(material.diffuseColor, refl, material.reflectionLevel) * edgeFade;
548
- let l = -light.lights[0].direction.xyz;
549
- let lightAccum = light.ambientColor.xyz + light.lights[0].color.xyz * light.lights[0].color.w * max(dot(n, l), 0.0);
550
- return vec4f(finalColor * lightAccum, edgeFade);
551
- }
552
- `,
553
- })
554
- this.groundPipeline = this.createRenderPipeline({
555
- label: "ground pipeline",
556
- layout: groundPipelineLayout,
557
- shaderModule: groundShaderModule,
558
- vertexBuffers: fullVertexBuffers,
559
- fragmentTarget: standardBlend,
560
- cullMode: "back",
561
- depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
562
- })
563
-
564
501
  this.shadowLightVPBuffer = this.device.createBuffer({
565
502
  size: 64,
566
503
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -677,38 +614,32 @@ export class Engine {
677
614
  depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: true, depthCompare: "less-equal" },
678
615
  })
679
616
 
680
- // Create reflection pipeline (multisampled version for higher quality)
681
- this.reflectionPipeline = this.createRenderPipeline({
682
- label: "reflection pipeline",
683
- layout: mainPipelineLayout,
684
- shaderModule,
685
- vertexBuffers: fullVertexBuffers,
686
- fragmentTarget: {
687
- format: this.presentationFormat,
688
- blend: standardBlend.blend,
689
- },
690
- multisample: { count: this.sampleCount }, // Use same multisampling as main render
691
- cullMode: "none",
692
- depthStencil: {
693
- format: "depth24plus-stencil8",
694
- depthWriteEnabled: true,
695
- depthCompare: "less-equal",
696
- },
617
+ // Outline: group 0 = per-frame (camera), group 1 = per-instance (skinMats), group 2 = per-material (edge uniforms)
618
+ this.outlinePerFrameBindGroupLayout = this.device.createBindGroupLayout({
619
+ label: "outline per-frame bind group layout",
620
+ entries: [
621
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
622
+ ],
697
623
  })
698
-
699
- // Create bind group layout for outline pipelines
700
- this.outlineBindGroupLayout = this.device.createBindGroupLayout({
701
- label: "outline bind group layout",
624
+ // Outline per-instance reuses mainPerInstanceBindGroupLayout (same skinMats binding)
625
+ this.outlinePerMaterialBindGroupLayout = this.device.createBindGroupLayout({
626
+ label: "outline per-material bind group layout",
702
627
  entries: [
703
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // camera
704
- { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }, // material
705
- { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, // skinMats
628
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
706
629
  ],
707
630
  })
708
631
 
709
632
  const outlinePipelineLayout = this.device.createPipelineLayout({
710
633
  label: "outline pipeline layout",
711
- bindGroupLayouts: [this.outlineBindGroupLayout],
634
+ bindGroupLayouts: [this.outlinePerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout, this.outlinePerMaterialBindGroupLayout],
635
+ })
636
+
637
+ this.outlinePerFrameBindGroup = this.device.createBindGroup({
638
+ label: "outline per-frame bind group",
639
+ layout: this.outlinePerFrameBindGroupLayout,
640
+ entries: [
641
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
642
+ ],
712
643
  })
713
644
 
714
645
  const outlineShaderModule = this.device.createShaderModule({
@@ -724,14 +655,17 @@ export class Engine {
724
655
  struct MaterialUniforms {
725
656
  edgeColor: vec4f,
726
657
  edgeSize: f32,
727
- isOverEyes: f32, // 1.0 if rendering over eyes, 0.0 otherwise (for hair outlines)
728
658
  _padding1: f32,
729
659
  _padding2: f32,
660
+ _padding3: f32,
730
661
  };
731
662
 
663
+ // group 0: per-frame
732
664
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
733
- @group(0) @binding(1) var<uniform> material: MaterialUniforms;
734
- @group(0) @binding(2) var<storage, read> skinMats: array<mat4x4f>;
665
+ // group 1: per-instance
666
+ @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
667
+ // group 2: per-material
668
+ @group(2) @binding(0) var<uniform> material: MaterialUniforms;
735
669
 
736
670
  struct VertexOutput {
737
671
  @builtin(position) position: vec4f,
@@ -746,7 +680,6 @@ export class Engine {
746
680
  var output: VertexOutput;
747
681
  let pos4 = vec4f(position, 1.0);
748
682
 
749
- // Branchless weight normalization (avoids GPU branch divergence)
750
683
  let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
751
684
  let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
752
685
  let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
@@ -764,7 +697,6 @@ export class Engine {
764
697
  let worldPos = skinnedPos.xyz;
765
698
  let worldNormal = normalize(skinnedNrm);
766
699
 
767
- // MMD invert hull: expand vertices outward along normals
768
700
  let scaleFactor = 0.01;
769
701
  let expandedPos = worldPos + worldNormal * material.edgeSize * scaleFactor;
770
702
  output.position = camera.projection * camera.view * vec4f(expandedPos, 1.0);
@@ -772,13 +704,7 @@ export class Engine {
772
704
  }
773
705
 
774
706
  @fragment fn fs() -> @location(0) vec4f {
775
- var color = material.edgeColor;
776
-
777
- if (material.isOverEyes > 0.5) {
778
- color.a *= 0.5; // Hair outlines over eyes get 50% alpha
779
- }
780
-
781
- return color;
707
+ return material.edgeColor;
782
708
  }
783
709
  `,
784
710
  })
@@ -797,57 +723,9 @@ export class Engine {
797
723
  },
798
724
  })
799
725
 
800
- // Hair outline pipeline
801
- this.hairOutlinePipeline = this.createRenderPipeline({
802
- label: "hair outline pipeline",
803
- layout: outlinePipelineLayout,
804
- shaderModule: outlineShaderModule,
805
- vertexBuffers: outlineVertexBuffers,
806
- fragmentTarget: standardBlend,
807
- cullMode: "back",
808
- depthStencil: {
809
- format: "depth24plus-stencil8",
810
- depthWriteEnabled: false,
811
- depthCompare: "less-equal",
812
- depthBias: -0.0001,
813
- depthBiasSlopeScale: 0.0,
814
- depthBiasClamp: 0.0,
815
- },
816
- })
817
-
818
- // Eye overlay pipeline (renders after opaque, writes stencil)
819
- this.eyePipeline = this.createRenderPipeline({
820
- label: "eye overlay pipeline",
821
- layout: mainPipelineLayout,
822
- shaderModule,
823
- vertexBuffers: fullVertexBuffers,
824
- fragmentTarget: standardBlend,
825
- cullMode: "front",
826
- depthStencil: {
827
- format: "depth24plus-stencil8",
828
- depthWriteEnabled: true,
829
- depthCompare: "less-equal",
830
- depthBias: -0.00005,
831
- depthBiasSlopeScale: 0.0,
832
- depthBiasClamp: 0.0,
833
- stencilFront: {
834
- compare: "always",
835
- failOp: "keep",
836
- depthFailOp: "keep",
837
- passOp: "replace",
838
- },
839
- stencilBack: {
840
- compare: "always",
841
- failOp: "keep",
842
- depthFailOp: "keep",
843
- passOp: "replace",
844
- },
845
- },
846
- })
847
-
848
- // Depth-only shader for hair pre-pass (reduces overdraw by early depth rejection)
849
- const depthOnlyShaderModule = this.device.createShaderModule({
850
- label: "depth only shader",
726
+ // GPU picking: encode (modelIndex, materialIndex) as color
727
+ const pickShaderModule = this.device.createShaderModule({
728
+ label: "pick shader",
851
729
  code: /* wgsl */ `
852
730
  struct CameraUniforms {
853
731
  view: mat4x4f,
@@ -855,94 +733,92 @@ export class Engine {
855
733
  viewPos: vec3f,
856
734
  _padding: f32,
857
735
  };
736
+ struct PickId {
737
+ modelId: f32,
738
+ materialId: f32,
739
+ _p1: f32,
740
+ _p2: f32,
741
+ };
858
742
 
859
743
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
860
- @group(0) @binding(4) var<storage, read> skinMats: array<mat4x4f>;
744
+ @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
745
+ @group(2) @binding(0) var<uniform> pickId: PickId;
861
746
 
862
747
  @vertex fn vs(
863
748
  @location(0) position: vec3f,
864
749
  @location(1) normal: vec3f,
750
+ @location(2) uv: vec2f,
865
751
  @location(3) joints0: vec4<u32>,
866
752
  @location(4) weights0: vec4<f32>
867
753
  ) -> @builtin(position) vec4f {
868
754
  let pos4 = vec4f(position, 1.0);
869
-
870
- // Branchless weight normalization (avoids GPU branch divergence)
871
755
  let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
872
756
  let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
873
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
874
-
875
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
876
- for (var i = 0u; i < 4u; i++) {
877
- let j = joints0[i];
878
- let w = normalizedWeights[i];
879
- let m = skinMats[j];
880
- skinnedPos += (m * pos4) * w;
881
- }
882
- let worldPos = skinnedPos.xyz;
883
- let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
884
- return clipPos;
757
+ let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
758
+ var sp = vec4f(0.0);
759
+ for (var i = 0u; i < 4u; i++) { sp += (skinMats[joints0[i]] * pos4) * nw[i]; }
760
+ return camera.projection * camera.view * vec4f(sp.xyz, 1.0);
885
761
  }
886
762
 
887
763
  @fragment fn fs() -> @location(0) vec4f {
888
- return vec4f(0.0, 0.0, 0.0, 0.0); // Transparent - color writes disabled via writeMask
764
+ return vec4f(pickId.modelId / 255.0, pickId.materialId / 255.0, 0.0, 1.0);
889
765
  }
890
766
  `,
891
767
  })
892
768
 
893
- // Hair depth pre-pass pipeline: depth-only with color writes disabled to eliminate overdraw
894
- this.hairDepthPipeline = this.createRenderPipeline({
895
- label: "hair depth pre-pass",
896
- layout: mainPipelineLayout,
897
- shaderModule: depthOnlyShaderModule,
898
- vertexBuffers: depthOnlyVertexBuffers,
899
- fragmentTarget: {
900
- format: this.presentationFormat,
901
- writeMask: 0,
769
+ this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
770
+ label: "pick per-frame layout",
771
+ entries: [
772
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
773
+ ],
774
+ })
775
+ this.pickPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
776
+ label: "pick per-instance layout",
777
+ entries: [
778
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
779
+ ],
780
+ })
781
+ this.pickPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
782
+ label: "pick per-material layout",
783
+ entries: [
784
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
785
+ ],
786
+ })
787
+
788
+ const pickPipelineLayout = this.device.createPipelineLayout({
789
+ label: "pick pipeline layout",
790
+ bindGroupLayouts: [this.pickPerFrameBindGroupLayout, this.pickPerInstanceBindGroupLayout, this.pickPerMaterialBindGroupLayout],
791
+ })
792
+
793
+ this.pickPerFrameBindGroup = this.device.createBindGroup({
794
+ label: "pick per-frame bind group",
795
+ layout: this.pickPerFrameBindGroupLayout,
796
+ entries: [
797
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
798
+ ],
799
+ })
800
+
801
+ this.pickPipeline = this.device.createRenderPipeline({
802
+ label: "pick pipeline",
803
+ layout: pickPipelineLayout,
804
+ vertex: { module: pickShaderModule, buffers: fullVertexBuffers },
805
+ fragment: {
806
+ module: pickShaderModule,
807
+ targets: [{ format: "rgba8unorm" }],
902
808
  },
903
- fragmentEntryPoint: "fs",
904
- cullMode: "none",
809
+ primitive: { cullMode: "none" },
905
810
  depthStencil: {
906
- format: "depth24plus-stencil8",
811
+ format: "depth24plus",
907
812
  depthWriteEnabled: true,
908
813
  depthCompare: "less-equal",
909
- depthBias: 0.0,
910
- depthBiasSlopeScale: 0.0,
911
- depthBiasClamp: 0.0,
912
814
  },
913
815
  })
914
816
 
915
- // Hair pipelines for rendering over eyes vs non-eyes (only differ in stencil compare mode)
916
- const createHairPipeline = (isOverEyes: boolean): GPURenderPipeline => {
917
- return this.createRenderPipeline({
918
- label: `hair pipeline (${isOverEyes ? "over eyes" : "over non-eyes"})`,
919
- layout: mainPipelineLayout,
920
- shaderModule,
921
- vertexBuffers: fullVertexBuffers,
922
- fragmentTarget: standardBlend,
923
- cullMode: "none",
924
- depthStencil: {
925
- format: "depth24plus-stencil8",
926
- depthWriteEnabled: false,
927
- depthCompare: "less-equal",
928
- stencilFront: {
929
- compare: isOverEyes ? "equal" : "not-equal",
930
- failOp: "keep",
931
- depthFailOp: "keep",
932
- passOp: "keep",
933
- },
934
- stencilBack: {
935
- compare: isOverEyes ? "equal" : "not-equal",
936
- failOp: "keep",
937
- depthFailOp: "keep",
938
- passOp: "keep",
939
- },
940
- },
941
- })
942
- }
943
-
944
- this.hairPipelineOverEyes = createHairPipeline(true)
945
- this.hairPipelineOverNonEyes = createHairPipeline(false)
817
+ this.pickReadbackBuffer = this.device.createBuffer({
818
+ label: "pick readback",
819
+ size: 256,
820
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
821
+ })
946
822
  }
947
823
 
948
824
 
@@ -974,7 +850,7 @@ export class Engine {
974
850
  this.multisampleTexture = this.device.createTexture({
975
851
  label: "multisample render target",
976
852
  size: [width, height],
977
- sampleCount: this.sampleCount,
853
+ sampleCount: Engine.MULTISAMPLE_COUNT,
978
854
  format: this.presentationFormat,
979
855
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
980
856
  })
@@ -982,29 +858,20 @@ export class Engine {
982
858
  this.depthTexture = this.device.createTexture({
983
859
  label: "depth texture",
984
860
  size: [width, height],
985
- sampleCount: this.sampleCount,
861
+ sampleCount: Engine.MULTISAMPLE_COUNT,
986
862
  format: "depth24plus-stencil8",
987
863
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
988
864
  })
989
865
 
990
866
  const depthTextureView = this.depthTexture.createView()
991
867
 
992
- // Render directly to canvas
993
- const colorAttachment: GPURenderPassColorAttachment =
994
- this.sampleCount > 1
995
- ? {
996
- view: this.multisampleTexture.createView(),
997
- resolveTarget: this.context.getCurrentTexture().createView(),
998
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
999
- loadOp: "clear",
1000
- storeOp: "store",
1001
- }
1002
- : {
1003
- view: this.context.getCurrentTexture().createView(),
1004
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
1005
- loadOp: "clear",
1006
- storeOp: "store",
1007
- }
868
+ const colorAttachment: GPURenderPassColorAttachment = {
869
+ view: this.multisampleTexture.createView(),
870
+ resolveTarget: this.context.getCurrentTexture().createView(),
871
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
872
+ loadOp: "clear",
873
+ storeOp: "store",
874
+ }
1008
875
 
1009
876
  this.renderPassDescriptor = {
1010
877
  label: "renderPass",
@@ -1016,11 +883,26 @@ export class Engine {
1016
883
  depthStoreOp: "store",
1017
884
  stencilClearValue: 0,
1018
885
  stencilLoadOp: "clear",
1019
- stencilStoreOp: "discard", // Discard stencil after frame to save bandwidth (we only use it during rendering)
886
+ stencilStoreOp: "discard",
1020
887
  },
1021
888
  }
1022
889
 
1023
890
  this.camera.aspect = width / height
891
+
892
+ if (this.onRaycast) {
893
+ this.pickTexture = this.device.createTexture({
894
+ label: "pick render target",
895
+ size: [width, height],
896
+ format: "rgba8unorm",
897
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
898
+ })
899
+ this.pickDepthTexture = this.device.createTexture({
900
+ label: "pick depth",
901
+ size: [width, height],
902
+ format: "depth24plus",
903
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
904
+ })
905
+ }
1024
906
  }
1025
907
  }
1026
908
 
@@ -1038,6 +920,49 @@ export class Engine {
1038
920
  this.camera.attachControl(this.canvas)
1039
921
  }
1040
922
 
923
+ /** Set static camera look-at / orbit center. Clears any model follow binding. */
924
+ public setCameraTarget(v: Vec3): void
925
+ /** Bind camera orbit center to a model's bone (Souls-style follow cam). Pass null to unbind. */
926
+ public setCameraTarget(model: Model | null, boneName: string, offset?: Vec3): void
927
+ public setCameraTarget(modelOrVec: Model | Vec3 | null, boneName?: string, offset?: Vec3): void {
928
+ if (modelOrVec === null) {
929
+ this.cameraTargetModel = null
930
+ return
931
+ }
932
+ if ("x" in modelOrVec && "y" in modelOrVec && "z" in modelOrVec) {
933
+ this.cameraTargetModel = null
934
+ this.camera.target.x = modelOrVec.x
935
+ this.camera.target.y = modelOrVec.y
936
+ this.camera.target.z = modelOrVec.z
937
+ return
938
+ }
939
+ this.cameraTargetModel = modelOrVec
940
+ this.cameraTargetBoneName = boneName ?? ""
941
+ this.cameraTargetOffset.x = offset?.x ?? 0
942
+ this.cameraTargetOffset.y = offset?.y ?? 0
943
+ this.cameraTargetOffset.z = offset?.z ?? 0
944
+ }
945
+
946
+ /** Souls-style follow cam: orbit center tracks a model bone each frame. Shorthand for setCameraTarget(model, boneName, offset). */
947
+ public setCameraFollow(model: Model | null, boneName?: string, offset?: Vec3): void {
948
+ if (model === null) {
949
+ this.cameraTargetModel = null
950
+ return
951
+ }
952
+ this.cameraTargetModel = model
953
+ this.cameraTargetBoneName = boneName ?? "全ての親"
954
+ this.cameraTargetOffset.x = offset?.x ?? 0
955
+ this.cameraTargetOffset.y = offset?.y ?? 0
956
+ this.cameraTargetOffset.z = offset?.z ?? 0
957
+ }
958
+
959
+ public getCameraDistance(): number { return this.camera.radius }
960
+ public setCameraDistance(d: number): void { this.camera.radius = d }
961
+ public getCameraAlpha(): number { return this.camera.alpha }
962
+ public setCameraAlpha(a: number): void { this.camera.alpha = a }
963
+ public getCameraBeta(): number { return this.camera.beta }
964
+ public setCameraBeta(b: number): void { this.camera.beta = b }
965
+
1041
966
  // Step 5: Create lighting buffers
1042
967
  private setupLighting() {
1043
968
  this.lightUniformBuffer = this.device.createBuffer({
@@ -1096,11 +1021,8 @@ export class Engine {
1096
1021
  width?: number
1097
1022
  height?: number
1098
1023
  diffuseColor?: Vec3
1099
- reflectionLevel?: number
1100
- reflectionTextureSize?: number
1101
1024
  fadeStart?: number
1102
1025
  fadeEnd?: number
1103
- mode?: "reflection" | "shadow"
1104
1026
  shadowMapSize?: number
1105
1027
  shadowStrength?: number
1106
1028
  }): void {
@@ -1108,29 +1030,20 @@ export class Engine {
1108
1030
  width: 100,
1109
1031
  height: 100,
1110
1032
  diffuseColor: new Vec3(1, 1, 1),
1111
- reflectionLevel: 0.5,
1112
- reflectionTextureSize: 1024,
1113
1033
  fadeStart: 5.0,
1114
1034
  fadeEnd: 60.0,
1115
- mode: "reflection" as const,
1116
1035
  shadowMapSize: 4096,
1117
1036
  shadowStrength: 1.0,
1118
1037
  ...options,
1119
1038
  }
1120
- this.groundMode = opts.mode
1121
1039
  this.createGroundGeometry(opts.width, opts.height)
1122
- if (opts.mode === "reflection") {
1123
- this.createGroundMaterialBuffer(opts.diffuseColor, opts.reflectionLevel, opts.fadeStart, opts.fadeEnd)
1124
- this.createReflectionTexture(opts.reflectionTextureSize)
1125
- } else {
1126
- this.createShadowGroundResources(opts.shadowMapSize, opts.diffuseColor, opts.fadeStart, opts.fadeEnd, opts.shadowStrength)
1127
- }
1128
- this.groundHasReflections = true
1040
+ this.createShadowGroundResources(opts.shadowMapSize, opts.diffuseColor, opts.fadeStart, opts.fadeEnd, opts.shadowStrength)
1041
+ this.hasGround = true
1129
1042
  this.groundDrawCall = {
1130
1043
  type: "ground",
1131
1044
  count: 6,
1132
1045
  firstIndex: 0,
1133
- bindGroup: (opts.mode === "reflection" ? this.groundReflectionBindGroup : this.groundShadowBindGroup)!,
1046
+ bindGroup: this.groundShadowBindGroup!,
1134
1047
  materialName: "Ground",
1135
1048
  }
1136
1049
  }
@@ -1185,6 +1098,17 @@ export class Engine {
1185
1098
  }
1186
1099
  }
1187
1100
 
1101
+ public async loadModel(path: string): Promise<Model>
1102
+ public async loadModel(name: string, path: string): Promise<Model>
1103
+ public async loadModel(nameOrPath: string, path?: string): Promise<Model> {
1104
+ const pmxPath = path === undefined ? nameOrPath : path
1105
+ const name = path === undefined ? "model_" + (this._nextDefaultModelId++) : nameOrPath
1106
+ const model = await PmxLoader.load(pmxPath)
1107
+ model.setName(name)
1108
+ await this.addModel(model, pmxPath, name)
1109
+ return model
1110
+ }
1111
+
1188
1112
  public async addModel(model: Model, pmxPath: string, name?: string): Promise<string> {
1189
1113
  const requested = name ?? model.name
1190
1114
  let key = requested
@@ -1199,10 +1123,6 @@ export class Engine {
1199
1123
  return key
1200
1124
  }
1201
1125
 
1202
- public async registerModel(model: Model, pmxPath: string): Promise<string> {
1203
- return this.addModel(model, pmxPath)
1204
- }
1205
-
1206
1126
  public removeModel(name: string): void {
1207
1127
  this.modelInstances.delete(name)
1208
1128
  }
@@ -1249,12 +1169,20 @@ export class Engine {
1249
1169
  return inst ? !inst.hiddenMaterials.has(materialName) : false
1250
1170
  }
1251
1171
 
1252
- public setModelIKEnabled(modelName: string, enabled: boolean): void {
1253
- this.modelInstances.get(modelName)?.model.setIKEnabled(enabled)
1172
+ public setIKEnabled(enabled: boolean): void {
1173
+ this.ikEnabled = enabled
1174
+ }
1175
+
1176
+ public getIKEnabled(): boolean {
1177
+ return this.ikEnabled
1254
1178
  }
1255
1179
 
1256
- public setModelPhysicsEnabled(modelName: string, enabled: boolean): void {
1257
- this.modelInstances.get(modelName)?.model.setPhysicsEnabled(enabled)
1180
+ public setPhysicsEnabled(enabled: boolean): void {
1181
+ this.physicsEnabled = enabled
1182
+ }
1183
+
1184
+ public getPhysicsEnabled(): boolean {
1185
+ return this.physicsEnabled
1258
1186
  }
1259
1187
 
1260
1188
  public resetPhysics(): void {
@@ -1265,19 +1193,15 @@ export class Engine {
1265
1193
  })
1266
1194
  }
1267
1195
 
1268
- private instances(): IterableIterator<ModelInstance> {
1269
- return this.modelInstances.values()
1270
- }
1271
-
1272
1196
  private forEachInstance(fn: (inst: ModelInstance) => void): void {
1273
- for (const inst of this.instances()) fn(inst)
1197
+ for (const inst of this.modelInstances.values()) fn(inst)
1274
1198
  }
1275
1199
 
1276
1200
  private updateInstances(deltaTime: number): void {
1277
1201
  this.forEachInstance((inst) => {
1278
- const verticesChanged = inst.model.update(deltaTime)
1202
+ const verticesChanged = inst.model.update(deltaTime, this.ikEnabled)
1279
1203
  if (verticesChanged) inst.vertexBufferNeedsUpdate = true
1280
- if (inst.physics && inst.model.getPhysicsEnabled()) {
1204
+ if (inst.physics && this.physicsEnabled) {
1281
1205
  inst.physics.step(
1282
1206
  deltaTime,
1283
1207
  inst.model.getWorldMatrices(),
@@ -1362,6 +1286,22 @@ export class Engine {
1362
1286
  ],
1363
1287
  })
1364
1288
 
1289
+ const mainPerInstanceBindGroup = this.device.createBindGroup({
1290
+ label: `${name}: main per-instance bind group`,
1291
+ layout: this.mainPerInstanceBindGroupLayout,
1292
+ entries: [
1293
+ { binding: 0, resource: { buffer: skinMatrixBuffer } },
1294
+ ],
1295
+ })
1296
+
1297
+ const pickPerInstanceBindGroup = this.device.createBindGroup({
1298
+ label: `${name}: pick per-instance bind group`,
1299
+ layout: this.pickPerInstanceBindGroupLayout,
1300
+ entries: [
1301
+ { binding: 0, resource: { buffer: skinMatrixBuffer } },
1302
+ ],
1303
+ })
1304
+
1365
1305
  const inst: ModelInstance = {
1366
1306
  name,
1367
1307
  model,
@@ -1374,6 +1314,9 @@ export class Engine {
1374
1314
  drawCalls: [],
1375
1315
  shadowDrawCalls: [],
1376
1316
  shadowBindGroup,
1317
+ mainPerInstanceBindGroup,
1318
+ pickPerInstanceBindGroup,
1319
+ pickDrawCalls: [],
1377
1320
  hiddenMaterials: new Set(),
1378
1321
  physics,
1379
1322
  vertexBufferNeedsUpdate: false,
@@ -1454,58 +1397,6 @@ export class Engine {
1454
1397
  this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices)
1455
1398
  }
1456
1399
 
1457
- private createGroundMaterialBuffer(diffuseColor: Vec3, reflectionLevel: number, fadeStart: number, fadeEnd: number) {
1458
- const u = new Float32Array(8)
1459
- u[0] = diffuseColor.x
1460
- u[1] = diffuseColor.y
1461
- u[2] = diffuseColor.z
1462
- u[3] = reflectionLevel
1463
- u[4] = fadeStart
1464
- u[5] = fadeEnd
1465
- u[6] = 0
1466
- u[7] = 0
1467
- this.groundMaterialUniformBuffer = this.device.createBuffer({
1468
- label: "ground material uniform buffer",
1469
- size: 64,
1470
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1471
- })
1472
- this.device.queue.writeBuffer(this.groundMaterialUniformBuffer, 0, u)
1473
- }
1474
-
1475
- private createReflectionTexture(size: number) {
1476
- this.groundReflectionTexture = this.device.createTexture({
1477
- label: "ground reflection texture",
1478
- size: [size, size],
1479
- sampleCount: this.sampleCount,
1480
- format: this.presentationFormat,
1481
- usage: GPUTextureUsage.RENDER_ATTACHMENT,
1482
- })
1483
- this.groundReflectionResolveTexture = this.device.createTexture({
1484
- label: "ground reflection resolve texture",
1485
- size: [size, size],
1486
- format: this.presentationFormat,
1487
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1488
- })
1489
- this.groundReflectionDepthTexture = this.device.createTexture({
1490
- label: "ground reflection depth texture",
1491
- size: [size, size],
1492
- sampleCount: this.sampleCount,
1493
- format: "depth24plus-stencil8",
1494
- usage: GPUTextureUsage.RENDER_ATTACHMENT,
1495
- })
1496
- this.groundReflectionBindGroup = this.device.createBindGroup({
1497
- label: "ground reflection bind group",
1498
- layout: this.groundBindGroupLayout,
1499
- entries: [
1500
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1501
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1502
- { binding: 2, resource: this.groundReflectionResolveTexture.createView() },
1503
- { binding: 3, resource: this.materialSampler },
1504
- { binding: 4, resource: { buffer: this.groundMaterialUniformBuffer! } },
1505
- ],
1506
- })
1507
- }
1508
-
1509
1400
  private createShadowGroundResources(
1510
1401
  shadowMapSize: number,
1511
1402
  diffuseColor: Vec3,
@@ -1574,6 +1465,8 @@ export class Engine {
1574
1465
  if (materials.length === 0) throw new Error("Model has no materials")
1575
1466
  const textures = model.getTextures()
1576
1467
  const prefix = `${inst.name}: `
1468
+ // 1-based so that (0,0) = clear color = "no hit"
1469
+ const modelId = this.modelInstances.size + 1
1577
1470
 
1578
1471
  const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
1579
1472
  if (texIndex < 0 || texIndex >= textures.length) return null
@@ -1582,9 +1475,11 @@ export class Engine {
1582
1475
  }
1583
1476
 
1584
1477
  let currentIndexOffset = 0
1478
+ let materialId = 0
1585
1479
  for (const mat of materials) {
1586
1480
  const indexCount = mat.vertexCount
1587
1481
  if (indexCount === 0) continue
1482
+ materialId++
1588
1483
 
1589
1484
  const diffuseTexture = await loadTextureByIndex(mat.diffuseTextureIndex)
1590
1485
  if (!diffuseTexture) throw new Error(`Material "${mat.name}" has no diffuse texture`)
@@ -1595,73 +1490,24 @@ export class Engine {
1595
1490
  const materialUniformBuffer = this.createMaterialUniformBuffer(
1596
1491
  prefix + mat.name,
1597
1492
  materialAlpha,
1598
- 0.0,
1599
1493
  [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]],
1600
1494
  mat.ambient,
1601
1495
  mat.specular,
1602
1496
  mat.shininess
1603
1497
  )
1604
1498
 
1499
+ const textureView = diffuseTexture.createView()
1605
1500
  const bindGroup = this.device.createBindGroup({
1606
1501
  label: `${prefix}material: ${mat.name}`,
1607
- layout: this.mainBindGroupLayout,
1502
+ layout: this.mainPerMaterialBindGroupLayout,
1608
1503
  entries: [
1609
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1610
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1611
- { binding: 2, resource: diffuseTexture.createView() },
1612
- { binding: 3, resource: this.materialSampler },
1613
- { binding: 4, resource: { buffer: inst.skinMatrixBuffer } },
1614
- { binding: 5, resource: { buffer: materialUniformBuffer } },
1504
+ { binding: 0, resource: textureView },
1505
+ { binding: 1, resource: { buffer: materialUniformBuffer } },
1615
1506
  ],
1616
1507
  })
1617
1508
 
1618
- if (indexCount > 0) {
1619
- if (mat.isEye) {
1620
- inst.drawCalls.push({ type: "eye", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
1621
- } else if (mat.isHair) {
1622
- const createHairBindGroup = (isOverEyes: boolean) => {
1623
- const buf = this.createMaterialUniformBuffer(
1624
- `${prefix}${mat.name} (${isOverEyes ? "over eyes" : "over non-eyes"})`,
1625
- materialAlpha,
1626
- isOverEyes ? 1.0 : 0.0,
1627
- [mat.diffuse[0], mat.diffuse[1], mat.diffuse[2]],
1628
- mat.ambient,
1629
- mat.specular,
1630
- mat.shininess
1631
- )
1632
- return this.device.createBindGroup({
1633
- label: `${prefix}hair ${isOverEyes ? "over eyes" : "over non-eyes"}: ${mat.name}`,
1634
- layout: this.mainBindGroupLayout,
1635
- entries: [
1636
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1637
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
1638
- { binding: 2, resource: diffuseTexture.createView() },
1639
- { binding: 3, resource: this.materialSampler },
1640
- { binding: 4, resource: { buffer: inst.skinMatrixBuffer } },
1641
- { binding: 5, resource: { buffer: buf } },
1642
- ],
1643
- })
1644
- }
1645
- inst.drawCalls.push({
1646
- type: "hair-over-eyes",
1647
- count: indexCount,
1648
- firstIndex: currentIndexOffset,
1649
- bindGroup: createHairBindGroup(true),
1650
- materialName: mat.name,
1651
- })
1652
- inst.drawCalls.push({
1653
- type: "hair-over-non-eyes",
1654
- count: indexCount,
1655
- firstIndex: currentIndexOffset,
1656
- bindGroup: createHairBindGroup(false),
1657
- materialName: mat.name,
1658
- })
1659
- } else if (isTransparent) {
1660
- inst.drawCalls.push({ type: "transparent", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
1661
- } else {
1662
- inst.drawCalls.push({ type: "opaque", count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
1663
- }
1664
- }
1509
+ const type: DrawCallType = isTransparent ? "transparent" : "opaque"
1510
+ inst.drawCalls.push({ type, count: indexCount, firstIndex: currentIndexOffset, bindGroup, materialName: mat.name })
1665
1511
 
1666
1512
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
1667
1513
  const materialUniformData = new Float32Array([
@@ -1671,32 +1517,37 @@ export class Engine {
1671
1517
  const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData)
1672
1518
  const outlineBindGroup = this.device.createBindGroup({
1673
1519
  label: `${prefix}outline: ${mat.name}`,
1674
- layout: this.outlineBindGroupLayout,
1520
+ layout: this.outlinePerMaterialBindGroupLayout,
1675
1521
  entries: [
1676
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1677
- { binding: 1, resource: { buffer: outlineUniformBuffer } },
1678
- { binding: 2, resource: { buffer: inst.skinMatrixBuffer } },
1522
+ { binding: 0, resource: { buffer: outlineUniformBuffer } },
1679
1523
  ],
1680
1524
  })
1681
- if (indexCount > 0) {
1682
- const outlineType: DrawCallType = mat.isEye ? "eye-outline" : mat.isHair ? "hair-outline" : isTransparent ? "transparent-outline" : "opaque-outline"
1683
- inst.drawCalls.push({ type: outlineType, count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup, materialName: mat.name })
1684
- }
1525
+ const outlineType: DrawCallType = isTransparent ? "transparent-outline" : "opaque-outline"
1526
+ inst.drawCalls.push({ type: outlineType, count: indexCount, firstIndex: currentIndexOffset, bindGroup: outlineBindGroup, materialName: mat.name })
1527
+ }
1528
+
1529
+ if (this.onRaycast) {
1530
+ const pickIdData = new Float32Array([modelId, materialId, 0, 0])
1531
+ const pickIdBuffer = this.createUniformBuffer(`${prefix}pick: ${mat.name}`, pickIdData)
1532
+ const pickBindGroup = this.device.createBindGroup({
1533
+ label: `${prefix}pick: ${mat.name}`,
1534
+ layout: this.pickPerMaterialBindGroupLayout,
1535
+ entries: [{ binding: 0, resource: { buffer: pickIdBuffer } }],
1536
+ })
1537
+ inst.pickDrawCalls.push({ count: indexCount, firstIndex: currentIndexOffset, bindGroup: pickBindGroup })
1685
1538
  }
1686
1539
 
1687
1540
  currentIndexOffset += indexCount
1688
1541
  }
1689
1542
 
1690
1543
  for (const d of inst.drawCalls) {
1691
- if (d.type === "opaque" || d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes")
1692
- inst.shadowDrawCalls.push(d)
1544
+ if (d.type === "opaque") inst.shadowDrawCalls.push(d)
1693
1545
  }
1694
1546
  }
1695
1547
 
1696
1548
  private createMaterialUniformBuffer(
1697
1549
  label: string,
1698
1550
  alpha: number,
1699
- isOverEyes: number,
1700
1551
  diffuseColor: [number, number, number],
1701
1552
  ambientColor: [number, number, number],
1702
1553
  specularColor: [number, number, number],
@@ -1705,25 +1556,13 @@ export class Engine {
1705
1556
  const data = new Float32Array(20)
1706
1557
  data.set([
1707
1558
  alpha,
1708
- 1.0,
1709
1559
  this.rimLightIntensity,
1710
- shininess, // alpha, alphaMultiplier, rimIntensity, shininess
1711
- 1.0,
1712
- 1.0,
1713
- 1.0,
1714
- isOverEyes, // rimColor (vec3), isOverEyes
1715
- diffuseColor[0],
1716
- diffuseColor[1],
1717
- diffuseColor[2],
1718
- 0.0, // diffuseColor (vec3), _padding2
1719
- ambientColor[0],
1720
- ambientColor[1],
1721
- ambientColor[2],
1722
- 0.0, // ambientColor (vec3), _padding3
1723
- specularColor[0],
1724
- specularColor[1],
1725
- specularColor[2],
1726
- 0.0, // specularColor (vec3), _padding4
1560
+ shininess,
1561
+ 0.0,
1562
+ 1.0, 1.0, 1.0, 0.0, // rimColor (vec3), _padding2
1563
+ diffuseColor[0], diffuseColor[1], diffuseColor[2], 0.0,
1564
+ ambientColor[0], ambientColor[1], ambientColor[2], 0.0,
1565
+ specularColor[0], specularColor[1], specularColor[2], 0.0,
1727
1566
  ])
1728
1567
  return this.createUniformBuffer(`material uniform: ${label}`, data)
1729
1568
  }
@@ -1776,128 +1615,16 @@ export class Engine {
1776
1615
  }
1777
1616
  }
1778
1617
 
1779
- // Helper: Render eyes with stencil writing (for post-alpha-eye effect)
1780
- private renderEyes(pass: GPURenderPassEncoder, inst: ModelInstance, useReflectionPipeline = false) {
1781
- if (useReflectionPipeline) {
1782
- pass.setPipeline(this.reflectionPipeline)
1783
- for (const draw of inst.drawCalls) {
1784
- if (draw.type === "eye") {
1785
- pass.setBindGroup(0, draw.bindGroup)
1786
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1787
- }
1788
- }
1789
- } else {
1790
- pass.setPipeline(this.eyePipeline)
1791
- pass.setStencilReference(this.STENCIL_EYE_VALUE)
1792
- for (const draw of inst.drawCalls) {
1793
- if (draw.type === "eye" && this.shouldRenderDrawCall(inst, draw)) {
1794
- pass.setBindGroup(0, draw.bindGroup)
1795
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1796
- }
1797
- }
1798
- }
1799
- }
1800
1618
 
1801
1619
  private renderGround(pass: GPURenderPassEncoder) {
1802
- if (!this.groundHasReflections || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
1803
- if (this.groundMode === "reflection" && this.groundReflectionTexture) this.renderReflectionTexture()
1804
- pass.setPipeline(this.groundMode === "reflection" ? this.groundPipeline : this.groundShadowPipeline)
1620
+ if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
1621
+ pass.setPipeline(this.groundShadowPipeline)
1805
1622
  pass.setVertexBuffer(0, this.groundVertexBuffer)
1806
1623
  pass.setIndexBuffer(this.groundIndexBuffer, "uint16")
1807
1624
  pass.setBindGroup(0, this.groundDrawCall.bindGroup)
1808
1625
  pass.drawIndexed(this.groundDrawCall.count, 1, this.groundDrawCall.firstIndex, 0, 0)
1809
1626
  }
1810
1627
 
1811
- private renderReflectionTexture() {
1812
- if (!this.groundReflectionTexture) return
1813
-
1814
- const mirrorMatrix = this.createMirrorMatrix(new Vec3(0, 1, 0), 0)
1815
- this.updateCameraUniforms()
1816
-
1817
- const reflectionEncoder = this.device.createCommandEncoder()
1818
- const reflectionPassDescriptor: GPURenderPassDescriptor = {
1819
- label: "reflection render pass",
1820
- colorAttachments: [
1821
- {
1822
- view: this.groundReflectionTexture!.createView(),
1823
- resolveTarget: this.groundReflectionResolveTexture!.createView(),
1824
- clearValue: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, // White
1825
- loadOp: "clear",
1826
- storeOp: "store",
1827
- },
1828
- ],
1829
- depthStencilAttachment: {
1830
- view: this.groundReflectionDepthTexture!.createView(),
1831
- depthClearValue: 1.0,
1832
- depthLoadOp: "clear",
1833
- depthStoreOp: "store",
1834
- stencilClearValue: 0,
1835
- stencilLoadOp: "clear",
1836
- stencilStoreOp: "discard",
1837
- },
1838
- }
1839
-
1840
- const reflectionPass = reflectionEncoder.beginRenderPass(reflectionPassDescriptor)
1841
- this.forEachInstance((inst) => this.renderOneModel(reflectionPass, inst, true, mirrorMatrix))
1842
- reflectionPass.end()
1843
- this.device.queue.submit([reflectionEncoder.finish()])
1844
- this.updateSkinMatrices()
1845
- }
1846
-
1847
- private renderHair(pass: GPURenderPassEncoder, inst: ModelInstance, useReflectionPipeline = false) {
1848
- if (useReflectionPipeline) {
1849
- pass.setPipeline(this.reflectionPipeline)
1850
- for (const draw of inst.drawCalls) {
1851
- if (draw.type === "hair-over-eyes" || draw.type === "hair-over-non-eyes") {
1852
- pass.setBindGroup(0, draw.bindGroup)
1853
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1854
- }
1855
- }
1856
- return
1857
- }
1858
-
1859
- const hasHair = inst.drawCalls.some(
1860
- (d) => (d.type === "hair-over-eyes" || d.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(inst, d)
1861
- )
1862
- if (hasHair) {
1863
- pass.setPipeline(this.hairDepthPipeline)
1864
- for (const draw of inst.drawCalls) {
1865
- if ((draw.type === "hair-over-eyes" || draw.type === "hair-over-non-eyes") && this.shouldRenderDrawCall(inst, draw)) {
1866
- pass.setBindGroup(0, draw.bindGroup)
1867
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1868
- }
1869
- }
1870
- }
1871
-
1872
- const hairOverEyes = inst.drawCalls.filter((d) => d.type === "hair-over-eyes" && this.shouldRenderDrawCall(inst, d))
1873
- if (hairOverEyes.length > 0) {
1874
- pass.setPipeline(this.hairPipelineOverEyes)
1875
- pass.setStencilReference(this.STENCIL_EYE_VALUE)
1876
- for (const draw of hairOverEyes) {
1877
- pass.setBindGroup(0, draw.bindGroup)
1878
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1879
- }
1880
- }
1881
-
1882
- const hairOverNonEyes = inst.drawCalls.filter((d) => d.type === "hair-over-non-eyes" && this.shouldRenderDrawCall(inst, d))
1883
- if (hairOverNonEyes.length > 0) {
1884
- pass.setPipeline(this.hairPipelineOverNonEyes)
1885
- pass.setStencilReference(this.STENCIL_EYE_VALUE)
1886
- for (const draw of hairOverNonEyes) {
1887
- pass.setBindGroup(0, draw.bindGroup)
1888
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1889
- }
1890
- }
1891
-
1892
- const hairOutlines = inst.drawCalls.filter((d) => d.type === "hair-outline" && this.shouldRenderDrawCall(inst, d))
1893
- if (hairOutlines.length > 0) {
1894
- pass.setPipeline(this.hairOutlinePipeline)
1895
- for (const draw of hairOutlines) {
1896
- pass.setBindGroup(0, draw.bindGroup)
1897
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
1898
- }
1899
- }
1900
- }
1901
1628
 
1902
1629
  private handleCanvasDoubleClick = (event: MouseEvent) => {
1903
1630
  if (!this.onRaycast || this.modelInstances.size === 0) return
@@ -1938,111 +1665,92 @@ export class Engine {
1938
1665
  this.onRaycast?.("", null, screenX, screenY)
1939
1666
  return
1940
1667
  }
1668
+ const dpr = window.devicePixelRatio || 1
1669
+ this.pendingPick = { x: Math.floor(screenX * dpr), y: Math.floor(screenY * dpr) }
1670
+ }
1941
1671
 
1942
- const viewMatrix = this.camera.getViewMatrix()
1943
- const projectionMatrix = this.camera.getProjectionMatrix()
1944
- const rect = this.canvas.getBoundingClientRect()
1945
- const clipX = (screenX / rect.width) * 2 - 1
1946
- const clipY = 1 - (screenY / rect.height) * 2
1947
- const viewProjMatrix = projectionMatrix.multiply(viewMatrix)
1948
- const inverseViewProj = viewProjMatrix.inverse()
1949
- const transformPoint = (matrix: Mat4, point: Vec3): Vec3 => {
1950
- const m = matrix.values
1951
- const x = point.x, y = point.y, z = point.z
1952
- const result = new Vec3(
1953
- m[0] * x + m[4] * y + m[8] * z + m[12],
1954
- m[1] * x + m[5] * y + m[9] * z + m[13],
1955
- m[2] * x + m[6] * y + m[10] * z + m[14]
1956
- )
1957
- const w = m[3] * x + m[7] * y + m[11] * z + m[15]
1958
- return result.scale(w !== 0 ? 1 / w : 1)
1959
- }
1960
- const worldNear = transformPoint(inverseViewProj, new Vec3(clipX, clipY, -1))
1961
- const worldFar = transformPoint(inverseViewProj, new Vec3(clipX, clipY, 1))
1962
- const rayOrigin = this.camera.getPosition()
1963
- const rayDirection = worldFar.subtract(worldNear).normalize()
1964
-
1965
- const transformByMatrix = (matrix: Float32Array, offset: number, point: Vec3): Vec3 => {
1966
- const m = matrix, x = point.x, y = point.y, z = point.z
1967
- return new Vec3(
1968
- m[offset + 0] * x + m[offset + 4] * y + m[offset + 8] * z + m[offset + 12],
1969
- m[offset + 1] * x + m[offset + 5] * y + m[offset + 9] * z + m[offset + 13],
1970
- m[offset + 2] * x + m[offset + 6] * y + m[offset + 10] * z + m[offset + 14]
1971
- )
1972
- }
1672
+ private renderPickPass(encoder: GPUCommandEncoder): void {
1673
+ if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture) return
1973
1674
 
1974
- let closest: { modelName: string; materialName: string; distance: number } | null = null
1975
- const maxDistance = 1000
1675
+ const pass = encoder.beginRenderPass({
1676
+ colorAttachments: [{
1677
+ view: this.pickTexture.createView(),
1678
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1679
+ loadOp: "clear",
1680
+ storeOp: "store",
1681
+ }],
1682
+ depthStencilAttachment: {
1683
+ view: this.pickDepthTexture.createView(),
1684
+ depthClearValue: 1.0,
1685
+ depthLoadOp: "clear",
1686
+ depthStoreOp: "store",
1687
+ },
1688
+ })
1689
+
1690
+ pass.setPipeline(this.pickPipeline)
1691
+ pass.setBindGroup(0, this.pickPerFrameBindGroup)
1976
1692
 
1977
1693
  this.forEachInstance((inst) => {
1978
- const model = inst.model
1979
- const materials = model.getMaterials()
1980
- if (materials.length === 0) return
1981
- const baseVertices = model.getVertices()
1982
- const indices = model.getIndices()
1983
- const skinning = model.getSkinning()
1984
- if (!baseVertices?.length || !indices || !skinning) return
1985
-
1986
- const vertices = new Float32Array(baseVertices.length)
1987
- const skinMatrices = model.getSkinMatrices()
1988
- for (let i = 0; i < baseVertices.length; i += 8) {
1989
- const vertexIndex = i / 8
1990
- const position = new Vec3(baseVertices[i], baseVertices[i + 1], baseVertices[i + 2])
1991
- const j0 = skinning.joints[vertexIndex * 4], j1 = skinning.joints[vertexIndex * 4 + 1], j2 = skinning.joints[vertexIndex * 4 + 2], j3 = skinning.joints[vertexIndex * 4 + 3]
1992
- 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
1993
- const ws = w0 + w1 + w2 + w3
1994
- const nw = ws > 0.0001 ? [w0 / ws, w1 / ws, w2 / ws, w3 / ws] : [1, 0, 0, 0]
1995
- let sp = new Vec3(0, 0, 0)
1996
- for (let j = 0; j < 4; j++) {
1997
- if (nw[j] <= 0) continue
1998
- const transformed = transformByMatrix(skinMatrices, [j0, j1, j2, j3][j] * 16, position)
1999
- sp = sp.add(transformed.scale(nw[j]))
2000
- }
2001
- vertices[i] = sp.x
2002
- vertices[i + 1] = sp.y
2003
- vertices[i + 2] = sp.z
2004
- vertices[i + 3] = baseVertices[i + 3]
2005
- vertices[i + 4] = baseVertices[i + 4]
2006
- vertices[i + 5] = baseVertices[i + 5]
2007
- vertices[i + 6] = baseVertices[i + 6]
2008
- vertices[i + 7] = baseVertices[i + 7]
1694
+ pass.setVertexBuffer(0, inst.vertexBuffer)
1695
+ pass.setVertexBuffer(1, inst.jointsBuffer)
1696
+ pass.setVertexBuffer(2, inst.weightsBuffer)
1697
+ pass.setIndexBuffer(inst.indexBuffer, "uint32")
1698
+ pass.setBindGroup(1, inst.pickPerInstanceBindGroup)
1699
+ for (const draw of inst.pickDrawCalls) {
1700
+ pass.setBindGroup(2, draw.bindGroup)
1701
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2009
1702
  }
1703
+ })
2010
1704
 
2011
- for (let i = 0; i < indices.length; i += 3) {
2012
- const idx0 = indices[i] * 8, idx1 = indices[i + 1] * 8, idx2 = indices[i + 2] * 8
2013
- const v0 = new Vec3(vertices[idx0], vertices[idx0 + 1], vertices[idx0 + 2])
2014
- const v1 = new Vec3(vertices[idx1], vertices[idx1 + 1], vertices[idx1 + 2])
2015
- const v2 = new Vec3(vertices[idx2], vertices[idx2 + 1], vertices[idx2 + 2])
2016
- let triangleMaterialIndex = -1
2017
- let indexOffset = 0
2018
- for (let matIdx = 0; matIdx < materials.length; matIdx++) {
2019
- if (i >= indexOffset && i < indexOffset + materials[matIdx].vertexCount) {
2020
- triangleMaterialIndex = matIdx
2021
- break
2022
- }
2023
- indexOffset += materials[matIdx].vertexCount
2024
- }
2025
- if (triangleMaterialIndex === -1) continue
2026
- const edge1 = v1.subtract(v0), edge2 = v2.subtract(v0), h = rayDirection.cross(edge2), a = edge1.dot(h)
2027
- if (Math.abs(a) < 0.0001) continue
2028
- const f = 1 / a, s = rayOrigin.subtract(v0), u = f * s.dot(h)
2029
- if (u < 0 || u > 1) continue
2030
- const q = s.cross(edge1), v = f * rayDirection.dot(q)
2031
- if (v < 0 || u + v > 1) continue
2032
- const t = f * edge2.dot(q)
2033
- if (t <= 0.0001 || t >= maxDistance) continue
2034
- const triangleNormal = edge1.cross(edge2).normalize()
2035
- if (triangleNormal.dot(rayDirection) >= 0) continue
2036
- if (!closest || t < closest.distance) {
2037
- closest = { modelName: inst.name, materialName: materials[triangleMaterialIndex].name, distance: t }
1705
+ pass.end()
1706
+
1707
+ // Copy the single pixel under cursor to readback buffer
1708
+ const px = Math.min(this.pendingPick.x, this.pickTexture.width - 1)
1709
+ const py = Math.min(this.pendingPick.y, this.pickTexture.height - 1)
1710
+ encoder.copyTextureToBuffer(
1711
+ { texture: this.pickTexture, origin: { x: Math.max(0, px), y: Math.max(0, py) } },
1712
+ { buffer: this.pickReadbackBuffer, bytesPerRow: 256 },
1713
+ { width: 1, height: 1 }
1714
+ )
1715
+ }
1716
+
1717
+ private async resolvePickResult(screenX: number, screenY: number): Promise<void> {
1718
+ if (!this.onRaycast) return
1719
+ await this.pickReadbackBuffer.mapAsync(GPUMapMode.READ)
1720
+ const data = new Uint8Array(this.pickReadbackBuffer.getMappedRange())
1721
+ const modelId = data[0]
1722
+ const materialId = data[1]
1723
+ this.pickReadbackBuffer.unmap()
1724
+
1725
+ if (modelId === 0) {
1726
+ this.onRaycast("", null, screenX, screenY)
1727
+ return
1728
+ }
1729
+
1730
+ // Find model by 1-based index
1731
+ let idx = 1
1732
+ let hitModel = ""
1733
+ for (const [name] of this.modelInstances) {
1734
+ if (idx === modelId) { hitModel = name; break }
1735
+ idx++
1736
+ }
1737
+
1738
+ // Find material by 1-based index (skipping zero-vertex materials)
1739
+ let hitMaterial: string | null = null
1740
+ if (hitModel) {
1741
+ const inst = this.modelInstances.get(hitModel)
1742
+ if (inst) {
1743
+ const materials = inst.model.getMaterials()
1744
+ let matIdx = 0
1745
+ for (const mat of materials) {
1746
+ if (mat.vertexCount === 0) continue
1747
+ matIdx++
1748
+ if (matIdx === materialId) { hitMaterial = mat.name; break }
2038
1749
  }
2039
1750
  }
2040
- })
2041
-
2042
- if (this.onRaycast) {
2043
- const hit = closest as { modelName: string; materialName: string; distance: number } | null
2044
- this.onRaycast(hit?.modelName ?? "", hit?.materialName ?? null, screenX, screenY)
2045
1751
  }
1752
+
1753
+ this.onRaycast(hitModel, hitMaterial, screenX, screenY)
2046
1754
  }
2047
1755
 
2048
1756
  public render() {
@@ -2052,18 +1760,29 @@ export class Engine {
2052
1760
  const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
2053
1761
  this.lastFrameTime = currentTime
2054
1762
 
2055
- this.updateCameraUniforms()
2056
1763
  this.updateRenderTarget()
2057
1764
 
2058
1765
  const hasModels = this.modelInstances.size > 0
2059
1766
  if (hasModels) {
2060
1767
  this.updateInstances(deltaTime)
2061
1768
  this.updateSkinMatrices()
1769
+ // Update camera target from bound model (bone not found → 0,0,0 + offset)
1770
+ if (this.cameraTargetModel) {
1771
+ const pos = this.cameraTargetModel.getBoneWorldPosition(this.cameraTargetBoneName)
1772
+ const px = pos?.x ?? 0
1773
+ const py = pos?.y ?? 0
1774
+ const pz = pos?.z ?? 0
1775
+ this.camera.target.x = px + this.cameraTargetOffset.x
1776
+ this.camera.target.y = py + this.cameraTargetOffset.y
1777
+ this.camera.target.z = pz + this.cameraTargetOffset.z
1778
+ }
2062
1779
  }
2063
- if (this.groundMode === "shadow") this.updateShadowLightVP()
1780
+
1781
+ this.updateCameraUniforms()
1782
+ if (this.hasGround) this.updateShadowLightVP()
2064
1783
 
2065
1784
  const encoder = this.device.createCommandEncoder()
2066
- if (hasModels && this.groundMode === "shadow" && this.shadowMapDepthView) {
1785
+ if (hasModels && this.hasGround && this.shadowMapDepthView) {
2067
1786
  const sp = encoder.beginRenderPass({
2068
1787
  colorAttachments: [],
2069
1788
  depthStencilAttachment: {
@@ -2079,14 +1798,29 @@ export class Engine {
2079
1798
  }
2080
1799
 
2081
1800
  const pass = encoder.beginRenderPass(this.renderPassDescriptor)
2082
- if (hasModels) this.forEachInstance((inst) => this.renderOneModel(pass, inst, false))
2083
- if (this.groundHasReflections) this.renderGround(pass)
2084
-
1801
+ if (hasModels) this.forEachInstance((inst) => this.renderOneModel(pass, inst))
1802
+ if (this.hasGround) this.renderGround(pass)
2085
1803
  pass.end()
1804
+
1805
+ const pick = this.pendingPick
1806
+ if (pick && hasModels) this.renderPickPass(encoder)
1807
+
2086
1808
  this.device.queue.submit([encoder.finish()])
1809
+
1810
+ if (pick) {
1811
+ this.pendingPick = null
1812
+ const dpr = window.devicePixelRatio || 1
1813
+ this.resolvePickResult(pick.x / dpr, pick.y / dpr)
1814
+ }
1815
+
2087
1816
  this.updateStats(performance.now() - currentTime)
2088
1817
  }
2089
1818
 
1819
+ private updateRenderTarget() {
1820
+ const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
1821
+ colorAttachment.resolveTarget = this.context.getCurrentTexture().createView()
1822
+ }
1823
+
2090
1824
  private drawInstanceShadow(sp: GPURenderPassEncoder, inst: ModelInstance): void {
2091
1825
  sp.setBindGroup(0, inst.shadowBindGroup)
2092
1826
  sp.setVertexBuffer(0, inst.vertexBuffer)
@@ -2098,50 +1832,42 @@ export class Engine {
2098
1832
  }
2099
1833
  }
2100
1834
 
2101
- private renderOneModel(pass: GPURenderPassEncoder, inst: ModelInstance, useReflection: boolean, mirrorMatrix?: Mat4): void {
2102
- pass.setVertexBuffer(0, inst.vertexBuffer)
2103
- pass.setVertexBuffer(1, inst.jointsBuffer)
2104
- pass.setVertexBuffer(2, inst.weightsBuffer)
2105
- pass.setIndexBuffer(inst.indexBuffer, "uint32")
2106
-
2107
- if (useReflection && mirrorMatrix) {
2108
- this.writeMirrorTransformedSkinMatrices(inst, mirrorMatrix)
2109
- pass.setPipeline(this.reflectionPipeline)
2110
- for (const draw of inst.drawCalls) {
2111
- if (draw.type === "opaque" && this.shouldRenderDrawCall(inst, draw)) {
2112
- pass.setBindGroup(0, draw.bindGroup)
2113
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2114
- }
2115
- }
2116
- this.renderEyes(pass, inst, true)
2117
- this.renderHair(pass, inst, true)
2118
- for (const draw of inst.drawCalls) {
2119
- if (draw.type === "transparent" && this.shouldRenderDrawCall(inst, draw)) {
2120
- pass.setBindGroup(0, draw.bindGroup)
2121
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2122
- }
2123
- }
2124
- this.drawOutlines(pass, inst, true, true)
2125
- return
2126
- }
2127
-
2128
- pass.setPipeline(this.modelPipeline)
1835
+ private drawOpaque(pass: GPURenderPassEncoder, inst: ModelInstance, pipeline: GPURenderPipeline): void {
1836
+ pass.setPipeline(pipeline)
2129
1837
  for (const draw of inst.drawCalls) {
2130
1838
  if (draw.type === "opaque" && this.shouldRenderDrawCall(inst, draw)) {
2131
- pass.setBindGroup(0, draw.bindGroup)
1839
+ pass.setBindGroup(2, draw.bindGroup)
2132
1840
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2133
1841
  }
2134
1842
  }
2135
- this.renderEyes(pass, inst, false)
2136
- this.drawOutlines(pass, inst, false)
2137
- this.renderHair(pass, inst, false)
2138
- pass.setPipeline(this.modelPipeline)
1843
+ }
1844
+
1845
+ private drawTransparent(pass: GPURenderPassEncoder, inst: ModelInstance, pipeline: GPURenderPipeline): void {
1846
+ pass.setPipeline(pipeline)
2139
1847
  for (const draw of inst.drawCalls) {
2140
1848
  if (draw.type === "transparent" && this.shouldRenderDrawCall(inst, draw)) {
2141
- pass.setBindGroup(0, draw.bindGroup)
1849
+ pass.setBindGroup(2, draw.bindGroup)
2142
1850
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2143
1851
  }
2144
1852
  }
1853
+ }
1854
+
1855
+ private bindMainGroups(pass: GPURenderPassEncoder, inst: ModelInstance): void {
1856
+ pass.setBindGroup(0, this.perFrameBindGroup)
1857
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
1858
+ }
1859
+
1860
+ private renderOneModel(pass: GPURenderPassEncoder, inst: ModelInstance): void {
1861
+ pass.setVertexBuffer(0, inst.vertexBuffer)
1862
+ pass.setVertexBuffer(1, inst.jointsBuffer)
1863
+ pass.setVertexBuffer(2, inst.weightsBuffer)
1864
+ pass.setIndexBuffer(inst.indexBuffer, "uint32")
1865
+
1866
+ this.bindMainGroups(pass, inst)
1867
+ this.drawOpaque(pass, inst, this.modelPipeline)
1868
+ this.drawOutlines(pass, inst, false)
1869
+ this.bindMainGroups(pass, inst)
1870
+ this.drawTransparent(pass, inst, this.modelPipeline)
2145
1871
  this.drawOutlines(pass, inst, true)
2146
1872
  }
2147
1873
 
@@ -2158,15 +1884,6 @@ export class Engine {
2158
1884
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
2159
1885
  }
2160
1886
 
2161
- private updateRenderTarget() {
2162
- // Update render target to use current canvas texture
2163
- const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
2164
- if (this.sampleCount > 1) {
2165
- colorAttachment.resolveTarget = this.context.getCurrentTexture().createView()
2166
- } else {
2167
- colorAttachment.view = this.context.getCurrentTexture().createView()
2168
- }
2169
- }
2170
1887
 
2171
1888
  private updateSkinMatrices() {
2172
1889
  this.forEachInstance((inst) => {
@@ -2181,13 +1898,14 @@ export class Engine {
2181
1898
  })
2182
1899
  }
2183
1900
 
2184
- private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, transparent: boolean, useReflectionPipeline = false) {
2185
- if (useReflectionPipeline) return
1901
+ private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, transparent: boolean) {
2186
1902
  pass.setPipeline(this.outlinePipeline)
1903
+ pass.setBindGroup(0, this.outlinePerFrameBindGroup)
1904
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
2187
1905
  const outlineType: DrawCallType = transparent ? "transparent-outline" : "opaque-outline"
2188
1906
  for (const draw of inst.drawCalls) {
2189
1907
  if (draw.type === outlineType && this.shouldRenderDrawCall(inst, draw)) {
2190
- pass.setBindGroup(0, draw.bindGroup)
1908
+ pass.setBindGroup(2, draw.bindGroup)
2191
1909
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2192
1910
  }
2193
1911
  }
@@ -2218,42 +1936,4 @@ export class Engine {
2218
1936
  }
2219
1937
  }
2220
1938
 
2221
- private createMirrorMatrix(planeNormal: Vec3, planeDistance: number): Mat4 {
2222
- // Create reflection matrix across a plane
2223
- const n = planeNormal.normalize()
2224
-
2225
- return new Mat4(
2226
- new Float32Array([
2227
- 1 - 2 * n.x * n.x,
2228
- -2 * n.x * n.y,
2229
- -2 * n.x * n.z,
2230
- 0,
2231
- -2 * n.y * n.x,
2232
- 1 - 2 * n.y * n.y,
2233
- -2 * n.y * n.z,
2234
- 0,
2235
- -2 * n.z * n.x,
2236
- -2 * n.z * n.y,
2237
- 1 - 2 * n.z * n.z,
2238
- 0,
2239
- -2 * planeDistance * n.x,
2240
- -2 * planeDistance * n.y,
2241
- -2 * planeDistance * n.z,
2242
- 1,
2243
- ])
2244
- )
2245
- }
2246
-
2247
- private writeMirrorTransformedSkinMatrices(inst: ModelInstance, mirrorMatrix: Mat4) {
2248
- const originalMatrices = inst.model.getSkinMatrices()
2249
- const transformedMatrices = new Float32Array(originalMatrices.length)
2250
- for (let i = 0; i < originalMatrices.length; i += 16) {
2251
- const boneMatrixValues = new Float32Array(16)
2252
- for (let j = 0; j < 16; j++) boneMatrixValues[j] = originalMatrices[i + j]
2253
- const boneMatrix = new Mat4(boneMatrixValues)
2254
- const transformed = mirrorMatrix.multiply(boneMatrix)
2255
- for (let j = 0; j < 16; j++) transformedMatrices[i + j] = transformed.values[j]
2256
- }
2257
- this.device.queue.writeBuffer(inst.skinMatrixBuffer, 0, transformedMatrices)
2258
- }
2259
1939
  }