reze-engine 0.10.1 → 0.11.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/README.md +113 -20
- package/dist/asset-reader.d.ts +16 -0
- package/dist/asset-reader.d.ts.map +1 -0
- package/dist/asset-reader.js +74 -0
- package/dist/engine.d.ts +179 -36
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1133 -321
- package/dist/folder-upload.d.ts +24 -0
- package/dist/folder-upload.d.ts.map +1 -0
- package/dist/folder-upload.js +50 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/model.d.ts +6 -1
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +34 -2
- package/dist/pmx-loader.d.ts +3 -0
- package/dist/pmx-loader.d.ts.map +1 -1
- package/dist/pmx-loader.js +9 -2
- package/dist/shaders/body.d.ts +2 -0
- package/dist/shaders/body.d.ts.map +1 -0
- package/dist/shaders/body.js +209 -0
- package/dist/shaders/classify.d.ts +4 -0
- package/dist/shaders/classify.d.ts.map +1 -0
- package/dist/shaders/classify.js +12 -0
- package/dist/shaders/cloth_rough.d.ts +2 -0
- package/dist/shaders/cloth_rough.d.ts.map +1 -0
- package/dist/shaders/cloth_rough.js +172 -0
- package/dist/shaders/cloth_smooth.d.ts +2 -0
- package/dist/shaders/cloth_smooth.d.ts.map +1 -0
- package/dist/shaders/cloth_smooth.js +171 -0
- package/dist/shaders/default.d.ts +2 -0
- package/dist/shaders/default.d.ts.map +1 -0
- package/dist/shaders/default.js +168 -0
- package/dist/shaders/dfg_lut.d.ts +4 -0
- package/dist/shaders/dfg_lut.d.ts.map +1 -0
- package/dist/shaders/dfg_lut.js +125 -0
- package/dist/shaders/eye.d.ts +2 -0
- package/dist/shaders/eye.d.ts.map +1 -0
- package/dist/shaders/eye.js +142 -0
- package/dist/shaders/face.d.ts +2 -0
- package/dist/shaders/face.d.ts.map +1 -0
- package/dist/shaders/face.js +211 -0
- package/dist/shaders/hair.d.ts +2 -0
- package/dist/shaders/hair.d.ts.map +1 -0
- package/dist/shaders/hair.js +186 -0
- package/dist/shaders/ltc_mag_lut.d.ts +3 -0
- package/dist/shaders/ltc_mag_lut.d.ts.map +1 -0
- package/dist/shaders/ltc_mag_lut.js +1033 -0
- package/dist/shaders/metal.d.ts +2 -0
- package/dist/shaders/metal.d.ts.map +1 -0
- package/dist/shaders/metal.js +171 -0
- package/dist/shaders/nodes.d.ts +2 -0
- package/dist/shaders/nodes.d.ts.map +1 -0
- package/dist/shaders/nodes.js +423 -0
- package/dist/shaders/stockings.d.ts +2 -0
- package/dist/shaders/stockings.d.ts.map +1 -0
- package/dist/shaders/stockings.js +229 -0
- package/package.json +1 -1
- package/src/asset-reader.ts +79 -0
- package/src/engine.ts +1352 -383
- package/src/folder-upload.ts +59 -0
- package/src/index.ts +12 -2
- package/src/model.ts +34 -2
- package/src/pmx-loader.ts +11 -2
- package/src/shaders/body.ts +211 -0
- package/src/shaders/classify.ts +25 -0
- package/src/shaders/cloth_rough.ts +174 -0
- package/src/shaders/cloth_smooth.ts +173 -0
- package/src/shaders/default.ts +169 -0
- package/src/shaders/dfg_lut.ts +127 -0
- package/src/shaders/eye.ts +143 -0
- package/src/shaders/face.ts +213 -0
- package/src/shaders/hair.ts +188 -0
- package/src/shaders/ltc_mag_lut.ts +1035 -0
- package/src/shaders/metal.ts +173 -0
- package/src/shaders/nodes.ts +424 -0
- package/src/shaders/stockings.ts +231 -0
package/src/engine.ts
CHANGED
|
@@ -3,33 +3,118 @@ import { Mat4, Vec3 } from "./math"
|
|
|
3
3
|
import { Model } from "./model"
|
|
4
4
|
import { PmxLoader } from "./pmx-loader"
|
|
5
5
|
import { Physics, type PhysicsOptions } from "./physics"
|
|
6
|
+
import {
|
|
7
|
+
createFetchAssetReader,
|
|
8
|
+
createFileMapAssetReader,
|
|
9
|
+
deriveBasePathFromPmxPath,
|
|
10
|
+
fileListToMap,
|
|
11
|
+
findFirstPmxFileInList,
|
|
12
|
+
joinAssetPath,
|
|
13
|
+
normalizeAssetPath,
|
|
14
|
+
type AssetReader,
|
|
15
|
+
} from "./asset-reader"
|
|
16
|
+
import { DEFAULT_SHADER_WGSL } from "./shaders/default"
|
|
17
|
+
import { DFG_LUT_SIZE, DFG_LUT_WGSL } from "./shaders/dfg_lut"
|
|
18
|
+
import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut"
|
|
19
|
+
import { FACE_SHADER_WGSL } from "./shaders/face"
|
|
20
|
+
import { HAIR_SHADER_WGSL } from "./shaders/hair"
|
|
21
|
+
import { CLOTH_SMOOTH_SHADER_WGSL } from "./shaders/cloth_smooth"
|
|
22
|
+
import { CLOTH_ROUGH_SHADER_WGSL } from "./shaders/cloth_rough"
|
|
23
|
+
import { METAL_SHADER_WGSL } from "./shaders/metal"
|
|
24
|
+
import { BODY_SHADER_WGSL } from "./shaders/body"
|
|
25
|
+
import { EYE_SHADER_WGSL } from "./shaders/eye"
|
|
26
|
+
import { STOCKINGS_SHADER_WGSL } from "./shaders/stockings"
|
|
27
|
+
import { resolvePreset, type MaterialPreset, type MaterialPresetMap } from "./shaders/classify"
|
|
6
28
|
|
|
7
29
|
export type RaycastCallback = (modelName: string, material: string | null, screenX: number, screenY: number) => void
|
|
8
30
|
|
|
31
|
+
/** Select a folder (webkitdirectory) and pass FileList or File[]; pmxFile picks which .pmx when several exist. */
|
|
32
|
+
export type LoadModelFromFilesOptions = {
|
|
33
|
+
files: FileList | File[]
|
|
34
|
+
pmxFile?: File
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Blender-style scene config. World = environment lighting (ambient);
|
|
38
|
+
// Sun = the single directional lamp; Camera = view framing.
|
|
39
|
+
export type WorldOptions = {
|
|
40
|
+
/** Linear scene-referred color of the World Background (Blender: World > Surface > Color). */
|
|
41
|
+
color?: Vec3
|
|
42
|
+
/** Multiplier on world color (Blender: World > Surface > Strength). */
|
|
43
|
+
strength?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type SunOptions = {
|
|
47
|
+
/** Linear color of the sun lamp (Blender: Light > Color). */
|
|
48
|
+
color?: Vec3
|
|
49
|
+
/** Lamp power in Blender units (Blender: Light > Strength). */
|
|
50
|
+
strength?: number
|
|
51
|
+
/** Direction sunlight travels (points FROM sun TO scene, Blender: -light.rotation.Z). */
|
|
52
|
+
direction?: Vec3
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type CameraOptions = {
|
|
56
|
+
/** Orbit distance from target. */
|
|
57
|
+
distance?: number
|
|
58
|
+
/** World-space orbit center. */
|
|
59
|
+
target?: Vec3
|
|
60
|
+
/** Vertical field of view in radians. */
|
|
61
|
+
fov?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** EEVEE Bloom panel (3D Viewport > Render > Bloom). Fields map 1:1 to Blender's UI. */
|
|
65
|
+
export type BloomOptions = {
|
|
66
|
+
enabled: boolean
|
|
67
|
+
threshold: number
|
|
68
|
+
knee: number
|
|
69
|
+
radius: number
|
|
70
|
+
color: Vec3
|
|
71
|
+
intensity: number
|
|
72
|
+
clamp: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const DEFAULT_BLOOM_OPTIONS: BloomOptions = {
|
|
76
|
+
enabled: true,
|
|
77
|
+
threshold: 0.5,
|
|
78
|
+
knee: 0.5,
|
|
79
|
+
radius: 4.0,
|
|
80
|
+
color: new Vec3(1.0, 0.7247558832168579, 0.6487361788749695),
|
|
81
|
+
intensity: 0.03,
|
|
82
|
+
clamp: 0.0,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Blender Color Management / View (rendering.txt: Filmic, exposure, gamma). `look` is reserved for future curve tweaks. */
|
|
86
|
+
export type ViewTransformOptions = {
|
|
87
|
+
/** Stops applied before Filmic: `linear *= 2^exposure` (Blender default often ~−0.3). */
|
|
88
|
+
exposure: number
|
|
89
|
+
/** After Filmic, display gamma (`pow(rgb, 1/gamma)`). */
|
|
90
|
+
gamma: number
|
|
91
|
+
look: "default" | "medium_high_contrast"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const DEFAULT_VIEW_TRANSFORM: ViewTransformOptions = {
|
|
95
|
+
exposure: -0.30000001192092896,
|
|
96
|
+
gamma: 1.0,
|
|
97
|
+
look: "medium_high_contrast",
|
|
98
|
+
}
|
|
99
|
+
|
|
9
100
|
export type EngineOptions = {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
101
|
+
world?: WorldOptions
|
|
102
|
+
sun?: SunOptions
|
|
103
|
+
camera?: CameraOptions
|
|
104
|
+
/** Initial EEVEE-style bloom; tune at runtime with `setBloomOptions`. */
|
|
105
|
+
bloom?: Partial<BloomOptions>
|
|
106
|
+
/** View transform (exposure/gamma) applied in composite before/after Filmic. */
|
|
107
|
+
view?: Partial<ViewTransformOptions>
|
|
17
108
|
onRaycast?: RaycastCallback
|
|
18
109
|
physicsOptions?: PhysicsOptions
|
|
19
|
-
shadowLightDirection?: Vec3
|
|
20
110
|
}
|
|
21
111
|
|
|
22
112
|
export const DEFAULT_ENGINE_OPTIONS = {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
rimLightIntensity: 0.4,
|
|
27
|
-
cameraDistance: 26.6,
|
|
28
|
-
cameraTarget: new Vec3(0, 12.5, 0),
|
|
29
|
-
cameraFov: Math.PI / 4,
|
|
113
|
+
world: { color: new Vec3(0.4014, 0.4944, 0.647), strength: 0.3 },
|
|
114
|
+
sun: { color: new Vec3(1.0, 1.0, 1.0), strength: 2.0, direction: new Vec3(-0.0873, -0.3844, 0.919) },
|
|
115
|
+
camera: { distance: 26.6, target: new Vec3(0, 12.5, 0), fov: Math.PI / 4 },
|
|
30
116
|
onRaycast: undefined,
|
|
31
117
|
physicsOptions: { constraintSolverKeywords: ["胸"] },
|
|
32
|
-
shadowLightDirection: new Vec3(0.12, -1, 0.16),
|
|
33
118
|
}
|
|
34
119
|
|
|
35
120
|
export interface EngineStats {
|
|
@@ -37,12 +122,7 @@ export interface EngineStats {
|
|
|
37
122
|
frameTime: number // ms
|
|
38
123
|
}
|
|
39
124
|
|
|
40
|
-
type DrawCallType =
|
|
41
|
-
| "opaque"
|
|
42
|
-
| "transparent"
|
|
43
|
-
| "ground"
|
|
44
|
-
| "opaque-outline"
|
|
45
|
-
| "transparent-outline"
|
|
125
|
+
type DrawCallType = "opaque" | "transparent" | "ground" | "opaque-outline" | "transparent-outline"
|
|
46
126
|
|
|
47
127
|
interface DrawCall {
|
|
48
128
|
type: DrawCallType
|
|
@@ -50,6 +130,7 @@ interface DrawCall {
|
|
|
50
130
|
firstIndex: number
|
|
51
131
|
bindGroup: GPUBindGroup
|
|
52
132
|
materialName: string
|
|
133
|
+
preset: MaterialPreset
|
|
53
134
|
}
|
|
54
135
|
|
|
55
136
|
interface PickDrawCall {
|
|
@@ -62,6 +143,9 @@ interface ModelInstance {
|
|
|
62
143
|
name: string
|
|
63
144
|
model: Model
|
|
64
145
|
basePath: string
|
|
146
|
+
assetReader: AssetReader
|
|
147
|
+
gpuBuffers: GPUBuffer[]
|
|
148
|
+
textureCacheKeys: string[]
|
|
65
149
|
vertexBuffer: GPUBuffer
|
|
66
150
|
indexBuffer: GPUBuffer
|
|
67
151
|
jointsBuffer: GPUBuffer
|
|
@@ -74,6 +158,7 @@ interface ModelInstance {
|
|
|
74
158
|
pickPerInstanceBindGroup: GPUBindGroup
|
|
75
159
|
pickDrawCalls: PickDrawCall[]
|
|
76
160
|
hiddenMaterials: Set<string>
|
|
161
|
+
materialPresets: MaterialPresetMap | undefined
|
|
77
162
|
physics: Physics | null
|
|
78
163
|
vertexBufferNeedsUpdate: boolean
|
|
79
164
|
}
|
|
@@ -95,15 +180,24 @@ export class Engine {
|
|
|
95
180
|
private camera!: Camera
|
|
96
181
|
private cameraUniformBuffer!: GPUBuffer
|
|
97
182
|
private cameraMatrixData = new Float32Array(36)
|
|
98
|
-
|
|
99
|
-
private
|
|
100
|
-
private
|
|
183
|
+
// Blender-style scene config groups (resolved from EngineOptions)
|
|
184
|
+
private world!: { color: Vec3; strength: number }
|
|
185
|
+
private sun!: { color: Vec3; strength: number; direction: Vec3 }
|
|
186
|
+
private cameraConfig!: { distance: number; target: Vec3; fov: number }
|
|
101
187
|
private lightUniformBuffer!: GPUBuffer
|
|
102
188
|
private lightData = new Float32Array(64)
|
|
103
189
|
private lightCount = 0
|
|
104
190
|
private resizeObserver: ResizeObserver | null = null
|
|
105
191
|
private depthTexture!: GPUTexture
|
|
106
192
|
private modelPipeline!: GPURenderPipeline
|
|
193
|
+
private facePipeline!: GPURenderPipeline
|
|
194
|
+
private hairPipeline!: GPURenderPipeline
|
|
195
|
+
private clothSmoothPipeline!: GPURenderPipeline
|
|
196
|
+
private clothRoughPipeline!: GPURenderPipeline
|
|
197
|
+
private metalPipeline!: GPURenderPipeline
|
|
198
|
+
private bodyPipeline!: GPURenderPipeline
|
|
199
|
+
private eyePipeline!: GPURenderPipeline
|
|
200
|
+
private stockingsPipeline!: GPURenderPipeline
|
|
107
201
|
private groundShadowPipeline!: GPURenderPipeline
|
|
108
202
|
private groundShadowBindGroupLayout!: GPUBindGroupLayout
|
|
109
203
|
private outlinePipeline!: GPURenderPipeline
|
|
@@ -115,22 +209,58 @@ export class Engine {
|
|
|
115
209
|
private perFrameBindGroup!: GPUBindGroup
|
|
116
210
|
private outlinePerFrameBindGroup!: GPUBindGroup
|
|
117
211
|
private multisampleTexture!: GPUTexture
|
|
212
|
+
private hdrResolveTexture!: GPUTexture
|
|
118
213
|
private static readonly MULTISAMPLE_COUNT = 4
|
|
214
|
+
private static readonly HDR_FORMAT: GPUTextureFormat = "rgba16float"
|
|
119
215
|
private renderPassDescriptor!: GPURenderPassDescriptor
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
private
|
|
123
|
-
private
|
|
124
|
-
private
|
|
125
|
-
//
|
|
126
|
-
private
|
|
216
|
+
private compositePassDescriptor!: GPURenderPassDescriptor
|
|
217
|
+
private compositePipeline!: GPURenderPipeline
|
|
218
|
+
private compositeBindGroupLayout!: GPUBindGroupLayout
|
|
219
|
+
private compositeBindGroup!: GPUBindGroup
|
|
220
|
+
private compositeUniformBuffer!: GPUBuffer
|
|
221
|
+
// [exposure, gamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
|
|
222
|
+
private readonly compositeUniformData = new Float32Array(8)
|
|
223
|
+
|
|
224
|
+
// EEVEE-style bloom pyramid (mirrors Blender 3.6 effect_bloom_frag.glsl):
|
|
225
|
+
// blit (HDR → half-res, 4-tap Karis + soft threshold/knee)
|
|
226
|
+
// N-1 downsamples (13-tap Jimenez/COD box filter, 5 group averages)
|
|
227
|
+
// N-1 upsamples (9-tap tent, additively combined with corresponding downsample mip)
|
|
228
|
+
// composite adds bloomUp mip 0 × (color × intensity) to HDR before Filmic.
|
|
229
|
+
// Matches EEVEE energy: tint/intensity applied at composite, not prefilter.
|
|
230
|
+
private bloomSampler!: GPUSampler
|
|
231
|
+
private bloomBlitUniformBuffer!: GPUBuffer
|
|
232
|
+
private bloomUpsampleUniformBuffer!: GPUBuffer
|
|
233
|
+
private readonly bloomBlitUniformData = new Float32Array(4)
|
|
234
|
+
private readonly bloomUpsampleUniformData = new Float32Array(4)
|
|
235
|
+
private bloomBlitPipeline!: GPURenderPipeline
|
|
236
|
+
private bloomDownsamplePipeline!: GPURenderPipeline
|
|
237
|
+
private bloomUpsamplePipeline!: GPURenderPipeline
|
|
238
|
+
private bloomBlitBindGroupLayout!: GPUBindGroupLayout
|
|
239
|
+
private bloomDownsampleBindGroupLayout!: GPUBindGroupLayout
|
|
240
|
+
private bloomUpsampleBindGroupLayout!: GPUBindGroupLayout
|
|
241
|
+
private bloomDownTexture!: GPUTexture
|
|
242
|
+
private bloomUpTexture!: GPUTexture
|
|
243
|
+
private bloomMipCount = 0
|
|
244
|
+
private bloomDownMipViews: GPUTextureView[] = []
|
|
245
|
+
private bloomUpMipViews: GPUTextureView[] = []
|
|
246
|
+
private bloomBlitBindGroup!: GPUBindGroup
|
|
247
|
+
private bloomDownsampleBindGroups: GPUBindGroup[] = []
|
|
248
|
+
private bloomUpsampleBindGroups: GPUBindGroup[] = []
|
|
249
|
+
/** Single-attachment pass; colorAttachments[0].view set per bloom step. */
|
|
250
|
+
private bloomPassDescriptor!: GPURenderPassDescriptor
|
|
251
|
+
private static readonly BLOOM_MAX_LEVELS = 7
|
|
127
252
|
|
|
128
253
|
// Ground properties (shadow only)
|
|
129
254
|
private groundVertexBuffer?: GPUBuffer
|
|
130
255
|
private groundIndexBuffer?: GPUBuffer
|
|
131
256
|
private hasGround = false
|
|
132
|
-
private shadowMapTexture
|
|
133
|
-
private shadowMapDepthView
|
|
257
|
+
private shadowMapTexture!: GPUTexture
|
|
258
|
+
private shadowMapDepthView!: GPUTextureView
|
|
259
|
+
private dfgLutTexture!: GPUTexture
|
|
260
|
+
private dfgLutView!: GPUTextureView
|
|
261
|
+
private ltcMagLutTexture!: GPUTexture
|
|
262
|
+
private ltcMagLutView!: GPUTextureView
|
|
263
|
+
private static readonly SHADOW_MAP_SIZE = 4096
|
|
134
264
|
private shadowDepthPipeline!: GPURenderPipeline
|
|
135
265
|
private shadowLightVPBuffer!: GPUBuffer
|
|
136
266
|
private shadowLightVPMatrix = new Float32Array(16)
|
|
@@ -141,7 +271,6 @@ export class Engine {
|
|
|
141
271
|
|
|
142
272
|
private onRaycast?: RaycastCallback
|
|
143
273
|
private physicsOptions: PhysicsOptions = DEFAULT_ENGINE_OPTIONS.physicsOptions
|
|
144
|
-
private shadowLightDirection: Vec3 = DEFAULT_ENGINE_OPTIONS.shadowLightDirection
|
|
145
274
|
private lastTouchTime = 0
|
|
146
275
|
private readonly DOUBLE_TAP_DELAY = 300
|
|
147
276
|
// GPU picking
|
|
@@ -180,24 +309,140 @@ export class Engine {
|
|
|
180
309
|
}
|
|
181
310
|
private animationFrameId: number | null = null
|
|
182
311
|
private renderLoopCallback: (() => void) | null = null
|
|
312
|
+
private bloomSettings!: BloomOptions
|
|
313
|
+
private viewTransform!: ViewTransformOptions
|
|
183
314
|
|
|
184
315
|
constructor(canvas: HTMLCanvasElement, options?: EngineOptions) {
|
|
185
316
|
this.canvas = canvas
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
317
|
+
const d = DEFAULT_ENGINE_OPTIONS
|
|
318
|
+
this.world = {
|
|
319
|
+
color: options?.world?.color ?? d.world.color,
|
|
320
|
+
strength: options?.world?.strength ?? d.world.strength,
|
|
321
|
+
}
|
|
322
|
+
this.sun = {
|
|
323
|
+
color: options?.sun?.color ?? d.sun.color,
|
|
324
|
+
strength: options?.sun?.strength ?? d.sun.strength,
|
|
325
|
+
direction: options?.sun?.direction ?? d.sun.direction,
|
|
326
|
+
}
|
|
327
|
+
this.cameraConfig = {
|
|
328
|
+
distance: options?.camera?.distance ?? d.camera.distance,
|
|
329
|
+
target: options?.camera?.target ?? d.camera.target,
|
|
330
|
+
fov: options?.camera?.fov ?? d.camera.fov,
|
|
331
|
+
}
|
|
332
|
+
this.onRaycast = options?.onRaycast
|
|
333
|
+
this.physicsOptions = options?.physicsOptions ?? d.physicsOptions
|
|
334
|
+
this.bloomSettings = Engine.mergeBloomDefaults(options?.bloom)
|
|
335
|
+
this.viewTransform = Engine.mergeViewTransformDefaults(options?.view)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Merge partial bloom with EEVEE defaults (same as constructor). */
|
|
339
|
+
static mergeBloomDefaults(partial?: Partial<BloomOptions>): BloomOptions {
|
|
340
|
+
const d = DEFAULT_BLOOM_OPTIONS
|
|
341
|
+
const c = partial?.color
|
|
342
|
+
return {
|
|
343
|
+
enabled: partial?.enabled ?? d.enabled,
|
|
344
|
+
threshold: partial?.threshold ?? d.threshold,
|
|
345
|
+
knee: partial?.knee ?? d.knee,
|
|
346
|
+
radius: partial?.radius ?? d.radius,
|
|
347
|
+
color: c ? new Vec3(c.x, c.y, c.z) : new Vec3(d.color.x, d.color.y, d.color.z),
|
|
348
|
+
intensity: partial?.intensity ?? d.intensity,
|
|
349
|
+
clamp: partial?.clamp ?? d.clamp,
|
|
198
350
|
}
|
|
199
351
|
}
|
|
200
352
|
|
|
353
|
+
static mergeViewTransformDefaults(partial?: Partial<ViewTransformOptions>): ViewTransformOptions {
|
|
354
|
+
const d = DEFAULT_VIEW_TRANSFORM
|
|
355
|
+
return {
|
|
356
|
+
exposure: partial?.exposure ?? d.exposure,
|
|
357
|
+
gamma: partial?.gamma ?? d.gamma,
|
|
358
|
+
look: partial?.look ?? d.look,
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Current bloom settings (Blender names; tint is a copied `Vec3`). */
|
|
363
|
+
getBloomOptions(): BloomOptions {
|
|
364
|
+
const b = this.bloomSettings
|
|
365
|
+
return {
|
|
366
|
+
enabled: b.enabled,
|
|
367
|
+
threshold: b.threshold,
|
|
368
|
+
knee: b.knee,
|
|
369
|
+
radius: b.radius,
|
|
370
|
+
color: new Vec3(b.color.x, b.color.y, b.color.z),
|
|
371
|
+
intensity: b.intensity,
|
|
372
|
+
clamp: b.clamp,
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
getViewTransformOptions(): ViewTransformOptions {
|
|
377
|
+
const v = this.viewTransform
|
|
378
|
+
return { exposure: v.exposure, gamma: v.gamma, look: v.look }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
setViewTransformOptions(patch: Partial<ViewTransformOptions>): void {
|
|
382
|
+
const v = this.viewTransform
|
|
383
|
+
if (patch.exposure !== undefined) v.exposure = patch.exposure
|
|
384
|
+
if (patch.gamma !== undefined) v.gamma = patch.gamma
|
|
385
|
+
if (patch.look !== undefined) v.look = patch.look
|
|
386
|
+
if (this.device && this.compositeUniformBuffer) {
|
|
387
|
+
this.writeCompositeViewUniforms()
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private writeCompositeViewUniforms(): void {
|
|
392
|
+
const v = this.viewTransform
|
|
393
|
+
const b = this.bloomSettings
|
|
394
|
+
const effIntensity = b.enabled ? b.intensity : 0.0
|
|
395
|
+
const u = this.compositeUniformData
|
|
396
|
+
u[0] = v.exposure
|
|
397
|
+
u[1] = Math.max(v.gamma, 1e-4)
|
|
398
|
+
u[2] = 0.0
|
|
399
|
+
u[3] = 0.0
|
|
400
|
+
u[4] = b.color.x
|
|
401
|
+
u[5] = b.color.y
|
|
402
|
+
u[6] = b.color.z
|
|
403
|
+
u[7] = effIntensity
|
|
404
|
+
this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Patch bloom; GPU uniforms update immediately if `init()` has run. */
|
|
408
|
+
setBloomOptions(patch: Partial<BloomOptions>): void {
|
|
409
|
+
const b = this.bloomSettings
|
|
410
|
+
if (patch.enabled !== undefined) b.enabled = patch.enabled
|
|
411
|
+
if (patch.threshold !== undefined) b.threshold = patch.threshold
|
|
412
|
+
if (patch.knee !== undefined) b.knee = patch.knee
|
|
413
|
+
if (patch.radius !== undefined) b.radius = patch.radius
|
|
414
|
+
if (patch.color !== undefined) {
|
|
415
|
+
b.color.x = patch.color.x
|
|
416
|
+
b.color.y = patch.color.y
|
|
417
|
+
b.color.z = patch.color.z
|
|
418
|
+
}
|
|
419
|
+
if (patch.intensity !== undefined) b.intensity = patch.intensity
|
|
420
|
+
if (patch.clamp !== undefined) b.clamp = patch.clamp
|
|
421
|
+
if (this.device && this.bloomBlitUniformBuffer) {
|
|
422
|
+
this.writeBloomUniforms()
|
|
423
|
+
this.writeCompositeViewUniforms()
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// EEVEE prefilter uniforms (blit stage) + upsample sample scale. Intensity/tint live in composite.
|
|
428
|
+
private writeBloomUniforms(): void {
|
|
429
|
+
const b = this.bloomSettings
|
|
430
|
+
const bu = this.bloomBlitUniformData
|
|
431
|
+
// EEVEE prefilter: threshold, knee, clamp (0 → disabled), _unused
|
|
432
|
+
bu[0] = b.threshold
|
|
433
|
+
bu[1] = b.knee
|
|
434
|
+
bu[2] = b.clamp
|
|
435
|
+
bu[3] = 0.0
|
|
436
|
+
this.device.queue.writeBuffer(this.bloomBlitUniformBuffer, 0, bu)
|
|
437
|
+
const us = this.bloomUpsampleUniformData
|
|
438
|
+
// Blender: bloom.radius directly controls the tent-filter sample scale in texel units.
|
|
439
|
+
us[0] = Math.max(0.5, b.radius)
|
|
440
|
+
us[1] = 0
|
|
441
|
+
us[2] = 0
|
|
442
|
+
us[3] = 0
|
|
443
|
+
this.device.queue.writeBuffer(this.bloomUpsampleUniformBuffer, 0, us)
|
|
444
|
+
}
|
|
445
|
+
|
|
201
446
|
// Step 1: Get WebGPU device and context
|
|
202
447
|
async init() {
|
|
203
448
|
const adapter = await navigator.gpu?.requestAdapter()
|
|
@@ -228,6 +473,78 @@ export class Engine {
|
|
|
228
473
|
Engine.instance = this
|
|
229
474
|
}
|
|
230
475
|
|
|
476
|
+
// One-shot bake of EEVEE's BRDF split-sum DFG LUT — ported from
|
|
477
|
+
// bsdf_lut_frag.glsl. Runs once per engine init; resulting 64×64 rg16float
|
|
478
|
+
// texture is sampled by every material shader via group(0) binding(9).
|
|
479
|
+
private bakeDfgLut() {
|
|
480
|
+
this.dfgLutTexture = this.device.createTexture({
|
|
481
|
+
label: "DFG LUT",
|
|
482
|
+
size: [DFG_LUT_SIZE, DFG_LUT_SIZE],
|
|
483
|
+
format: "rg16float",
|
|
484
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
485
|
+
})
|
|
486
|
+
this.dfgLutView = this.dfgLutTexture.createView()
|
|
487
|
+
|
|
488
|
+
const module = this.device.createShaderModule({ label: "DFG LUT bake", code: DFG_LUT_WGSL })
|
|
489
|
+
const pipeline = this.device.createRenderPipeline({
|
|
490
|
+
label: "DFG LUT bake pipeline",
|
|
491
|
+
layout: "auto",
|
|
492
|
+
vertex: { module, entryPoint: "vs" },
|
|
493
|
+
fragment: { module, entryPoint: "fs", targets: [{ format: "rg16float" }] },
|
|
494
|
+
primitive: { topology: "triangle-list" },
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
const enc = this.device.createCommandEncoder({ label: "DFG LUT bake encoder" })
|
|
498
|
+
const pass = enc.beginRenderPass({
|
|
499
|
+
colorAttachments: [
|
|
500
|
+
{ view: this.dfgLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
|
|
501
|
+
],
|
|
502
|
+
})
|
|
503
|
+
pass.setPipeline(pipeline)
|
|
504
|
+
pass.draw(3, 1, 0, 0)
|
|
505
|
+
pass.end()
|
|
506
|
+
this.device.queue.submit([enc.finish()])
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Upload Blender's static LTC GGX magnitude LUT (eevee_lut.c ltc_mag_ggx[]).
|
|
510
|
+
// Pairs with the DFG LUT to form ltc_brdf_scale — closure_eval_glossy_lib.glsl:79-81.
|
|
511
|
+
private uploadLtcMagLut() {
|
|
512
|
+
this.ltcMagLutTexture = this.device.createTexture({
|
|
513
|
+
label: "LTC mag LUT",
|
|
514
|
+
size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
|
|
515
|
+
format: "rg16float",
|
|
516
|
+
usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
|
|
517
|
+
})
|
|
518
|
+
this.ltcMagLutView = this.ltcMagLutTexture.createView()
|
|
519
|
+
|
|
520
|
+
// Float32 → float16 bits. rg16float writeTexture expects packed half floats.
|
|
521
|
+
const n = LTC_MAG_LUT_DATA.length
|
|
522
|
+
const half = new Uint16Array(n)
|
|
523
|
+
const f32 = new Float32Array(1)
|
|
524
|
+
const u32 = new Uint32Array(f32.buffer)
|
|
525
|
+
for (let i = 0; i < n; i++) {
|
|
526
|
+
f32[0] = LTC_MAG_LUT_DATA[i]
|
|
527
|
+
const x = u32[0]
|
|
528
|
+
const sign = (x >>> 16) & 0x8000
|
|
529
|
+
let exp = ((x >>> 23) & 0xff) - 127 + 15
|
|
530
|
+
let mant = x & 0x7fffff
|
|
531
|
+
if (exp <= 0) {
|
|
532
|
+
half[i] = sign // flush tiny values to signed zero (data here is in [0, ~1])
|
|
533
|
+
} else if (exp >= 31) {
|
|
534
|
+
half[i] = sign | 0x7c00 // inf
|
|
535
|
+
} else {
|
|
536
|
+
half[i] = sign | (exp << 10) | (mant >>> 13)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
this.device.queue.writeTexture(
|
|
541
|
+
{ texture: this.ltcMagLutTexture },
|
|
542
|
+
half,
|
|
543
|
+
{ bytesPerRow: LTC_MAG_LUT_SIZE * 4, rowsPerImage: LTC_MAG_LUT_SIZE },
|
|
544
|
+
{ width: LTC_MAG_LUT_SIZE, height: LTC_MAG_LUT_SIZE, depthOrArrayLayers: 1 }
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
|
|
231
548
|
private createRenderPipeline(config: {
|
|
232
549
|
label: string
|
|
233
550
|
layout: GPUPipelineLayout
|
|
@@ -305,8 +622,11 @@ export class Engine {
|
|
|
305
622
|
},
|
|
306
623
|
]
|
|
307
624
|
|
|
625
|
+
// Internal scene passes render into the HDR offscreen target; only the final
|
|
626
|
+
// composite pass writes the swapchain. Tonemap moved to composite so bloom
|
|
627
|
+
// (added next) can run on linear HDR.
|
|
308
628
|
const standardBlend: GPUColorTargetState = {
|
|
309
|
-
format:
|
|
629
|
+
format: Engine.HDR_FORMAT,
|
|
310
630
|
blend: {
|
|
311
631
|
color: {
|
|
312
632
|
srcFactor: "src-alpha",
|
|
@@ -322,146 +642,68 @@ export class Engine {
|
|
|
322
642
|
}
|
|
323
643
|
|
|
324
644
|
const shaderModule = this.device.createShaderModule({
|
|
325
|
-
label: "model
|
|
326
|
-
code:
|
|
327
|
-
|
|
328
|
-
view: mat4x4f,
|
|
329
|
-
projection: mat4x4f,
|
|
330
|
-
viewPos: vec3f,
|
|
331
|
-
_padding: f32,
|
|
332
|
-
};
|
|
333
|
-
|
|
334
|
-
struct Light {
|
|
335
|
-
direction: vec4f,
|
|
336
|
-
color: vec4f,
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
struct LightUniforms {
|
|
340
|
-
ambientColor: vec4f,
|
|
341
|
-
lights: array<Light, 4>,
|
|
342
|
-
};
|
|
343
|
-
|
|
344
|
-
struct MaterialUniforms {
|
|
345
|
-
alpha: f32,
|
|
346
|
-
rimIntensity: f32,
|
|
347
|
-
shininess: f32,
|
|
348
|
-
_padding1: f32,
|
|
349
|
-
rimColor: vec3f,
|
|
350
|
-
_padding2: f32,
|
|
351
|
-
diffuseColor: vec3f,
|
|
352
|
-
_padding3: f32,
|
|
353
|
-
ambientColor: vec3f,
|
|
354
|
-
_padding4: f32,
|
|
355
|
-
specularColor: vec3f,
|
|
356
|
-
_padding5: f32,
|
|
357
|
-
};
|
|
358
|
-
|
|
359
|
-
struct VertexOutput {
|
|
360
|
-
@builtin(position) position: vec4f,
|
|
361
|
-
@location(0) normal: vec3f,
|
|
362
|
-
@location(1) uv: vec2f,
|
|
363
|
-
@location(2) worldPos: vec3f,
|
|
364
|
-
};
|
|
645
|
+
label: "default model shader",
|
|
646
|
+
code: DEFAULT_SHADER_WGSL,
|
|
647
|
+
})
|
|
365
648
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
// group 1: per-instance (bound once per model)
|
|
371
|
-
@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
|
|
372
|
-
// group 2: per-material (bound per draw call)
|
|
373
|
-
@group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
|
|
374
|
-
@group(2) @binding(1) var<uniform> material: MaterialUniforms;
|
|
649
|
+
const faceShaderModule = this.device.createShaderModule({
|
|
650
|
+
label: "face NPR shader",
|
|
651
|
+
code: FACE_SHADER_WGSL,
|
|
652
|
+
})
|
|
375
653
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
@location(3) joints0: vec4<u32>,
|
|
381
|
-
@location(4) weights0: vec4<f32>
|
|
382
|
-
) -> VertexOutput {
|
|
383
|
-
var output: VertexOutput;
|
|
384
|
-
let pos4 = vec4f(position, 1.0);
|
|
385
|
-
|
|
386
|
-
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
387
|
-
let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
|
|
388
|
-
let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
|
|
389
|
-
|
|
390
|
-
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
391
|
-
var skinnedNrm = vec3f(0.0, 0.0, 0.0);
|
|
392
|
-
for (var i = 0u; i < 4u; i++) {
|
|
393
|
-
let j = joints0[i];
|
|
394
|
-
let w = normalizedWeights[i];
|
|
395
|
-
let m = skinMats[j];
|
|
396
|
-
skinnedPos += (m * pos4) * w;
|
|
397
|
-
let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
|
|
398
|
-
skinnedNrm += (r3 * normal) * w;
|
|
399
|
-
}
|
|
400
|
-
let worldPos = skinnedPos.xyz;
|
|
401
|
-
output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
|
|
402
|
-
output.normal = normalize(skinnedNrm);
|
|
403
|
-
output.uv = uv;
|
|
404
|
-
output.worldPos = worldPos;
|
|
405
|
-
return output;
|
|
406
|
-
}
|
|
654
|
+
const hairShaderModule = this.device.createShaderModule({
|
|
655
|
+
label: "hair NPR shader",
|
|
656
|
+
code: HAIR_SHADER_WGSL,
|
|
657
|
+
})
|
|
407
658
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
let n = normalize(input.normal);
|
|
415
|
-
let textureColor = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
|
|
659
|
+
const clothSmoothShaderModule = this.device.createShaderModule({
|
|
660
|
+
label: "cloth smooth NPR shader",
|
|
661
|
+
code: CLOTH_SMOOTH_SHADER_WGSL,
|
|
662
|
+
})
|
|
416
663
|
|
|
417
|
-
|
|
664
|
+
const clothRoughShaderModule = this.device.createShaderModule({
|
|
665
|
+
label: "cloth rough NPR shader",
|
|
666
|
+
code: CLOTH_ROUGH_SHADER_WGSL,
|
|
667
|
+
})
|
|
418
668
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
let specPower = max(material.shininess, 1.0);
|
|
424
|
-
|
|
425
|
-
let l = -light.lights[0].direction.xyz;
|
|
426
|
-
let nDotL = max(dot(n, l), 0.0);
|
|
427
|
-
let intensity = light.lights[0].color.w;
|
|
428
|
-
let radiance = light.lights[0].color.xyz * intensity;
|
|
429
|
-
|
|
430
|
-
let lightAccum = light.ambientColor.xyz + radiance * nDotL;
|
|
669
|
+
const metalShaderModule = this.device.createShaderModule({
|
|
670
|
+
label: "metal NPR shader",
|
|
671
|
+
code: METAL_SHADER_WGSL,
|
|
672
|
+
})
|
|
431
673
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
let litColor = albedo * lightAccum;
|
|
674
|
+
const bodyShaderModule = this.device.createShaderModule({
|
|
675
|
+
label: "body NPR shader",
|
|
676
|
+
code: BODY_SHADER_WGSL,
|
|
677
|
+
})
|
|
438
678
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
679
|
+
const eyeShaderModule = this.device.createShaderModule({
|
|
680
|
+
label: "eye shader",
|
|
681
|
+
code: EYE_SHADER_WGSL,
|
|
682
|
+
})
|
|
442
683
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
}
|
|
447
|
-
`,
|
|
684
|
+
const stockingsShaderModule = this.device.createShaderModule({
|
|
685
|
+
label: "stockings NPR shader",
|
|
686
|
+
code: STOCKINGS_SHADER_WGSL,
|
|
448
687
|
})
|
|
449
688
|
|
|
450
|
-
// group 0: per-frame (camera + light + sampler) — bound once per pass
|
|
689
|
+
// group 0: per-frame (camera + light + sampler + shadow) — bound once per pass
|
|
451
690
|
this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
|
|
452
691
|
label: "main per-frame bind group layout",
|
|
453
692
|
entries: [
|
|
454
693
|
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
455
694
|
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
456
695
|
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
696
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
|
|
697
|
+
{ binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
|
|
698
|
+
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
699
|
+
{ binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
700
|
+
{ binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
457
701
|
],
|
|
458
702
|
})
|
|
459
703
|
// group 1: per-instance (skinMats) — bound once per model
|
|
460
704
|
this.mainPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
|
|
461
705
|
label: "main per-instance bind group layout",
|
|
462
|
-
entries: [
|
|
463
|
-
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
464
|
-
],
|
|
706
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
|
|
465
707
|
})
|
|
466
708
|
// group 2: per-material (texture + material uniforms) — bound per draw call
|
|
467
709
|
this.mainPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
|
|
@@ -474,19 +716,15 @@ export class Engine {
|
|
|
474
716
|
|
|
475
717
|
const mainPipelineLayout = this.device.createPipelineLayout({
|
|
476
718
|
label: "main pipeline layout",
|
|
477
|
-
bindGroupLayouts: [
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
label: "main per-frame bind group",
|
|
482
|
-
layout: this.mainPerFrameBindGroupLayout,
|
|
483
|
-
entries: [
|
|
484
|
-
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
485
|
-
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
486
|
-
{ binding: 2, resource: this.materialSampler },
|
|
719
|
+
bindGroupLayouts: [
|
|
720
|
+
this.mainPerFrameBindGroupLayout,
|
|
721
|
+
this.mainPerInstanceBindGroupLayout,
|
|
722
|
+
this.mainPerMaterialBindGroupLayout,
|
|
487
723
|
],
|
|
488
724
|
})
|
|
489
725
|
|
|
726
|
+
// perFrameBindGroup is created after shadow resources below
|
|
727
|
+
|
|
490
728
|
this.modelPipeline = this.createRenderPipeline({
|
|
491
729
|
label: "model pipeline",
|
|
492
730
|
layout: mainPipelineLayout,
|
|
@@ -501,6 +739,118 @@ export class Engine {
|
|
|
501
739
|
},
|
|
502
740
|
})
|
|
503
741
|
|
|
742
|
+
this.facePipeline = this.createRenderPipeline({
|
|
743
|
+
label: "face NPR pipeline",
|
|
744
|
+
layout: mainPipelineLayout,
|
|
745
|
+
shaderModule: faceShaderModule,
|
|
746
|
+
vertexBuffers: fullVertexBuffers,
|
|
747
|
+
fragmentTarget: standardBlend,
|
|
748
|
+
cullMode: "none",
|
|
749
|
+
depthStencil: {
|
|
750
|
+
format: "depth24plus-stencil8",
|
|
751
|
+
depthWriteEnabled: true,
|
|
752
|
+
depthCompare: "less-equal",
|
|
753
|
+
},
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
this.hairPipeline = this.createRenderPipeline({
|
|
757
|
+
label: "hair NPR pipeline",
|
|
758
|
+
layout: mainPipelineLayout,
|
|
759
|
+
shaderModule: hairShaderModule,
|
|
760
|
+
vertexBuffers: fullVertexBuffers,
|
|
761
|
+
fragmentTarget: standardBlend,
|
|
762
|
+
cullMode: "none",
|
|
763
|
+
depthStencil: {
|
|
764
|
+
format: "depth24plus-stencil8",
|
|
765
|
+
depthWriteEnabled: true,
|
|
766
|
+
depthCompare: "less-equal",
|
|
767
|
+
},
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
this.clothSmoothPipeline = this.createRenderPipeline({
|
|
771
|
+
label: "cloth smooth NPR pipeline",
|
|
772
|
+
layout: mainPipelineLayout,
|
|
773
|
+
shaderModule: clothSmoothShaderModule,
|
|
774
|
+
vertexBuffers: fullVertexBuffers,
|
|
775
|
+
fragmentTarget: standardBlend,
|
|
776
|
+
cullMode: "none",
|
|
777
|
+
depthStencil: {
|
|
778
|
+
format: "depth24plus-stencil8",
|
|
779
|
+
depthWriteEnabled: true,
|
|
780
|
+
depthCompare: "less-equal",
|
|
781
|
+
},
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
this.clothRoughPipeline = this.createRenderPipeline({
|
|
785
|
+
label: "cloth rough NPR pipeline",
|
|
786
|
+
layout: mainPipelineLayout,
|
|
787
|
+
shaderModule: clothRoughShaderModule,
|
|
788
|
+
vertexBuffers: fullVertexBuffers,
|
|
789
|
+
fragmentTarget: standardBlend,
|
|
790
|
+
cullMode: "none",
|
|
791
|
+
depthStencil: {
|
|
792
|
+
format: "depth24plus-stencil8",
|
|
793
|
+
depthWriteEnabled: true,
|
|
794
|
+
depthCompare: "less-equal",
|
|
795
|
+
},
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
this.metalPipeline = this.createRenderPipeline({
|
|
799
|
+
label: "metal NPR pipeline",
|
|
800
|
+
layout: mainPipelineLayout,
|
|
801
|
+
shaderModule: metalShaderModule,
|
|
802
|
+
vertexBuffers: fullVertexBuffers,
|
|
803
|
+
fragmentTarget: standardBlend,
|
|
804
|
+
cullMode: "none",
|
|
805
|
+
depthStencil: {
|
|
806
|
+
format: "depth24plus-stencil8",
|
|
807
|
+
depthWriteEnabled: true,
|
|
808
|
+
depthCompare: "less-equal",
|
|
809
|
+
},
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
this.bodyPipeline = this.createRenderPipeline({
|
|
813
|
+
label: "body NPR pipeline",
|
|
814
|
+
layout: mainPipelineLayout,
|
|
815
|
+
shaderModule: bodyShaderModule,
|
|
816
|
+
vertexBuffers: fullVertexBuffers,
|
|
817
|
+
fragmentTarget: standardBlend,
|
|
818
|
+
cullMode: "none",
|
|
819
|
+
depthStencil: {
|
|
820
|
+
format: "depth24plus-stencil8",
|
|
821
|
+
depthWriteEnabled: true,
|
|
822
|
+
depthCompare: "less-equal",
|
|
823
|
+
},
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
this.eyePipeline = this.createRenderPipeline({
|
|
827
|
+
label: "eye pipeline",
|
|
828
|
+
layout: mainPipelineLayout,
|
|
829
|
+
shaderModule: eyeShaderModule,
|
|
830
|
+
vertexBuffers: fullVertexBuffers,
|
|
831
|
+
fragmentTarget: standardBlend,
|
|
832
|
+
cullMode: "none",
|
|
833
|
+
depthStencil: {
|
|
834
|
+
format: "depth24plus-stencil8",
|
|
835
|
+
depthWriteEnabled: true,
|
|
836
|
+
depthCompare: "less-equal",
|
|
837
|
+
},
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
this.stockingsPipeline = this.createRenderPipeline({
|
|
841
|
+
label: "stockings NPR pipeline",
|
|
842
|
+
layout: mainPipelineLayout,
|
|
843
|
+
shaderModule: stockingsShaderModule,
|
|
844
|
+
vertexBuffers: fullVertexBuffers,
|
|
845
|
+
fragmentTarget: standardBlend,
|
|
846
|
+
cullMode: "none",
|
|
847
|
+
depthStencil: {
|
|
848
|
+
format: "depth24plus-stencil8",
|
|
849
|
+
depthWriteEnabled: true,
|
|
850
|
+
depthCompare: "less-equal",
|
|
851
|
+
},
|
|
852
|
+
})
|
|
853
|
+
|
|
504
854
|
this.shadowLightVPBuffer = this.device.createBuffer({
|
|
505
855
|
size: 64,
|
|
506
856
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
@@ -549,6 +899,35 @@ export class Engine {
|
|
|
549
899
|
magFilter: "linear",
|
|
550
900
|
minFilter: "linear",
|
|
551
901
|
})
|
|
902
|
+
this.shadowMapTexture = this.device.createTexture({
|
|
903
|
+
label: "shadow map",
|
|
904
|
+
size: [Engine.SHADOW_MAP_SIZE, Engine.SHADOW_MAP_SIZE],
|
|
905
|
+
format: "depth32float",
|
|
906
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
907
|
+
})
|
|
908
|
+
this.shadowMapDepthView = this.shadowMapTexture.createView()
|
|
909
|
+
|
|
910
|
+
// One-shot bake of Blender EEVEE's BRDF split-sum DFG LUT (bsdf_lut_frag.glsl).
|
|
911
|
+
this.bakeDfgLut()
|
|
912
|
+
// Upload static LTC GGX magnitude LUT for direct-specular energy compensation.
|
|
913
|
+
this.uploadLtcMagLut()
|
|
914
|
+
|
|
915
|
+
// Now that shadow resources exist, create the main per-frame bind group
|
|
916
|
+
this.perFrameBindGroup = this.device.createBindGroup({
|
|
917
|
+
label: "main per-frame bind group",
|
|
918
|
+
layout: this.mainPerFrameBindGroupLayout,
|
|
919
|
+
entries: [
|
|
920
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
921
|
+
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
922
|
+
{ binding: 2, resource: this.materialSampler },
|
|
923
|
+
{ binding: 3, resource: this.shadowMapDepthView },
|
|
924
|
+
{ binding: 4, resource: this.shadowComparisonSampler },
|
|
925
|
+
{ binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
|
|
926
|
+
{ binding: 9, resource: this.dfgLutView },
|
|
927
|
+
{ binding: 10, resource: this.ltcMagLutView },
|
|
928
|
+
],
|
|
929
|
+
})
|
|
930
|
+
|
|
552
931
|
this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
|
|
553
932
|
label: "ground shadow layout",
|
|
554
933
|
entries: [
|
|
@@ -679,15 +1058,17 @@ export class Engine {
|
|
|
679
1058
|
|
|
680
1059
|
const outlinePipelineLayout = this.device.createPipelineLayout({
|
|
681
1060
|
label: "outline pipeline layout",
|
|
682
|
-
bindGroupLayouts: [
|
|
1061
|
+
bindGroupLayouts: [
|
|
1062
|
+
this.outlinePerFrameBindGroupLayout,
|
|
1063
|
+
this.mainPerInstanceBindGroupLayout,
|
|
1064
|
+
this.outlinePerMaterialBindGroupLayout,
|
|
1065
|
+
],
|
|
683
1066
|
})
|
|
684
1067
|
|
|
685
1068
|
this.outlinePerFrameBindGroup = this.device.createBindGroup({
|
|
686
1069
|
label: "outline per-frame bind group",
|
|
687
1070
|
layout: this.outlinePerFrameBindGroupLayout,
|
|
688
|
-
entries: [
|
|
689
|
-
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
690
|
-
],
|
|
1071
|
+
entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
|
|
691
1072
|
})
|
|
692
1073
|
|
|
693
1074
|
const outlineShaderModule = this.device.createShaderModule({
|
|
@@ -744,12 +1125,27 @@ export class Engine {
|
|
|
744
1125
|
}
|
|
745
1126
|
let worldPos = skinnedPos.xyz;
|
|
746
1127
|
let worldNormal = normalize(skinnedNrm);
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
1128
|
+
|
|
1129
|
+
// Screen-space outline extrusion — MMD-style pixel-stable edge line.
|
|
1130
|
+
// 1. Project position and normal-as-direction to clip space.
|
|
1131
|
+
// 2. Normalize the 2D clip-space normal, aspect-compensated so "one pixel horizontally"
|
|
1132
|
+
// matches "one pixel vertically" (otherwise wide viewports squash the outline in X).
|
|
1133
|
+
// 3. Offset clip-space xy by (normal * edgeSize * edgeScale), then multiply by w
|
|
1134
|
+
// so the perspective divide cancels out → offset stays constant in NDC regardless
|
|
1135
|
+
// of depth, matching how MMD / babylon-mmd style outlines look identical when zooming.
|
|
1136
|
+
// 4. edgeScale is in NDC-y units per PMX edgeSize. ≈ 0.006 gives ~3px at 1080p; it's
|
|
1137
|
+
// tied to viewport HEIGHT so resizing the window keeps pixel thickness stable.
|
|
1138
|
+
let viewProj = camera.projection * camera.view;
|
|
1139
|
+
let clipPos = viewProj * vec4f(worldPos, 1.0);
|
|
1140
|
+
let clipNormal = (viewProj * vec4f(worldNormal, 0.0)).xy;
|
|
1141
|
+
// projection is column-major: proj[0][0] = 1/(aspect·tan(fov/2)), proj[1][1] = 1/tan(fov/2).
|
|
1142
|
+
// Ratio proj[1][1]/proj[0][0] recovers the viewport aspect (width/height).
|
|
1143
|
+
let aspect = camera.projection[1][1] / camera.projection[0][0];
|
|
1144
|
+
let pixelDir = normalize(vec2f(clipNormal.x * aspect, clipNormal.y));
|
|
1145
|
+
let ndcDir = vec2f(pixelDir.x / aspect, pixelDir.y);
|
|
1146
|
+
let edgeScale = 0.0016;
|
|
1147
|
+
let offset = ndcDir * material.edgeSize * edgeScale * clipPos.w;
|
|
1148
|
+
output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);
|
|
753
1149
|
return output;
|
|
754
1150
|
}
|
|
755
1151
|
|
|
@@ -774,6 +1170,289 @@ export class Engine {
|
|
|
774
1170
|
},
|
|
775
1171
|
})
|
|
776
1172
|
|
|
1173
|
+
// ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
|
|
1174
|
+
// Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
|
|
1175
|
+
// Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
|
|
1176
|
+
// Gaussian cannot reproduce this — hot pixels dominate and produce the sparkle halo.
|
|
1177
|
+
this.bloomSampler = this.device.createSampler({
|
|
1178
|
+
label: "bloom sampler",
|
|
1179
|
+
magFilter: "linear",
|
|
1180
|
+
minFilter: "linear",
|
|
1181
|
+
addressModeU: "clamp-to-edge",
|
|
1182
|
+
addressModeV: "clamp-to-edge",
|
|
1183
|
+
})
|
|
1184
|
+
this.bloomBlitUniformBuffer = this.device.createBuffer({
|
|
1185
|
+
label: "bloom blit uniforms",
|
|
1186
|
+
size: 16,
|
|
1187
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1188
|
+
})
|
|
1189
|
+
this.bloomUpsampleUniformBuffer = this.device.createBuffer({
|
|
1190
|
+
label: "bloom upsample uniforms",
|
|
1191
|
+
size: 16,
|
|
1192
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1193
|
+
})
|
|
1194
|
+
|
|
1195
|
+
this.bloomBlitBindGroupLayout = this.device.createBindGroupLayout({
|
|
1196
|
+
label: "bloom blit layout",
|
|
1197
|
+
entries: [
|
|
1198
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
|
|
1199
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
1200
|
+
],
|
|
1201
|
+
})
|
|
1202
|
+
this.bloomDownsampleBindGroupLayout = this.device.createBindGroupLayout({
|
|
1203
|
+
label: "bloom downsample layout",
|
|
1204
|
+
entries: [
|
|
1205
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
1206
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
1207
|
+
],
|
|
1208
|
+
})
|
|
1209
|
+
this.bloomUpsampleBindGroupLayout = this.device.createBindGroupLayout({
|
|
1210
|
+
label: "bloom upsample layout",
|
|
1211
|
+
entries: [
|
|
1212
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // coarser-mip accumulator
|
|
1213
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // matching downsample mip (base add)
|
|
1214
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
1215
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
1216
|
+
],
|
|
1217
|
+
})
|
|
1218
|
+
|
|
1219
|
+
const bloomFullscreenVs = /* wgsl */ `
|
|
1220
|
+
@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
1221
|
+
let x = f32((vi & 1u) << 2u) - 1.0;
|
|
1222
|
+
let y = f32((vi & 2u) << 1u) - 1.0;
|
|
1223
|
+
return vec4f(x, y, 0.0, 1.0);
|
|
1224
|
+
}
|
|
1225
|
+
`
|
|
1226
|
+
|
|
1227
|
+
// Blit: full-res HDR → half-res. Karis 4-tap firefly average + EEVEE quadratic knee threshold + clamp.
|
|
1228
|
+
const bloomBlitShader = this.device.createShaderModule({
|
|
1229
|
+
label: "bloom blit (Karis prefilter)",
|
|
1230
|
+
code: `${bloomFullscreenVs}
|
|
1231
|
+
@group(0) @binding(0) var hdrTex: texture_2d<f32>;
|
|
1232
|
+
@group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
|
|
1233
|
+
|
|
1234
|
+
fn luminance(c: vec3f) -> f32 {
|
|
1235
|
+
return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
|
|
1236
|
+
}
|
|
1237
|
+
fn fetch(c: vec2<i32>, clampV: f32) -> vec3f {
|
|
1238
|
+
let d = vec2<i32>(textureDimensions(hdrTex));
|
|
1239
|
+
let cc = clamp(c, vec2<i32>(0), d - vec2<i32>(1));
|
|
1240
|
+
let s = textureLoad(hdrTex, cc, 0);
|
|
1241
|
+
// Scene pass uses src-alpha blend with clear alpha 0 → premultiplied. Unpremultiply.
|
|
1242
|
+
let rgb = max(s.rgb / max(s.a, 1e-6), vec3f(0.0));
|
|
1243
|
+
// Blender: clamp each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
|
|
1244
|
+
return select(rgb, min(rgb, vec3f(clampV)), clampV > 0.0);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
|
1248
|
+
let dst = vec2<i32>(p.xy - vec2f(0.5));
|
|
1249
|
+
let base = dst * 2;
|
|
1250
|
+
let clampV = prefilter.z;
|
|
1251
|
+
let a = fetch(base + vec2<i32>(0, 0), clampV);
|
|
1252
|
+
let b = fetch(base + vec2<i32>(1, 0), clampV);
|
|
1253
|
+
let c = fetch(base + vec2<i32>(0, 1), clampV);
|
|
1254
|
+
let d = fetch(base + vec2<i32>(1, 1), clampV);
|
|
1255
|
+
// Karis partial average: weight each tap by 1/(1+luma) — suppresses fireflies.
|
|
1256
|
+
let wa = 1.0 / (1.0 + luminance(a));
|
|
1257
|
+
let wb = 1.0 / (1.0 + luminance(b));
|
|
1258
|
+
let wc = 1.0 / (1.0 + luminance(c));
|
|
1259
|
+
let wd = 1.0 / (1.0 + luminance(d));
|
|
1260
|
+
let avg = (a * wa + b * wb + c * wc + d * wd) / max(wa + wb + wc + wd, 1e-6);
|
|
1261
|
+
// EEVEE quadratic threshold (brightness = max-channel, then soft-knee curve).
|
|
1262
|
+
let bright = max(avg.r, max(avg.g, avg.b));
|
|
1263
|
+
let soft = clamp(bright - prefilter.x + prefilter.y, 0.0, 2.0 * prefilter.y);
|
|
1264
|
+
let q = (soft * soft) / (4.0 * max(prefilter.y, 1e-4) + 1e-6);
|
|
1265
|
+
let contrib = max(q, bright - prefilter.x) / max(bright, 1e-4);
|
|
1266
|
+
return vec4f(max(avg * contrib, vec3f(0.0)), 1.0);
|
|
1267
|
+
}
|
|
1268
|
+
`,
|
|
1269
|
+
})
|
|
1270
|
+
|
|
1271
|
+
// Downsample: Jimenez/COD 13-tap dual-box — 5 weighted 2×2 averages, rejects nyquist ringing.
|
|
1272
|
+
const bloomDownsampleShader = this.device.createShaderModule({
|
|
1273
|
+
label: "bloom downsample 13-tap",
|
|
1274
|
+
code: `${bloomFullscreenVs}
|
|
1275
|
+
@group(0) @binding(0) var srcTex: texture_2d<f32>;
|
|
1276
|
+
@group(0) @binding(1) var srcSamp: sampler;
|
|
1277
|
+
|
|
1278
|
+
fn samp(uv: vec2f, off: vec2f) -> vec3f {
|
|
1279
|
+
return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
|
1283
|
+
let srcDims = vec2f(textureDimensions(srcTex));
|
|
1284
|
+
let t = 1.0 / srcDims;
|
|
1285
|
+
// fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) — divide by dst dims directly.
|
|
1286
|
+
let dstDims = srcDims * 0.5;
|
|
1287
|
+
let uv = p.xy / max(dstDims, vec2f(1.0));
|
|
1288
|
+
let A = samp(uv, t * vec2f(-2.0, -2.0));
|
|
1289
|
+
let B = samp(uv, t * vec2f( 0.0, -2.0));
|
|
1290
|
+
let C = samp(uv, t * vec2f( 2.0, -2.0));
|
|
1291
|
+
let D = samp(uv, t * vec2f(-1.0, -1.0));
|
|
1292
|
+
let E = samp(uv, t * vec2f( 1.0, -1.0));
|
|
1293
|
+
let F = samp(uv, t * vec2f(-2.0, 0.0));
|
|
1294
|
+
let G = samp(uv, t * vec2f( 0.0, 0.0));
|
|
1295
|
+
let H = samp(uv, t * vec2f( 2.0, 0.0));
|
|
1296
|
+
let I = samp(uv, t * vec2f(-1.0, 1.0));
|
|
1297
|
+
let J = samp(uv, t * vec2f( 1.0, 1.0));
|
|
1298
|
+
let K = samp(uv, t * vec2f(-2.0, 2.0));
|
|
1299
|
+
let L = samp(uv, t * vec2f( 0.0, 2.0));
|
|
1300
|
+
let M = samp(uv, t * vec2f( 2.0, 2.0));
|
|
1301
|
+
var o = (D + E + I + J) * (0.5 / 4.0);
|
|
1302
|
+
o = o + (A + B + G + F) * (0.125 / 4.0);
|
|
1303
|
+
o = o + (B + C + H + G) * (0.125 / 4.0);
|
|
1304
|
+
o = o + (F + G + L + K) * (0.125 / 4.0);
|
|
1305
|
+
o = o + (G + H + M + L) * (0.125 / 4.0);
|
|
1306
|
+
return vec4f(o, 1.0);
|
|
1307
|
+
}
|
|
1308
|
+
`,
|
|
1309
|
+
})
|
|
1310
|
+
|
|
1311
|
+
// Upsample: 9-tap tent, progressively added to matching downsample mip. Blender radius = sample scale.
|
|
1312
|
+
const bloomUpsampleShader = this.device.createShaderModule({
|
|
1313
|
+
label: "bloom upsample 9-tap tent",
|
|
1314
|
+
code: `${bloomFullscreenVs}
|
|
1315
|
+
@group(0) @binding(0) var srcTex: texture_2d<f32>; // coarser accumulator
|
|
1316
|
+
@group(0) @binding(1) var baseTex: texture_2d<f32>; // matching downsample mip
|
|
1317
|
+
@group(0) @binding(2) var srcSamp: sampler;
|
|
1318
|
+
@group(0) @binding(3) var<uniform> upU: vec4<f32>; // sampleScale, _, _, _
|
|
1319
|
+
|
|
1320
|
+
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
|
1321
|
+
let srcDims = vec2f(textureDimensions(srcTex));
|
|
1322
|
+
let baseDims = vec2f(textureDimensions(baseTex));
|
|
1323
|
+
let uv = p.xy / max(baseDims, vec2f(1.0));
|
|
1324
|
+
let t = upU.x / srcDims;
|
|
1325
|
+
var o = textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, -1.0), 0.0).rgb * 1.0;
|
|
1326
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, -1.0), 0.0).rgb * 2.0;
|
|
1327
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, -1.0), 0.0).rgb * 1.0;
|
|
1328
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 0.0), 0.0).rgb * 2.0;
|
|
1329
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 0.0), 0.0).rgb * 4.0;
|
|
1330
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 0.0), 0.0).rgb * 2.0;
|
|
1331
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 1.0), 0.0).rgb * 1.0;
|
|
1332
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 1.0), 0.0).rgb * 2.0;
|
|
1333
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 1.0), 0.0).rgb * 1.0;
|
|
1334
|
+
o = o * (1.0 / 16.0);
|
|
1335
|
+
let base = textureSampleLevel(baseTex, srcSamp, uv, 0.0).rgb;
|
|
1336
|
+
return vec4f(o + base, 1.0);
|
|
1337
|
+
}
|
|
1338
|
+
`,
|
|
1339
|
+
})
|
|
1340
|
+
|
|
1341
|
+
const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] })
|
|
1342
|
+
const bloomDownLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomDownsampleBindGroupLayout] })
|
|
1343
|
+
const bloomUpLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomUpsampleBindGroupLayout] })
|
|
1344
|
+
|
|
1345
|
+
this.bloomBlitPipeline = this.device.createRenderPipeline({
|
|
1346
|
+
label: "bloom blit pipeline",
|
|
1347
|
+
layout: bloomBlitLayout,
|
|
1348
|
+
vertex: { module: bloomBlitShader, entryPoint: "vs" },
|
|
1349
|
+
fragment: { module: bloomBlitShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
|
|
1350
|
+
primitive: { topology: "triangle-list" },
|
|
1351
|
+
})
|
|
1352
|
+
this.bloomDownsamplePipeline = this.device.createRenderPipeline({
|
|
1353
|
+
label: "bloom downsample pipeline",
|
|
1354
|
+
layout: bloomDownLayout,
|
|
1355
|
+
vertex: { module: bloomDownsampleShader, entryPoint: "vs" },
|
|
1356
|
+
fragment: { module: bloomDownsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
|
|
1357
|
+
primitive: { topology: "triangle-list" },
|
|
1358
|
+
})
|
|
1359
|
+
this.bloomUpsamplePipeline = this.device.createRenderPipeline({
|
|
1360
|
+
label: "bloom upsample pipeline",
|
|
1361
|
+
layout: bloomUpLayout,
|
|
1362
|
+
vertex: { module: bloomUpsampleShader, entryPoint: "vs" },
|
|
1363
|
+
fragment: { module: bloomUpsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
|
|
1364
|
+
primitive: { topology: "triangle-list" },
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
// ─── Composite: HDR + bloom → Filmic → swapchain (premultiplied) ───
|
|
1368
|
+
// Bloom color/intensity applied HERE (pyramid is pure energy; tint belongs to the combine step,
|
|
1369
|
+
// mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
|
|
1370
|
+
this.compositeUniformBuffer = this.device.createBuffer({
|
|
1371
|
+
label: "composite view uniforms",
|
|
1372
|
+
size: 32,
|
|
1373
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1374
|
+
})
|
|
1375
|
+
this.compositeBindGroupLayout = this.device.createBindGroupLayout({
|
|
1376
|
+
label: "composite bind group layout",
|
|
1377
|
+
entries: [
|
|
1378
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
|
|
1379
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
1380
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
1381
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
1382
|
+
],
|
|
1383
|
+
})
|
|
1384
|
+
|
|
1385
|
+
const compositeShader = this.device.createShaderModule({
|
|
1386
|
+
label: "composite shader",
|
|
1387
|
+
code: /* wgsl */ `
|
|
1388
|
+
@group(0) @binding(0) var hdrTex: texture_2d<f32>;
|
|
1389
|
+
@group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
|
|
1390
|
+
@group(0) @binding(2) var bloomSamp: sampler;
|
|
1391
|
+
@group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 2>;
|
|
1392
|
+
// viewU[0] = (exposure, gamma, _, _); viewU[1] = (tint.rgb, intensity)
|
|
1393
|
+
|
|
1394
|
+
fn filmic(x: f32) -> f32 {
|
|
1395
|
+
var lut = array<f32, 14>(
|
|
1396
|
+
0.0067, 0.0141, 0.0272, 0.0499, 0.0885, 0.1512, 0.2462,
|
|
1397
|
+
0.3753, 0.5273, 0.6776, 0.8031, 0.8929, 0.9495, 0.9814
|
|
1398
|
+
);
|
|
1399
|
+
let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
|
|
1400
|
+
let i = u32(t);
|
|
1401
|
+
let j = min(i + 1u, 13u);
|
|
1402
|
+
return mix(lut[i], lut[j], t - f32(i));
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
1406
|
+
let x = f32((vi & 1u) << 2u) - 1.0;
|
|
1407
|
+
let y = f32((vi & 2u) << 1u) - 1.0;
|
|
1408
|
+
return vec4f(x, y, 0.0, 1.0);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
@fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
|
|
1412
|
+
let hdr = textureLoad(hdrTex, vec2<i32>(fragCoord.xy), 0);
|
|
1413
|
+
let a = max(hdr.a, 1e-6);
|
|
1414
|
+
let straight = hdr.rgb / a;
|
|
1415
|
+
let fullSz = vec2f(textureDimensions(hdrTex));
|
|
1416
|
+
let bloomSz = vec2f(textureDimensions(bloomTex));
|
|
1417
|
+
// Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.
|
|
1418
|
+
let bloomUv = (fragCoord.xy + vec2f(0.5)) / max(fullSz, vec2f(1.0));
|
|
1419
|
+
let tint = viewU[1].xyz;
|
|
1420
|
+
let intensity = viewU[1].w;
|
|
1421
|
+
let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;
|
|
1422
|
+
let combined = straight + bloom;
|
|
1423
|
+
let exposed = combined * exp2(viewU[0].x);
|
|
1424
|
+
let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
|
|
1425
|
+
let g = max(viewU[0].y, 1e-4);
|
|
1426
|
+
let disp = pow(max(tm, vec3f(0.0)), vec3f(1.0 / g));
|
|
1427
|
+
return vec4f(disp * hdr.a, hdr.a);
|
|
1428
|
+
}
|
|
1429
|
+
`,
|
|
1430
|
+
})
|
|
1431
|
+
|
|
1432
|
+
this.compositePipeline = this.device.createRenderPipeline({
|
|
1433
|
+
label: "composite pipeline",
|
|
1434
|
+
layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.compositeBindGroupLayout] }),
|
|
1435
|
+
vertex: { module: compositeShader, entryPoint: "vs" },
|
|
1436
|
+
fragment: {
|
|
1437
|
+
module: compositeShader,
|
|
1438
|
+
entryPoint: "fs",
|
|
1439
|
+
targets: [{ format: this.presentationFormat }],
|
|
1440
|
+
},
|
|
1441
|
+
primitive: { topology: "triangle-list" },
|
|
1442
|
+
})
|
|
1443
|
+
|
|
1444
|
+
this.bloomPassDescriptor = {
|
|
1445
|
+
label: "bloom pass",
|
|
1446
|
+
colorAttachments: [
|
|
1447
|
+
{
|
|
1448
|
+
view: undefined as unknown as GPUTextureView,
|
|
1449
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1450
|
+
loadOp: "clear",
|
|
1451
|
+
storeOp: "store",
|
|
1452
|
+
},
|
|
1453
|
+
],
|
|
1454
|
+
} as GPURenderPassDescriptor
|
|
1455
|
+
|
|
777
1456
|
// GPU picking: encode (modelIndex, materialIndex) as color
|
|
778
1457
|
const pickShaderModule = this.device.createShaderModule({
|
|
779
1458
|
label: "pick shader",
|
|
@@ -819,34 +1498,30 @@ export class Engine {
|
|
|
819
1498
|
|
|
820
1499
|
this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
|
|
821
1500
|
label: "pick per-frame layout",
|
|
822
|
-
entries: [
|
|
823
|
-
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
|
|
824
|
-
],
|
|
1501
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } }],
|
|
825
1502
|
})
|
|
826
1503
|
this.pickPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
|
|
827
1504
|
label: "pick per-instance layout",
|
|
828
|
-
entries: [
|
|
829
|
-
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
830
|
-
],
|
|
1505
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
|
|
831
1506
|
})
|
|
832
1507
|
this.pickPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
|
|
833
1508
|
label: "pick per-material layout",
|
|
834
|
-
entries: [
|
|
835
|
-
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
836
|
-
],
|
|
1509
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
|
|
837
1510
|
})
|
|
838
1511
|
|
|
839
1512
|
const pickPipelineLayout = this.device.createPipelineLayout({
|
|
840
1513
|
label: "pick pipeline layout",
|
|
841
|
-
bindGroupLayouts: [
|
|
1514
|
+
bindGroupLayouts: [
|
|
1515
|
+
this.pickPerFrameBindGroupLayout,
|
|
1516
|
+
this.pickPerInstanceBindGroupLayout,
|
|
1517
|
+
this.pickPerMaterialBindGroupLayout,
|
|
1518
|
+
],
|
|
842
1519
|
})
|
|
843
1520
|
|
|
844
1521
|
this.pickPerFrameBindGroup = this.device.createBindGroup({
|
|
845
1522
|
label: "pick per-frame bind group",
|
|
846
1523
|
layout: this.pickPerFrameBindGroupLayout,
|
|
847
|
-
entries: [
|
|
848
|
-
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
849
|
-
],
|
|
1524
|
+
entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
|
|
850
1525
|
})
|
|
851
1526
|
|
|
852
1527
|
this.pickPipeline = this.device.createRenderPipeline({
|
|
@@ -872,7 +1547,6 @@ export class Engine {
|
|
|
872
1547
|
})
|
|
873
1548
|
}
|
|
874
1549
|
|
|
875
|
-
|
|
876
1550
|
// Step 3: Setup canvas resize handling
|
|
877
1551
|
private setupResize() {
|
|
878
1552
|
this.resizeObserver = new ResizeObserver(() => this.handleResize())
|
|
@@ -899,13 +1573,57 @@ export class Engine {
|
|
|
899
1573
|
this.canvas.height = height
|
|
900
1574
|
|
|
901
1575
|
this.multisampleTexture = this.device.createTexture({
|
|
902
|
-
label: "multisample render target",
|
|
1576
|
+
label: "multisample HDR render target",
|
|
903
1577
|
size: [width, height],
|
|
904
1578
|
sampleCount: Engine.MULTISAMPLE_COUNT,
|
|
905
|
-
format:
|
|
1579
|
+
format: Engine.HDR_FORMAT,
|
|
906
1580
|
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
907
1581
|
})
|
|
908
1582
|
|
|
1583
|
+
this.hdrResolveTexture = this.device.createTexture({
|
|
1584
|
+
label: "HDR resolve target",
|
|
1585
|
+
size: [width, height],
|
|
1586
|
+
format: Engine.HDR_FORMAT,
|
|
1587
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1588
|
+
})
|
|
1589
|
+
|
|
1590
|
+
// Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
|
|
1591
|
+
// Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
|
|
1592
|
+
const bw = Math.max(1, Math.floor(width / 2))
|
|
1593
|
+
const bh = Math.max(1, Math.floor(height / 2))
|
|
1594
|
+
const shortSide = Math.max(1, Math.min(bw, bh))
|
|
1595
|
+
this.bloomMipCount = Math.max(
|
|
1596
|
+
1,
|
|
1597
|
+
Math.min(Engine.BLOOM_MAX_LEVELS, Math.floor(Math.log2(shortSide)) - 1),
|
|
1598
|
+
)
|
|
1599
|
+
this.bloomDownTexture = this.device.createTexture({
|
|
1600
|
+
label: "bloom down pyramid",
|
|
1601
|
+
size: [bw, bh],
|
|
1602
|
+
mipLevelCount: this.bloomMipCount,
|
|
1603
|
+
format: Engine.HDR_FORMAT,
|
|
1604
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1605
|
+
})
|
|
1606
|
+
this.bloomUpTexture = this.device.createTexture({
|
|
1607
|
+
label: "bloom up pyramid",
|
|
1608
|
+
size: [bw, bh],
|
|
1609
|
+
mipLevelCount: Math.max(1, this.bloomMipCount - 1),
|
|
1610
|
+
format: Engine.HDR_FORMAT,
|
|
1611
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1612
|
+
})
|
|
1613
|
+
this.bloomDownMipViews = []
|
|
1614
|
+
for (let i = 0; i < this.bloomMipCount; i++) {
|
|
1615
|
+
this.bloomDownMipViews.push(
|
|
1616
|
+
this.bloomDownTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }),
|
|
1617
|
+
)
|
|
1618
|
+
}
|
|
1619
|
+
this.bloomUpMipViews = []
|
|
1620
|
+
const upLevels = Math.max(1, this.bloomMipCount - 1)
|
|
1621
|
+
for (let i = 0; i < upLevels; i++) {
|
|
1622
|
+
this.bloomUpMipViews.push(
|
|
1623
|
+
this.bloomUpTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }),
|
|
1624
|
+
)
|
|
1625
|
+
}
|
|
1626
|
+
|
|
909
1627
|
this.depthTexture = this.device.createTexture({
|
|
910
1628
|
label: "depth texture",
|
|
911
1629
|
size: [width, height],
|
|
@@ -918,7 +1636,7 @@ export class Engine {
|
|
|
918
1636
|
|
|
919
1637
|
const colorAttachment: GPURenderPassColorAttachment = {
|
|
920
1638
|
view: this.multisampleTexture.createView(),
|
|
921
|
-
resolveTarget: this.
|
|
1639
|
+
resolveTarget: this.hdrResolveTexture.createView(),
|
|
922
1640
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
923
1641
|
loadOp: "clear",
|
|
924
1642
|
storeOp: "store",
|
|
@@ -938,6 +1656,80 @@ export class Engine {
|
|
|
938
1656
|
},
|
|
939
1657
|
}
|
|
940
1658
|
|
|
1659
|
+
// Composite pass descriptor (color attachment view patched per-frame to current swapchain).
|
|
1660
|
+
this.compositePassDescriptor = {
|
|
1661
|
+
label: "composite pass",
|
|
1662
|
+
colorAttachments: [
|
|
1663
|
+
{
|
|
1664
|
+
view: undefined as unknown as GPUTextureView,
|
|
1665
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1666
|
+
loadOp: "clear",
|
|
1667
|
+
storeOp: "store",
|
|
1668
|
+
},
|
|
1669
|
+
],
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
this.writeBloomUniforms()
|
|
1673
|
+
|
|
1674
|
+
if (this.compositeBindGroupLayout && this.bloomBlitBindGroupLayout) {
|
|
1675
|
+
// Blit: reads HDR resolve texture (full-res), writes bloomDown mip 0.
|
|
1676
|
+
this.bloomBlitBindGroup = this.device.createBindGroup({
|
|
1677
|
+
label: "bloom blit bind group",
|
|
1678
|
+
layout: this.bloomBlitBindGroupLayout,
|
|
1679
|
+
entries: [
|
|
1680
|
+
{ binding: 0, resource: this.hdrResolveTexture.createView() },
|
|
1681
|
+
{ binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
|
|
1682
|
+
],
|
|
1683
|
+
})
|
|
1684
|
+
// Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
|
|
1685
|
+
this.bloomDownsampleBindGroups = []
|
|
1686
|
+
for (let i = 1; i < this.bloomMipCount; i++) {
|
|
1687
|
+
this.bloomDownsampleBindGroups.push(
|
|
1688
|
+
this.device.createBindGroup({
|
|
1689
|
+
label: `bloom downsample ${i}`,
|
|
1690
|
+
layout: this.bloomDownsampleBindGroupLayout,
|
|
1691
|
+
entries: [
|
|
1692
|
+
{ binding: 0, resource: this.bloomDownMipViews[i - 1] },
|
|
1693
|
+
{ binding: 1, resource: this.bloomSampler },
|
|
1694
|
+
],
|
|
1695
|
+
}),
|
|
1696
|
+
)
|
|
1697
|
+
}
|
|
1698
|
+
// Upsample[i] writes bloomUp mip i. Coarsest step reads bloomDown[N-1] (no prior up yet);
|
|
1699
|
+
// subsequent steps read bloomUp[i+1]. Both read bloomDown[i] as the base (additive combine).
|
|
1700
|
+
this.bloomUpsampleBindGroups = []
|
|
1701
|
+
const topIdx = this.bloomMipCount - 2
|
|
1702
|
+
for (let i = topIdx; i >= 0; i--) {
|
|
1703
|
+
const srcView = i === topIdx ? this.bloomDownMipViews[this.bloomMipCount - 1] : this.bloomUpMipViews[i + 1]
|
|
1704
|
+
this.bloomUpsampleBindGroups.push(
|
|
1705
|
+
this.device.createBindGroup({
|
|
1706
|
+
label: `bloom upsample ${i}`,
|
|
1707
|
+
layout: this.bloomUpsampleBindGroupLayout,
|
|
1708
|
+
entries: [
|
|
1709
|
+
{ binding: 0, resource: srcView },
|
|
1710
|
+
{ binding: 1, resource: this.bloomDownMipViews[i] },
|
|
1711
|
+
{ binding: 2, resource: this.bloomSampler },
|
|
1712
|
+
{ binding: 3, resource: { buffer: this.bloomUpsampleUniformBuffer } },
|
|
1713
|
+
],
|
|
1714
|
+
}),
|
|
1715
|
+
)
|
|
1716
|
+
}
|
|
1717
|
+
// Composite reads bloomUp mip 0 (full pyramid collapsed); fallback to bloomDown mip 0 if no upsample level.
|
|
1718
|
+
const compositeBloomView = this.bloomMipCount > 1 ? this.bloomUpMipViews[0] : this.bloomDownMipViews[0]
|
|
1719
|
+
this.compositeBindGroup = this.device.createBindGroup({
|
|
1720
|
+
label: "composite bind group",
|
|
1721
|
+
layout: this.compositeBindGroupLayout,
|
|
1722
|
+
entries: [
|
|
1723
|
+
{ binding: 0, resource: this.hdrResolveTexture.createView() },
|
|
1724
|
+
{ binding: 1, resource: compositeBloomView },
|
|
1725
|
+
{ binding: 2, resource: this.bloomSampler },
|
|
1726
|
+
{ binding: 3, resource: { buffer: this.compositeUniformBuffer } },
|
|
1727
|
+
],
|
|
1728
|
+
})
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
this.writeCompositeViewUniforms()
|
|
1732
|
+
|
|
941
1733
|
this.camera.aspect = width / height
|
|
942
1734
|
|
|
943
1735
|
if (this.onRaycast) {
|
|
@@ -965,7 +1757,13 @@ export class Engine {
|
|
|
965
1757
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
966
1758
|
})
|
|
967
1759
|
|
|
968
|
-
this.camera = new Camera(
|
|
1760
|
+
this.camera = new Camera(
|
|
1761
|
+
Math.PI,
|
|
1762
|
+
Math.PI / 2.5,
|
|
1763
|
+
this.cameraConfig.distance,
|
|
1764
|
+
this.cameraConfig.target,
|
|
1765
|
+
this.cameraConfig.fov,
|
|
1766
|
+
)
|
|
969
1767
|
|
|
970
1768
|
this.camera.aspect = this.canvas.width / this.canvas.height
|
|
971
1769
|
this.camera.attachControl(this.canvas)
|
|
@@ -1007,55 +1805,92 @@ export class Engine {
|
|
|
1007
1805
|
this.cameraTargetOffset.z = offset?.z ?? 0
|
|
1008
1806
|
}
|
|
1009
1807
|
|
|
1010
|
-
getCameraDistance(): number {
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1808
|
+
getCameraDistance(): number {
|
|
1809
|
+
return this.camera.radius
|
|
1810
|
+
}
|
|
1811
|
+
setCameraDistance(d: number): void {
|
|
1812
|
+
this.camera.radius = d
|
|
1813
|
+
}
|
|
1814
|
+
getCameraAlpha(): number {
|
|
1815
|
+
return this.camera.alpha
|
|
1816
|
+
}
|
|
1817
|
+
setCameraAlpha(a: number): void {
|
|
1818
|
+
this.camera.alpha = a
|
|
1819
|
+
}
|
|
1820
|
+
getCameraBeta(): number {
|
|
1821
|
+
return this.camera.beta
|
|
1822
|
+
}
|
|
1823
|
+
setCameraBeta(b: number): void {
|
|
1824
|
+
this.camera.beta = b
|
|
1825
|
+
}
|
|
1016
1826
|
|
|
1017
1827
|
// Step 5: Create lighting buffers
|
|
1018
1828
|
private setupLighting() {
|
|
1019
1829
|
this.lightUniformBuffer = this.device.createBuffer({
|
|
1020
1830
|
label: "light uniforms",
|
|
1021
|
-
size: 64 * 4, //
|
|
1831
|
+
size: 64 * 4, // ambientColor vec4f (4) + 4 lights * 2 vec4f each (32) = 36 f32 padded to 64
|
|
1022
1832
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1023
1833
|
})
|
|
1024
|
-
|
|
1025
|
-
// Initialize light buffer to zeros
|
|
1026
1834
|
this.lightData.fill(0)
|
|
1027
1835
|
this.lightCount = 0
|
|
1836
|
+
this.writeWorld()
|
|
1837
|
+
this.writeSun(0)
|
|
1838
|
+
}
|
|
1028
1839
|
|
|
1029
|
-
|
|
1030
|
-
|
|
1840
|
+
/**
|
|
1841
|
+
* Write world ambient. For a uniform-radiance world, hemispherical irradiance
|
|
1842
|
+
* is E = π·L and a Lambertian BRDF reflects (albedo/π)·E = albedo·L, so the
|
|
1843
|
+
* shader's ambient uniform is just `world.color × world.strength` — no /π.
|
|
1844
|
+
*/
|
|
1845
|
+
private writeWorld() {
|
|
1846
|
+
const s = this.world.strength
|
|
1847
|
+
this.lightData[0] = this.world.color.x * s
|
|
1848
|
+
this.lightData[1] = this.world.color.y * s
|
|
1849
|
+
this.lightData[2] = this.world.color.z * s
|
|
1850
|
+
this.lightData[3] = 0
|
|
1851
|
+
this.updateLightBuffer()
|
|
1031
1852
|
}
|
|
1032
1853
|
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
this.lightData[
|
|
1854
|
+
/** Write sun lamp into light slot `index` (0..3). Layout mirrors the WGSL struct. */
|
|
1855
|
+
private writeSun(index: number) {
|
|
1856
|
+
if (index < 0 || index >= 4) return
|
|
1857
|
+
const normalized = this.sun.direction.normalize()
|
|
1858
|
+
const base = 4 + index * 8 // 8 floats per light (direction vec4, color vec4)
|
|
1859
|
+
this.lightData[base] = normalized.x
|
|
1860
|
+
this.lightData[base + 1] = normalized.y
|
|
1861
|
+
this.lightData[base + 2] = normalized.z
|
|
1862
|
+
this.lightData[base + 3] = 0
|
|
1863
|
+
this.lightData[base + 4] = this.sun.color.x
|
|
1864
|
+
this.lightData[base + 5] = this.sun.color.y
|
|
1865
|
+
this.lightData[base + 6] = this.sun.color.z
|
|
1866
|
+
this.lightData[base + 7] = this.sun.strength
|
|
1867
|
+
if (index >= this.lightCount) this.lightCount = index + 1
|
|
1039
1868
|
this.updateLightBuffer()
|
|
1040
1869
|
}
|
|
1041
1870
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1871
|
+
/** Update the world environment (Blender: World Background). Ambient recomputes immediately. */
|
|
1872
|
+
setWorld(options: WorldOptions): void {
|
|
1873
|
+
if (options.color) this.world.color = options.color
|
|
1874
|
+
if (options.strength !== undefined) this.world.strength = options.strength
|
|
1875
|
+
this.writeWorld()
|
|
1876
|
+
}
|
|
1044
1877
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
this.
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
this.
|
|
1054
|
-
|
|
1878
|
+
/** Update the sun lamp (Blender: Light > Sun). Direction change marks shadow VP dirty. */
|
|
1879
|
+
setSun(options: SunOptions): void {
|
|
1880
|
+
if (options.color) this.sun.color = options.color
|
|
1881
|
+
if (options.strength !== undefined) this.sun.strength = options.strength
|
|
1882
|
+
if (options.direction) {
|
|
1883
|
+
this.sun.direction = options.direction
|
|
1884
|
+
this.shadowLightVPDirty = true
|
|
1885
|
+
}
|
|
1886
|
+
this.writeSun(0)
|
|
1887
|
+
}
|
|
1055
1888
|
|
|
1056
|
-
|
|
1057
|
-
this.
|
|
1058
|
-
|
|
1889
|
+
getWorld(): Readonly<{ color: Vec3; strength: number }> {
|
|
1890
|
+
return this.world
|
|
1891
|
+
}
|
|
1892
|
+
getSun(): Readonly<{ color: Vec3; strength: number; direction: Vec3 }> {
|
|
1893
|
+
return this.sun
|
|
1059
1894
|
}
|
|
1060
1895
|
|
|
1061
1896
|
addGround(options?: {
|
|
@@ -1064,7 +1899,6 @@ export class Engine {
|
|
|
1064
1899
|
diffuseColor?: Vec3
|
|
1065
1900
|
fadeStart?: number
|
|
1066
1901
|
fadeEnd?: number
|
|
1067
|
-
shadowMapSize?: number
|
|
1068
1902
|
shadowStrength?: number
|
|
1069
1903
|
gridSpacing?: number
|
|
1070
1904
|
gridLineWidth?: number
|
|
@@ -1075,10 +1909,9 @@ export class Engine {
|
|
|
1075
1909
|
const opts = {
|
|
1076
1910
|
width: 160,
|
|
1077
1911
|
height: 160,
|
|
1078
|
-
diffuseColor: new Vec3(0.
|
|
1912
|
+
diffuseColor: new Vec3(0.9, 0.1, 1.0),
|
|
1079
1913
|
fadeStart: 10.0,
|
|
1080
1914
|
fadeEnd: 80.0,
|
|
1081
|
-
shadowMapSize: 4096,
|
|
1082
1915
|
shadowStrength: 1.0,
|
|
1083
1916
|
gridSpacing: 4.2,
|
|
1084
1917
|
gridLineWidth: 0.012,
|
|
@@ -1096,6 +1929,7 @@ export class Engine {
|
|
|
1096
1929
|
firstIndex: 0,
|
|
1097
1930
|
bindGroup: this.groundShadowBindGroup!,
|
|
1098
1931
|
materialName: "Ground",
|
|
1932
|
+
preset: "cloth_rough",
|
|
1099
1933
|
}
|
|
1100
1934
|
}
|
|
1101
1935
|
|
|
@@ -1151,30 +1985,59 @@ export class Engine {
|
|
|
1151
1985
|
|
|
1152
1986
|
async loadModel(path: string): Promise<Model>
|
|
1153
1987
|
async loadModel(name: string, path: string): Promise<Model>
|
|
1154
|
-
async loadModel(
|
|
1155
|
-
|
|
1156
|
-
|
|
1988
|
+
async loadModel(name: string, options: LoadModelFromFilesOptions): Promise<Model>
|
|
1989
|
+
async loadModel(nameOrPath: string, pathOrOptions?: string | LoadModelFromFilesOptions): Promise<Model> {
|
|
1990
|
+
if (pathOrOptions !== undefined && typeof pathOrOptions === "object" && "files" in pathOrOptions) {
|
|
1991
|
+
const name = nameOrPath
|
|
1992
|
+
const pmxFile = pathOrOptions.pmxFile ?? findFirstPmxFileInList(pathOrOptions.files)
|
|
1993
|
+
if (!pmxFile) throw new Error("No .pmx file found in the selected folder")
|
|
1994
|
+
const map = fileListToMap(pathOrOptions.files)
|
|
1995
|
+
const pmxKey = normalizeAssetPath(
|
|
1996
|
+
(pmxFile as File & { webkitRelativePath?: string }).webkitRelativePath ?? pmxFile.name,
|
|
1997
|
+
)
|
|
1998
|
+
const reader = createFileMapAssetReader(map)
|
|
1999
|
+
const model = await PmxLoader.loadFromReader(reader, pmxKey)
|
|
2000
|
+
model.setName(name)
|
|
2001
|
+
await this.addModel(model, pmxKey, name, reader)
|
|
2002
|
+
return model
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
const pmxPath = pathOrOptions === undefined ? nameOrPath : pathOrOptions
|
|
2006
|
+
const name = pathOrOptions === undefined ? "model_" + this._nextDefaultModelId++ : nameOrPath
|
|
1157
2007
|
const model = await PmxLoader.load(pmxPath)
|
|
1158
2008
|
model.setName(name)
|
|
1159
2009
|
await this.addModel(model, pmxPath, name)
|
|
1160
2010
|
return model
|
|
1161
2011
|
}
|
|
1162
2012
|
|
|
1163
|
-
async addModel(model: Model, pmxPath: string, name?: string): Promise<string> {
|
|
2013
|
+
async addModel(model: Model, pmxPath: string, name?: string, assetReader?: AssetReader): Promise<string> {
|
|
1164
2014
|
const requested = name ?? model.name
|
|
1165
2015
|
let key = requested
|
|
1166
2016
|
let n = 1
|
|
1167
2017
|
while (this.modelInstances.has(key)) {
|
|
1168
2018
|
key = `${requested}_${n++}`
|
|
1169
2019
|
}
|
|
1170
|
-
const
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
await this.setupModelInstance(key, model, basePath)
|
|
2020
|
+
const reader = assetReader ?? createFetchAssetReader()
|
|
2021
|
+
const basePath = deriveBasePathFromPmxPath(pmxPath)
|
|
2022
|
+
model.setAssetContext(reader, basePath)
|
|
2023
|
+
await this.setupModelInstance(key, model, basePath, reader)
|
|
1174
2024
|
return key
|
|
1175
2025
|
}
|
|
1176
2026
|
|
|
1177
2027
|
removeModel(name: string): void {
|
|
2028
|
+
const inst = this.modelInstances.get(name)
|
|
2029
|
+
if (!inst) return
|
|
2030
|
+
inst.model.stopAnimation()
|
|
2031
|
+
for (const path of inst.textureCacheKeys) {
|
|
2032
|
+
const tex = this.textureCache.get(path)
|
|
2033
|
+
if (tex) {
|
|
2034
|
+
tex.destroy()
|
|
2035
|
+
this.textureCache.delete(path)
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
for (const buf of inst.gpuBuffers) {
|
|
2039
|
+
buf.destroy()
|
|
2040
|
+
}
|
|
1178
2041
|
this.modelInstances.delete(name)
|
|
1179
2042
|
}
|
|
1180
2043
|
|
|
@@ -1201,6 +2064,15 @@ export class Engine {
|
|
|
1201
2064
|
}
|
|
1202
2065
|
}
|
|
1203
2066
|
|
|
2067
|
+
setMaterialPresets(modelName: string, presets: MaterialPresetMap): void {
|
|
2068
|
+
const inst = this.modelInstances.get(modelName)
|
|
2069
|
+
if (!inst) return
|
|
2070
|
+
inst.materialPresets = presets
|
|
2071
|
+
for (const dc of inst.drawCalls) {
|
|
2072
|
+
dc.preset = resolvePreset(dc.materialName, presets)
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
|
|
1204
2076
|
setMaterialVisible(modelName: string, materialName: string, visible: boolean): void {
|
|
1205
2077
|
const inst = this.modelInstances.get(modelName)
|
|
1206
2078
|
if (!inst) return
|
|
@@ -1245,11 +2117,7 @@ export class Engine {
|
|
|
1245
2117
|
const verticesChanged = inst.model.update(deltaTime, this.ikEnabled)
|
|
1246
2118
|
if (verticesChanged) inst.vertexBufferNeedsUpdate = true
|
|
1247
2119
|
if (inst.physics && this.physicsEnabled) {
|
|
1248
|
-
inst.physics.step(
|
|
1249
|
-
deltaTime,
|
|
1250
|
-
inst.model.getWorldMatrices(),
|
|
1251
|
-
inst.model.getBoneInverseBindMatrices()
|
|
1252
|
-
)
|
|
2120
|
+
inst.physics.step(deltaTime, inst.model.getWorldMatrices(), inst.model.getBoneInverseBindMatrices())
|
|
1253
2121
|
}
|
|
1254
2122
|
if (inst.vertexBufferNeedsUpdate) this.updateVertexBuffer(inst)
|
|
1255
2123
|
})
|
|
@@ -1262,7 +2130,12 @@ export class Engine {
|
|
|
1262
2130
|
inst.vertexBufferNeedsUpdate = false
|
|
1263
2131
|
}
|
|
1264
2132
|
|
|
1265
|
-
private async setupModelInstance(
|
|
2133
|
+
private async setupModelInstance(
|
|
2134
|
+
name: string,
|
|
2135
|
+
model: Model,
|
|
2136
|
+
basePath: string,
|
|
2137
|
+
assetReader: AssetReader,
|
|
2138
|
+
): Promise<void> {
|
|
1266
2139
|
const vertices = model.getVertices()
|
|
1267
2140
|
const skinning = model.getSkinning()
|
|
1268
2141
|
const skeleton = model.getSkeleton()
|
|
@@ -1286,7 +2159,7 @@ export class Engine {
|
|
|
1286
2159
|
0,
|
|
1287
2160
|
skinning.joints.buffer,
|
|
1288
2161
|
skinning.joints.byteOffset,
|
|
1289
|
-
skinning.joints.byteLength
|
|
2162
|
+
skinning.joints.byteLength,
|
|
1290
2163
|
)
|
|
1291
2164
|
|
|
1292
2165
|
const weightsBuffer = this.device.createBuffer({
|
|
@@ -1299,7 +2172,7 @@ export class Engine {
|
|
|
1299
2172
|
0,
|
|
1300
2173
|
skinning.weights.buffer,
|
|
1301
2174
|
skinning.weights.byteOffset,
|
|
1302
|
-
skinning.weights.byteLength
|
|
2175
|
+
skinning.weights.byteLength,
|
|
1303
2176
|
)
|
|
1304
2177
|
|
|
1305
2178
|
const skinMatrixBuffer = this.device.createBuffer({
|
|
@@ -1332,23 +2205,24 @@ export class Engine {
|
|
|
1332
2205
|
const mainPerInstanceBindGroup = this.device.createBindGroup({
|
|
1333
2206
|
label: `${name}: main per-instance bind group`,
|
|
1334
2207
|
layout: this.mainPerInstanceBindGroupLayout,
|
|
1335
|
-
entries: [
|
|
1336
|
-
{ binding: 0, resource: { buffer: skinMatrixBuffer } },
|
|
1337
|
-
],
|
|
2208
|
+
entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
|
|
1338
2209
|
})
|
|
1339
2210
|
|
|
1340
2211
|
const pickPerInstanceBindGroup = this.device.createBindGroup({
|
|
1341
2212
|
label: `${name}: pick per-instance bind group`,
|
|
1342
2213
|
layout: this.pickPerInstanceBindGroupLayout,
|
|
1343
|
-
entries: [
|
|
1344
|
-
{ binding: 0, resource: { buffer: skinMatrixBuffer } },
|
|
1345
|
-
],
|
|
2214
|
+
entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
|
|
1346
2215
|
})
|
|
1347
2216
|
|
|
2217
|
+
const gpuBuffers: GPUBuffer[] = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer]
|
|
2218
|
+
|
|
1348
2219
|
const inst: ModelInstance = {
|
|
1349
2220
|
name,
|
|
1350
2221
|
model,
|
|
1351
2222
|
basePath,
|
|
2223
|
+
assetReader,
|
|
2224
|
+
gpuBuffers,
|
|
2225
|
+
textureCacheKeys: [],
|
|
1352
2226
|
vertexBuffer,
|
|
1353
2227
|
indexBuffer,
|
|
1354
2228
|
jointsBuffer,
|
|
@@ -1361,6 +2235,7 @@ export class Engine {
|
|
|
1361
2235
|
pickPerInstanceBindGroup,
|
|
1362
2236
|
pickDrawCalls: [],
|
|
1363
2237
|
hiddenMaterials: new Set(),
|
|
2238
|
+
materialPresets: undefined,
|
|
1364
2239
|
physics,
|
|
1365
2240
|
vertexBufferNeedsUpdate: false,
|
|
1366
2241
|
}
|
|
@@ -1441,7 +2316,6 @@ export class Engine {
|
|
|
1441
2316
|
}
|
|
1442
2317
|
|
|
1443
2318
|
private createShadowGroundResources(opts: {
|
|
1444
|
-
shadowMapSize: number
|
|
1445
2319
|
diffuseColor: Vec3
|
|
1446
2320
|
fadeStart: number
|
|
1447
2321
|
fadeEnd: number
|
|
@@ -1452,21 +2326,39 @@ export class Engine {
|
|
|
1452
2326
|
gridLineColor: Vec3
|
|
1453
2327
|
noiseStrength: number
|
|
1454
2328
|
}) {
|
|
1455
|
-
const {
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
2329
|
+
const {
|
|
2330
|
+
diffuseColor,
|
|
2331
|
+
fadeStart,
|
|
2332
|
+
fadeEnd,
|
|
2333
|
+
shadowStrength,
|
|
2334
|
+
gridSpacing,
|
|
2335
|
+
gridLineWidth,
|
|
2336
|
+
gridLineOpacity,
|
|
2337
|
+
gridLineColor,
|
|
2338
|
+
noiseStrength,
|
|
2339
|
+
} = opts
|
|
2340
|
+
// Shadow map is already created in setupPipelines()
|
|
1464
2341
|
const gb = new Float32Array(16)
|
|
1465
|
-
gb[0] = diffuseColor.x
|
|
1466
|
-
gb[
|
|
1467
|
-
gb[
|
|
1468
|
-
gb[
|
|
1469
|
-
|
|
2342
|
+
gb[0] = diffuseColor.x
|
|
2343
|
+
gb[1] = diffuseColor.y
|
|
2344
|
+
gb[2] = diffuseColor.z
|
|
2345
|
+
gb[3] = fadeStart
|
|
2346
|
+
gb[4] = fadeEnd
|
|
2347
|
+
gb[5] = shadowStrength
|
|
2348
|
+
gb[6] = 1 / Engine.SHADOW_MAP_SIZE
|
|
2349
|
+
gb[7] = gridSpacing
|
|
2350
|
+
gb[8] = gridLineWidth
|
|
2351
|
+
gb[9] = gridLineOpacity
|
|
2352
|
+
gb[10] = noiseStrength
|
|
2353
|
+
gb[11] = 0
|
|
2354
|
+
gb[12] = gridLineColor.x
|
|
2355
|
+
gb[13] = gridLineColor.y
|
|
2356
|
+
gb[14] = gridLineColor.z
|
|
2357
|
+
gb[15] = 0
|
|
2358
|
+
this.groundShadowMaterialBuffer = this.device.createBuffer({
|
|
2359
|
+
size: gb.byteLength,
|
|
2360
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2361
|
+
})
|
|
1470
2362
|
this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb)
|
|
1471
2363
|
this.groundShadowBindGroup = this.device.createBindGroup({
|
|
1472
2364
|
label: "ground shadow bind",
|
|
@@ -1482,12 +2374,12 @@ export class Engine {
|
|
|
1482
2374
|
})
|
|
1483
2375
|
}
|
|
1484
2376
|
|
|
1485
|
-
// Shadow
|
|
2377
|
+
// Shadow is cast from the visible sun direction — same vector the shader lights with.
|
|
1486
2378
|
private shadowLightVPDirty = true
|
|
1487
2379
|
private updateShadowLightVP() {
|
|
1488
2380
|
if (!this.shadowLightVPDirty) return
|
|
1489
2381
|
this.shadowLightVPDirty = false
|
|
1490
|
-
const dir = new Vec3(this.
|
|
2382
|
+
const dir = new Vec3(this.sun.direction.x, this.sun.direction.y, this.sun.direction.z)
|
|
1491
2383
|
dir.normalize()
|
|
1492
2384
|
const target = new Vec3(0, 11, 0)
|
|
1493
2385
|
const eye = new Vec3(target.x - dir.x * 72, target.y - dir.y * 72, target.z - dir.z * 72)
|
|
@@ -1510,8 +2402,8 @@ export class Engine {
|
|
|
1510
2402
|
|
|
1511
2403
|
const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
|
|
1512
2404
|
if (texIndex < 0 || texIndex >= textures.length) return null
|
|
1513
|
-
const
|
|
1514
|
-
return this.
|
|
2405
|
+
const logicalPath = joinAssetPath(inst.basePath, normalizeAssetPath(textures[texIndex].path))
|
|
2406
|
+
return this.createTextureFromLogicalPath(inst, logicalPath)
|
|
1515
2407
|
}
|
|
1516
2408
|
|
|
1517
2409
|
let currentIndexOffset = 0
|
|
@@ -1527,14 +2419,12 @@ export class Engine {
|
|
|
1527
2419
|
const materialAlpha = mat.diffuse[3]
|
|
1528
2420
|
const isTransparent = materialAlpha < 1.0 - 0.001
|
|
1529
2421
|
|
|
1530
|
-
const materialUniformBuffer = this.createMaterialUniformBuffer(
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
mat.shininess
|
|
1537
|
-
)
|
|
2422
|
+
const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [
|
|
2423
|
+
mat.diffuse[0],
|
|
2424
|
+
mat.diffuse[1],
|
|
2425
|
+
mat.diffuse[2],
|
|
2426
|
+
])
|
|
2427
|
+
inst.gpuBuffers.push(materialUniformBuffer)
|
|
1538
2428
|
|
|
1539
2429
|
const textureView = diffuseTexture.createView()
|
|
1540
2430
|
const bindGroup = this.device.createBindGroup({
|
|
@@ -1547,28 +2437,49 @@ export class Engine {
|
|
|
1547
2437
|
})
|
|
1548
2438
|
|
|
1549
2439
|
const type: DrawCallType = isTransparent ? "transparent" : "opaque"
|
|
1550
|
-
|
|
2440
|
+
const preset = resolvePreset(mat.name, inst.materialPresets)
|
|
2441
|
+
inst.drawCalls.push({
|
|
2442
|
+
type,
|
|
2443
|
+
count: indexCount,
|
|
2444
|
+
firstIndex: currentIndexOffset,
|
|
2445
|
+
bindGroup,
|
|
2446
|
+
materialName: mat.name,
|
|
2447
|
+
preset,
|
|
2448
|
+
})
|
|
1551
2449
|
|
|
1552
2450
|
if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
|
|
1553
2451
|
const materialUniformData = new Float32Array([
|
|
1554
|
-
mat.edgeColor[0],
|
|
1555
|
-
mat.
|
|
2452
|
+
mat.edgeColor[0],
|
|
2453
|
+
mat.edgeColor[1],
|
|
2454
|
+
mat.edgeColor[2],
|
|
2455
|
+
mat.edgeColor[3],
|
|
2456
|
+
mat.edgeSize,
|
|
2457
|
+
0,
|
|
2458
|
+
0,
|
|
2459
|
+
0,
|
|
1556
2460
|
])
|
|
1557
2461
|
const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData)
|
|
2462
|
+
inst.gpuBuffers.push(outlineUniformBuffer)
|
|
1558
2463
|
const outlineBindGroup = this.device.createBindGroup({
|
|
1559
2464
|
label: `${prefix}outline: ${mat.name}`,
|
|
1560
2465
|
layout: this.outlinePerMaterialBindGroupLayout,
|
|
1561
|
-
entries: [
|
|
1562
|
-
{ binding: 0, resource: { buffer: outlineUniformBuffer } },
|
|
1563
|
-
],
|
|
2466
|
+
entries: [{ binding: 0, resource: { buffer: outlineUniformBuffer } }],
|
|
1564
2467
|
})
|
|
1565
2468
|
const outlineType: DrawCallType = isTransparent ? "transparent-outline" : "opaque-outline"
|
|
1566
|
-
inst.drawCalls.push({
|
|
2469
|
+
inst.drawCalls.push({
|
|
2470
|
+
type: outlineType,
|
|
2471
|
+
count: indexCount,
|
|
2472
|
+
firstIndex: currentIndexOffset,
|
|
2473
|
+
bindGroup: outlineBindGroup,
|
|
2474
|
+
materialName: mat.name,
|
|
2475
|
+
preset,
|
|
2476
|
+
})
|
|
1567
2477
|
}
|
|
1568
2478
|
|
|
1569
2479
|
if (this.onRaycast) {
|
|
1570
2480
|
const pickIdData = new Float32Array([modelId, materialId, 0, 0])
|
|
1571
2481
|
const pickIdBuffer = this.createUniformBuffer(`${prefix}pick: ${mat.name}`, pickIdData)
|
|
2482
|
+
inst.gpuBuffers.push(pickIdBuffer)
|
|
1572
2483
|
const pickBindGroup = this.device.createBindGroup({
|
|
1573
2484
|
label: `${prefix}pick: ${mat.name}`,
|
|
1574
2485
|
layout: this.pickPerMaterialBindGroupLayout,
|
|
@@ -1585,25 +2496,13 @@ export class Engine {
|
|
|
1585
2496
|
}
|
|
1586
2497
|
}
|
|
1587
2498
|
|
|
1588
|
-
private createMaterialUniformBuffer(
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
): GPUBuffer {
|
|
1596
|
-
const data = new Float32Array(20)
|
|
1597
|
-
data.set([
|
|
1598
|
-
alpha,
|
|
1599
|
-
this.rimLightIntensity,
|
|
1600
|
-
shininess,
|
|
1601
|
-
0.0,
|
|
1602
|
-
1.0, 1.0, 1.0, 0.0, // rimColor (vec3), _padding2
|
|
1603
|
-
diffuseColor[0], diffuseColor[1], diffuseColor[2], 0.0,
|
|
1604
|
-
ambientColor[0], ambientColor[1], ambientColor[2], 0.0,
|
|
1605
|
-
specularColor[0], specularColor[1], specularColor[2], 0.0,
|
|
1606
|
-
])
|
|
2499
|
+
private createMaterialUniformBuffer(label: string, alpha: number, diffuseColor: [number, number, number]): GPUBuffer {
|
|
2500
|
+
// Matches WGSL `struct MaterialUniforms { diffuseColor: vec3f, alpha: f32 }` — 16 bytes.
|
|
2501
|
+
const data = new Float32Array(4)
|
|
2502
|
+
data[0] = diffuseColor[0]
|
|
2503
|
+
data[1] = diffuseColor[1]
|
|
2504
|
+
data[2] = diffuseColor[2]
|
|
2505
|
+
data[3] = alpha
|
|
1607
2506
|
return this.createUniformBuffer(`material uniform: ${label}`, data)
|
|
1608
2507
|
}
|
|
1609
2508
|
|
|
@@ -1621,26 +2520,24 @@ export class Engine {
|
|
|
1621
2520
|
return !inst.hiddenMaterials.has(drawCall.materialName)
|
|
1622
2521
|
}
|
|
1623
2522
|
|
|
1624
|
-
private async
|
|
1625
|
-
const
|
|
2523
|
+
private async createTextureFromLogicalPath(inst: ModelInstance, logicalPath: string): Promise<GPUTexture | null> {
|
|
2524
|
+
const cacheKey = logicalPath
|
|
2525
|
+
const cached = this.textureCache.get(cacheKey)
|
|
1626
2526
|
if (cached) {
|
|
1627
2527
|
return cached
|
|
1628
2528
|
}
|
|
1629
2529
|
|
|
1630
2530
|
try {
|
|
1631
|
-
const
|
|
1632
|
-
|
|
1633
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
1634
|
-
}
|
|
1635
|
-
const imageBitmap = await createImageBitmap(await response.blob(), {
|
|
2531
|
+
const buffer = await inst.assetReader.readBinary(logicalPath)
|
|
2532
|
+
const imageBitmap = await createImageBitmap(new Blob([buffer]), {
|
|
1636
2533
|
premultiplyAlpha: "none",
|
|
1637
2534
|
colorSpaceConversion: "none",
|
|
1638
2535
|
})
|
|
1639
2536
|
|
|
1640
2537
|
const texture = this.device.createTexture({
|
|
1641
|
-
label: `texture: ${
|
|
2538
|
+
label: `texture: ${cacheKey}`,
|
|
1642
2539
|
size: [imageBitmap.width, imageBitmap.height],
|
|
1643
|
-
format: "rgba8unorm",
|
|
2540
|
+
format: "rgba8unorm-srgb",
|
|
1644
2541
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
1645
2542
|
})
|
|
1646
2543
|
this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
|
|
@@ -1648,14 +2545,14 @@ export class Engine {
|
|
|
1648
2545
|
imageBitmap.height,
|
|
1649
2546
|
])
|
|
1650
2547
|
|
|
1651
|
-
this.textureCache.set(
|
|
2548
|
+
this.textureCache.set(cacheKey, texture)
|
|
2549
|
+
inst.textureCacheKeys.push(cacheKey)
|
|
1652
2550
|
return texture
|
|
1653
2551
|
} catch {
|
|
1654
2552
|
return null
|
|
1655
2553
|
}
|
|
1656
2554
|
}
|
|
1657
2555
|
|
|
1658
|
-
|
|
1659
2556
|
private renderGround(pass: GPURenderPassEncoder) {
|
|
1660
2557
|
if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
|
|
1661
2558
|
pass.setPipeline(this.groundShadowPipeline)
|
|
@@ -1665,7 +2562,6 @@ export class Engine {
|
|
|
1665
2562
|
pass.drawIndexed(this.groundDrawCall.count, 1, this.groundDrawCall.firstIndex, 0, 0)
|
|
1666
2563
|
}
|
|
1667
2564
|
|
|
1668
|
-
|
|
1669
2565
|
private handleCanvasDoubleClick = (event: MouseEvent) => {
|
|
1670
2566
|
if (!this.onRaycast || this.modelInstances.size === 0) return
|
|
1671
2567
|
const rect = this.canvas.getBoundingClientRect()
|
|
@@ -1713,12 +2609,14 @@ export class Engine {
|
|
|
1713
2609
|
if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture) return
|
|
1714
2610
|
|
|
1715
2611
|
const pass = encoder.beginRenderPass({
|
|
1716
|
-
colorAttachments: [
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
2612
|
+
colorAttachments: [
|
|
2613
|
+
{
|
|
2614
|
+
view: this.pickTexture.createView(),
|
|
2615
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
2616
|
+
loadOp: "clear",
|
|
2617
|
+
storeOp: "store",
|
|
2618
|
+
},
|
|
2619
|
+
],
|
|
1722
2620
|
depthStencilAttachment: {
|
|
1723
2621
|
view: this.pickDepthTexture.createView(),
|
|
1724
2622
|
depthClearValue: 1.0,
|
|
@@ -1750,7 +2648,7 @@ export class Engine {
|
|
|
1750
2648
|
encoder.copyTextureToBuffer(
|
|
1751
2649
|
{ texture: this.pickTexture, origin: { x: Math.max(0, px), y: Math.max(0, py) } },
|
|
1752
2650
|
{ buffer: this.pickReadbackBuffer, bytesPerRow: 256 },
|
|
1753
|
-
{ width: 1, height: 1 }
|
|
2651
|
+
{ width: 1, height: 1 },
|
|
1754
2652
|
)
|
|
1755
2653
|
}
|
|
1756
2654
|
|
|
@@ -1771,7 +2669,10 @@ export class Engine {
|
|
|
1771
2669
|
let idx = 1
|
|
1772
2670
|
let hitModel = ""
|
|
1773
2671
|
for (const [name] of this.modelInstances) {
|
|
1774
|
-
if (idx === modelId) {
|
|
2672
|
+
if (idx === modelId) {
|
|
2673
|
+
hitModel = name
|
|
2674
|
+
break
|
|
2675
|
+
}
|
|
1775
2676
|
idx++
|
|
1776
2677
|
}
|
|
1777
2678
|
|
|
@@ -1785,7 +2686,10 @@ export class Engine {
|
|
|
1785
2686
|
for (const mat of materials) {
|
|
1786
2687
|
if (mat.vertexCount === 0) continue
|
|
1787
2688
|
matIdx++
|
|
1788
|
-
if (matIdx === materialId) {
|
|
2689
|
+
if (matIdx === materialId) {
|
|
2690
|
+
hitMaterial = mat.name
|
|
2691
|
+
break
|
|
2692
|
+
}
|
|
1789
2693
|
}
|
|
1790
2694
|
}
|
|
1791
2695
|
}
|
|
@@ -1800,8 +2704,6 @@ export class Engine {
|
|
|
1800
2704
|
const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
|
|
1801
2705
|
this.lastFrameTime = currentTime
|
|
1802
2706
|
|
|
1803
|
-
this.updateRenderTarget()
|
|
1804
|
-
|
|
1805
2707
|
const hasModels = this.modelInstances.size > 0
|
|
1806
2708
|
if (hasModels) {
|
|
1807
2709
|
this.updateInstances(deltaTime)
|
|
@@ -1819,10 +2721,10 @@ export class Engine {
|
|
|
1819
2721
|
}
|
|
1820
2722
|
|
|
1821
2723
|
this.updateCameraUniforms()
|
|
1822
|
-
|
|
2724
|
+
this.updateShadowLightVP()
|
|
1823
2725
|
|
|
1824
2726
|
const encoder = this.device.createCommandEncoder()
|
|
1825
|
-
if (hasModels
|
|
2727
|
+
if (hasModels) {
|
|
1826
2728
|
const sp = encoder.beginRenderPass({
|
|
1827
2729
|
colorAttachments: [],
|
|
1828
2730
|
depthStencilAttachment: {
|
|
@@ -1842,6 +2744,56 @@ export class Engine {
|
|
|
1842
2744
|
if (this.hasGround) this.renderGround(pass)
|
|
1843
2745
|
pass.end()
|
|
1844
2746
|
|
|
2747
|
+
// Bloom pyramid (EEVEE 3.6):
|
|
2748
|
+
// 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
|
|
2749
|
+
// 2. Downsample: bloomDown[0] → bloomDown[1] → … → bloomDown[N-1] (13-tap)
|
|
2750
|
+
// 3. Upsample (top-down): bloomUp[N-2] = tent(bloomDown[N-1]) + bloomDown[N-2],
|
|
2751
|
+
// then bloomUp[i] = tent(bloomUp[i+1]) + bloomDown[i] until i=0 (9-tap tent)
|
|
2752
|
+
// Composite reads bloomUp[0] and adds tint * intensity * bloom before Filmic.
|
|
2753
|
+
if (this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
|
|
2754
|
+
const bloomAtt = this.bloomPassDescriptor.colorAttachments as GPURenderPassColorAttachment[]
|
|
2755
|
+
|
|
2756
|
+
// 1. Blit
|
|
2757
|
+
bloomAtt[0].view = this.bloomDownMipViews[0]
|
|
2758
|
+
const pBlit = encoder.beginRenderPass(this.bloomPassDescriptor)
|
|
2759
|
+
pBlit.setPipeline(this.bloomBlitPipeline)
|
|
2760
|
+
pBlit.setBindGroup(0, this.bloomBlitBindGroup)
|
|
2761
|
+
pBlit.draw(3)
|
|
2762
|
+
pBlit.end()
|
|
2763
|
+
|
|
2764
|
+
// 2. Downsample chain
|
|
2765
|
+
for (let i = 1; i < this.bloomMipCount; i++) {
|
|
2766
|
+
bloomAtt[0].view = this.bloomDownMipViews[i]
|
|
2767
|
+
const p = encoder.beginRenderPass(this.bloomPassDescriptor)
|
|
2768
|
+
p.setPipeline(this.bloomDownsamplePipeline)
|
|
2769
|
+
p.setBindGroup(0, this.bloomDownsampleBindGroups[i - 1])
|
|
2770
|
+
p.draw(3)
|
|
2771
|
+
p.end()
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
// 3. Upsample chain (coarsest to finest; bindGroups[0] is the coarsest step)
|
|
2775
|
+
const upSteps = this.bloomUpsampleBindGroups.length
|
|
2776
|
+
const topIdx = this.bloomMipCount - 2
|
|
2777
|
+
for (let k = 0; k < upSteps; k++) {
|
|
2778
|
+
const levelIdx = topIdx - k // writes bloomUp[levelIdx]
|
|
2779
|
+
bloomAtt[0].view = this.bloomUpMipViews[levelIdx]
|
|
2780
|
+
const p = encoder.beginRenderPass(this.bloomPassDescriptor)
|
|
2781
|
+
p.setPipeline(this.bloomUpsamplePipeline)
|
|
2782
|
+
p.setBindGroup(0, this.bloomUpsampleBindGroups[k])
|
|
2783
|
+
p.draw(3)
|
|
2784
|
+
p.end()
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
// Composite: HDR + bloom → Filmic tonemap → swapchain.
|
|
2789
|
+
const compositeAttachment = (this.compositePassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
|
|
2790
|
+
compositeAttachment.view = this.context.getCurrentTexture().createView()
|
|
2791
|
+
const cpass = encoder.beginRenderPass(this.compositePassDescriptor)
|
|
2792
|
+
cpass.setPipeline(this.compositePipeline)
|
|
2793
|
+
cpass.setBindGroup(0, this.compositeBindGroup)
|
|
2794
|
+
cpass.draw(3)
|
|
2795
|
+
cpass.end()
|
|
2796
|
+
|
|
1845
2797
|
const pick = this.pendingPick
|
|
1846
2798
|
if (pick && hasModels) this.renderPickPass(encoder)
|
|
1847
2799
|
|
|
@@ -1856,11 +2808,6 @@ export class Engine {
|
|
|
1856
2808
|
this.updateStats(performance.now() - currentTime)
|
|
1857
2809
|
}
|
|
1858
2810
|
|
|
1859
|
-
private updateRenderTarget() {
|
|
1860
|
-
const colorAttachment = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
|
|
1861
|
-
colorAttachment.resolveTarget = this.context.getCurrentTexture().createView()
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
2811
|
private drawInstanceShadow(sp: GPURenderPassEncoder, inst: ModelInstance): void {
|
|
1865
2812
|
sp.setBindGroup(0, inst.shadowBindGroup)
|
|
1866
2813
|
sp.setVertexBuffer(0, inst.vertexBuffer)
|
|
@@ -1872,46 +2819,83 @@ export class Engine {
|
|
|
1872
2819
|
}
|
|
1873
2820
|
}
|
|
1874
2821
|
|
|
1875
|
-
private
|
|
1876
|
-
|
|
2822
|
+
private pipelineForPreset(preset: MaterialPreset): GPURenderPipeline {
|
|
2823
|
+
if (preset === "face") return this.facePipeline
|
|
2824
|
+
if (preset === "hair") return this.hairPipeline
|
|
2825
|
+
if (preset === "cloth_smooth") return this.clothSmoothPipeline
|
|
2826
|
+
if (preset === "cloth_rough") return this.clothRoughPipeline
|
|
2827
|
+
if (preset === "metal") return this.metalPipeline
|
|
2828
|
+
if (preset === "body") return this.bodyPipeline
|
|
2829
|
+
if (preset === "eye") return this.eyePipeline
|
|
2830
|
+
if (preset === "stockings") return this.stockingsPipeline
|
|
2831
|
+
return this.modelPipeline
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
/**
|
|
2835
|
+
* Draw every material of a given type (`opaque` or `transparent`) using the main
|
|
2836
|
+
* pipeline(s). Binds the per-frame and per-instance groups once at the top of the
|
|
2837
|
+
* batch, then issues one draw per material. Early-outs if nothing to draw so we
|
|
2838
|
+
* don't waste bindings when a model has no transparents, etc.
|
|
2839
|
+
*/
|
|
2840
|
+
private drawMaterials(pass: GPURenderPassEncoder, inst: ModelInstance, type: "opaque" | "transparent"): void {
|
|
2841
|
+
let currentPipeline: GPURenderPipeline | null = null
|
|
2842
|
+
let bound = false
|
|
1877
2843
|
for (const draw of inst.drawCalls) {
|
|
1878
|
-
if (draw.type
|
|
1879
|
-
|
|
1880
|
-
pass.
|
|
2844
|
+
if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw)) continue
|
|
2845
|
+
if (!bound) {
|
|
2846
|
+
pass.setBindGroup(0, this.perFrameBindGroup)
|
|
2847
|
+
pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
|
|
2848
|
+
bound = true
|
|
2849
|
+
}
|
|
2850
|
+
const pipeline = this.pipelineForPreset(draw.preset)
|
|
2851
|
+
if (pipeline !== currentPipeline) {
|
|
2852
|
+
pass.setPipeline(pipeline)
|
|
2853
|
+
currentPipeline = pipeline
|
|
1881
2854
|
}
|
|
2855
|
+
pass.setBindGroup(2, draw.bindGroup)
|
|
2856
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
|
|
1882
2857
|
}
|
|
1883
2858
|
}
|
|
1884
2859
|
|
|
1885
|
-
|
|
1886
|
-
|
|
2860
|
+
/**
|
|
2861
|
+
* Draw every outline of a given type (`opaque-outline` or `transparent-outline`).
|
|
2862
|
+
* Uses its own pipeline layout (group 0 = camera-only, group 2 = edge uniforms), so
|
|
2863
|
+
* every batch binds its own groups from scratch — the next drawMaterials call will
|
|
2864
|
+
* rebind group 0/1 correctly if needed.
|
|
2865
|
+
*/
|
|
2866
|
+
private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, type: DrawCallType): void {
|
|
2867
|
+
let bound = false
|
|
1887
2868
|
for (const draw of inst.drawCalls) {
|
|
1888
|
-
if (draw.type
|
|
1889
|
-
|
|
1890
|
-
pass.
|
|
2869
|
+
if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw)) continue
|
|
2870
|
+
if (!bound) {
|
|
2871
|
+
pass.setPipeline(this.outlinePipeline)
|
|
2872
|
+
pass.setBindGroup(0, this.outlinePerFrameBindGroup)
|
|
2873
|
+
pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
|
|
2874
|
+
bound = true
|
|
1891
2875
|
}
|
|
2876
|
+
pass.setBindGroup(2, draw.bindGroup)
|
|
2877
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
|
|
1892
2878
|
}
|
|
1893
2879
|
}
|
|
1894
2880
|
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
2881
|
+
/**
|
|
2882
|
+
* Main-pass render sequence for one model instance:
|
|
2883
|
+
* 1) opaque bodies → 2) opaque outlines → 3) transparents → 4) transparent outlines.
|
|
2884
|
+
* Each batch binds the groups it needs, so switching between main and outline
|
|
2885
|
+
* pipelines is self-contained (no cross-batch dependencies).
|
|
2886
|
+
*/
|
|
1900
2887
|
private renderOneModel(pass: GPURenderPassEncoder, inst: ModelInstance): void {
|
|
1901
2888
|
pass.setVertexBuffer(0, inst.vertexBuffer)
|
|
1902
2889
|
pass.setVertexBuffer(1, inst.jointsBuffer)
|
|
1903
2890
|
pass.setVertexBuffer(2, inst.weightsBuffer)
|
|
1904
2891
|
pass.setIndexBuffer(inst.indexBuffer, "uint32")
|
|
1905
2892
|
|
|
1906
|
-
this.
|
|
1907
|
-
this.
|
|
1908
|
-
this.
|
|
1909
|
-
this.
|
|
1910
|
-
this.drawTransparent(pass, inst, this.modelPipeline)
|
|
1911
|
-
this.drawOutlines(pass, inst, true)
|
|
2893
|
+
this.drawMaterials(pass, inst, "opaque")
|
|
2894
|
+
this.drawOutlines(pass, inst, "opaque-outline")
|
|
2895
|
+
this.drawMaterials(pass, inst, "transparent")
|
|
2896
|
+
this.drawOutlines(pass, inst, "transparent-outline")
|
|
1912
2897
|
}
|
|
1913
2898
|
|
|
1914
|
-
|
|
1915
2899
|
private updateCameraUniforms() {
|
|
1916
2900
|
const viewMatrix = this.camera.getViewMatrix()
|
|
1917
2901
|
const projectionMatrix = this.camera.getProjectionMatrix()
|
|
@@ -1924,7 +2908,6 @@ export class Engine {
|
|
|
1924
2908
|
this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
|
|
1925
2909
|
}
|
|
1926
2910
|
|
|
1927
|
-
|
|
1928
2911
|
private updateSkinMatrices() {
|
|
1929
2912
|
this.forEachInstance((inst) => {
|
|
1930
2913
|
const skinMatrices = inst.model.getSkinMatrices()
|
|
@@ -1933,24 +2916,11 @@ export class Engine {
|
|
|
1933
2916
|
0,
|
|
1934
2917
|
skinMatrices.buffer,
|
|
1935
2918
|
skinMatrices.byteOffset,
|
|
1936
|
-
skinMatrices.byteLength
|
|
2919
|
+
skinMatrices.byteLength,
|
|
1937
2920
|
)
|
|
1938
2921
|
})
|
|
1939
2922
|
}
|
|
1940
2923
|
|
|
1941
|
-
private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, transparent: boolean) {
|
|
1942
|
-
pass.setPipeline(this.outlinePipeline)
|
|
1943
|
-
pass.setBindGroup(0, this.outlinePerFrameBindGroup)
|
|
1944
|
-
pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
|
|
1945
|
-
const outlineType: DrawCallType = transparent ? "transparent-outline" : "opaque-outline"
|
|
1946
|
-
for (const draw of inst.drawCalls) {
|
|
1947
|
-
if (draw.type === outlineType && this.shouldRenderDrawCall(inst, draw)) {
|
|
1948
|
-
pass.setBindGroup(2, draw.bindGroup)
|
|
1949
|
-
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
|
|
1950
|
-
}
|
|
1951
|
-
}
|
|
1952
|
-
}
|
|
1953
|
-
|
|
1954
2924
|
private updateStats(frameTime: number) {
|
|
1955
2925
|
// Simplified frame time tracking - rolling average with fixed window
|
|
1956
2926
|
const maxSamples = 60
|
|
@@ -1975,5 +2945,4 @@ export class Engine {
|
|
|
1975
2945
|
this.lastFpsUpdate = now
|
|
1976
2946
|
}
|
|
1977
2947
|
}
|
|
1978
|
-
|
|
1979
2948
|
}
|