topazcube 0.1.33 → 0.1.35

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.
Files changed (34) hide show
  1. package/dist/Renderer.cjs +2925 -281
  2. package/dist/Renderer.cjs.map +1 -1
  3. package/dist/Renderer.js +2925 -281
  4. package/dist/Renderer.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/renderer/DebugUI.js +176 -45
  7. package/src/renderer/Material.js +3 -0
  8. package/src/renderer/Pipeline.js +3 -1
  9. package/src/renderer/Renderer.js +185 -13
  10. package/src/renderer/core/AssetManager.js +40 -5
  11. package/src/renderer/core/CullingSystem.js +1 -0
  12. package/src/renderer/gltf.js +17 -0
  13. package/src/renderer/rendering/RenderGraph.js +224 -30
  14. package/src/renderer/rendering/passes/BloomPass.js +5 -2
  15. package/src/renderer/rendering/passes/CRTPass.js +724 -0
  16. package/src/renderer/rendering/passes/FogPass.js +26 -0
  17. package/src/renderer/rendering/passes/GBufferPass.js +31 -7
  18. package/src/renderer/rendering/passes/HiZPass.js +30 -0
  19. package/src/renderer/rendering/passes/LightingPass.js +14 -0
  20. package/src/renderer/rendering/passes/ParticlePass.js +10 -4
  21. package/src/renderer/rendering/passes/PostProcessPass.js +127 -4
  22. package/src/renderer/rendering/passes/SSGIPass.js +3 -2
  23. package/src/renderer/rendering/passes/SSGITilePass.js +14 -5
  24. package/src/renderer/rendering/passes/ShadowPass.js +265 -15
  25. package/src/renderer/rendering/passes/VolumetricFogPass.js +715 -0
  26. package/src/renderer/rendering/shaders/crt.wgsl +455 -0
  27. package/src/renderer/rendering/shaders/geometry.wgsl +36 -6
  28. package/src/renderer/rendering/shaders/particle_render.wgsl +153 -6
  29. package/src/renderer/rendering/shaders/postproc.wgsl +23 -2
  30. package/src/renderer/rendering/shaders/shadow.wgsl +42 -1
  31. package/src/renderer/rendering/shaders/volumetric_blur.wgsl +80 -0
  32. package/src/renderer/rendering/shaders/volumetric_composite.wgsl +80 -0
  33. package/src/renderer/rendering/shaders/volumetric_raymarch.wgsl +634 -0
  34. package/src/renderer/utils/Raycaster.js +761 -0
package/dist/Renderer.js CHANGED
@@ -751,6 +751,13 @@ function sphereInCascade(bsphere, cascadeMatrix) {
751
751
  if (clipZ + clipRadius < 0 || clipZ - clipRadius > 1) return false;
752
752
  return true;
753
753
  }
754
+ const BoundingSphere = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
755
+ __proto__: null,
756
+ calculateBoundingSphere,
757
+ calculateShadowBoundingSphere,
758
+ sphereInCascade,
759
+ transformBoundingSphere
760
+ }, Symbol.toStringTag, { value: "Module" }));
754
761
  var _UID$2 = 30001;
755
762
  class Geometry {
756
763
  constructor(engine, attributes) {
@@ -1913,6 +1920,7 @@ class Material {
1913
1920
  this.luminanceToAlpha = false;
1914
1921
  this.forceEmissive = false;
1915
1922
  this.specularBoost = 0;
1923
+ this.doubleSided = false;
1916
1924
  }
1917
1925
  /**
1918
1926
  * Get textures array, substituting albedo for emission if forceEmissive is true
@@ -2648,6 +2656,13 @@ class ShadowPass extends BasePass {
2648
2656
  this.noiseAnimated = true;
2649
2657
  this.hizPass = null;
2650
2658
  this._meshBindGroups = /* @__PURE__ */ new WeakMap();
2659
+ this._cameraShadowBuffer = null;
2660
+ this._cameraShadowReadBuffer = null;
2661
+ this._cameraShadowPipeline = null;
2662
+ this._cameraShadowBindGroup = null;
2663
+ this._cameraShadowUniformBuffer = null;
2664
+ this._cameraInShadow = false;
2665
+ this._cameraShadowPending = false;
2651
2666
  }
2652
2667
  /**
2653
2668
  * Set the HiZ pass for occlusion culling of static meshes
@@ -2764,6 +2779,7 @@ class ShadowPass extends BasePass {
2764
2779
  });
2765
2780
  this._createPlaceholderTextures();
2766
2781
  await this._createPipeline();
2782
+ await this._createCameraShadowDetection();
2767
2783
  }
2768
2784
  async _createPipeline() {
2769
2785
  const { device } = this.engine;
@@ -3207,12 +3223,13 @@ class ShadowPass extends BasePass {
3207
3223
  * @param {mat4} cascadeMatrix - Cascade's view-projection matrix
3208
3224
  * @param {Array} lightDir - Normalized light direction (pointing to light)
3209
3225
  * @param {number} groundLevel - Ground plane Y coordinate
3226
+ * @param {Object|null} combinedBsphere - Combined bsphere for skinned models (optional)
3210
3227
  * @returns {{ data: Float32Array, count: number }}
3211
3228
  */
3212
- _buildCascadeFilteredInstances(geometry, cascadeMatrix, lightDir, groundLevel) {
3229
+ _buildCascadeFilteredInstances(geometry, cascadeMatrix, lightDir, groundLevel, combinedBsphere = null) {
3213
3230
  const instanceStride = 28;
3214
3231
  const visibleIndices = [];
3215
- const localBsphere = geometry.getBoundingSphere?.();
3232
+ const localBsphere = combinedBsphere || geometry.getBoundingSphere?.();
3216
3233
  for (let i = 0; i < geometry.instanceCount; i++) {
3217
3234
  const offset = i * instanceStride;
3218
3235
  let bsphere = {
@@ -3260,12 +3277,13 @@ class ShadowPass extends BasePass {
3260
3277
  * @param {Array} lightDir - Normalized light direction
3261
3278
  * @param {number} maxDistance - Max shadow distance (min of light radius and spotMaxDistance)
3262
3279
  * @param {number} coneAngle - Half-angle of spotlight cone in radians
3280
+ * @param {Object|null} combinedBsphere - Combined bsphere for skinned models (optional)
3263
3281
  * @returns {{ data: Float32Array, count: number }}
3264
3282
  */
3265
- _buildFilteredInstances(geometry, lightPos, lightDir, maxDistance, coneAngle) {
3283
+ _buildFilteredInstances(geometry, lightPos, lightDir, maxDistance, coneAngle, combinedBsphere = null) {
3266
3284
  const instanceStride = 28;
3267
3285
  const visibleIndices = [];
3268
- const localBsphere = geometry.getBoundingSphere?.();
3286
+ const localBsphere = combinedBsphere || geometry.getBoundingSphere?.();
3269
3287
  for (let i = 0; i < geometry.instanceCount; i++) {
3270
3288
  const offset = i * instanceStride;
3271
3289
  let bsphere = {
@@ -3485,6 +3503,10 @@ class ShadowPass extends BasePass {
3485
3503
  console.warn("ShadowPass: No pipeline or meshes", { pipeline: !!this.pipeline, meshes: !!meshes });
3486
3504
  return;
3487
3505
  }
3506
+ this._meshBindGroups = /* @__PURE__ */ new WeakMap();
3507
+ if (this._skinBindGroups) {
3508
+ this._skinBindGroups = /* @__PURE__ */ new WeakMap();
3509
+ }
3488
3510
  let shadowDrawCalls = 0;
3489
3511
  let shadowTriangles = 0;
3490
3512
  let shadowCulledInstances = 0;
@@ -3523,7 +3545,7 @@ class ShadowPass extends BasePass {
3523
3545
  visibleMeshes[name] = mesh;
3524
3546
  continue;
3525
3547
  }
3526
- const localBsphere = geometry.getBoundingSphere?.();
3548
+ const localBsphere = mesh.combinedBsphere || geometry.getBoundingSphere?.();
3527
3549
  if (!localBsphere || localBsphere.radius <= 0) {
3528
3550
  meshNoBsphere++;
3529
3551
  visibleMeshes[name] = mesh;
@@ -3532,23 +3554,28 @@ class ShadowPass extends BasePass {
3532
3554
  const matrix = geometry.instanceData?.subarray(0, 16);
3533
3555
  const worldBsphere = matrix ? transformBoundingSphere(localBsphere, matrix) : localBsphere;
3534
3556
  const shadowBsphere = mainLightEnabled ? calculateShadowBoundingSphere(worldBsphere, lightDir, groundLevel) : worldBsphere;
3535
- const dx = shadowBsphere.center[0] - camera.position[0];
3536
- const dy = shadowBsphere.center[1] - camera.position[1];
3537
- const dz = shadowBsphere.center[2] - camera.position[2];
3538
- const distance = Math.sqrt(dx * dx + dy * dy + dz * dz) - shadowBsphere.radius;
3557
+ const skinnedExpansion = this.engine?.settings?.shadow?.skinnedBsphereExpansion ?? 2;
3558
+ const cullBsphere = mesh.hasSkin ? {
3559
+ center: shadowBsphere.center,
3560
+ radius: shadowBsphere.radius * skinnedExpansion
3561
+ } : shadowBsphere;
3562
+ const dx = cullBsphere.center[0] - camera.position[0];
3563
+ const dy = cullBsphere.center[1] - camera.position[1];
3564
+ const dz = cullBsphere.center[2] - camera.position[2];
3565
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz) - cullBsphere.radius;
3539
3566
  if (distance > shadowMaxDistance) {
3540
3567
  meshDistanceCulled++;
3541
3568
  continue;
3542
3569
  }
3543
3570
  if (shadowFrustumCullingEnabled && cameraFrustum) {
3544
- if (!cameraFrustum.testSpherePlanes(shadowBsphere)) {
3571
+ if (!cameraFrustum.testSpherePlanes(cullBsphere)) {
3545
3572
  meshFrustumCulled++;
3546
3573
  continue;
3547
3574
  }
3548
3575
  }
3549
3576
  if (shadowHiZEnabled && this.hizPass) {
3550
3577
  const occluded = this.hizPass.testSphereOcclusion(
3551
- shadowBsphere,
3578
+ cullBsphere,
3552
3579
  camera.viewProj,
3553
3580
  camera.near,
3554
3581
  camera.far,
@@ -3610,7 +3637,9 @@ class ShadowPass extends BasePass {
3610
3637
  geometry,
3611
3638
  this.cascadeMatrices[cascade],
3612
3639
  lightDir,
3613
- groundLevel
3640
+ groundLevel,
3641
+ mesh.combinedBsphere
3642
+ // Use combined bsphere for skinned models
3614
3643
  );
3615
3644
  if (filtered.count === 0) {
3616
3645
  cascadeCulledInstances += geometry.instanceCount;
@@ -3729,6 +3758,9 @@ class ShadowPass extends BasePass {
3729
3758
  this.cascadeMatricesData.set(this.cascadeMatrices[i], i * 16);
3730
3759
  }
3731
3760
  device.queue.writeBuffer(this.cascadeMatricesBuffer, 0, this.cascadeMatricesData);
3761
+ if (mainLightEnabled) {
3762
+ this._updateCameraShadowDetection(camera);
3763
+ }
3732
3764
  if (!mainLightEnabled) {
3733
3765
  for (const name in meshes) {
3734
3766
  const mesh = meshes[name];
@@ -3813,7 +3845,9 @@ class ShadowPass extends BasePass {
3813
3845
  lightPos,
3814
3846
  spotLightDir,
3815
3847
  spotShadowMaxDist,
3816
- coneAngle
3848
+ coneAngle,
3849
+ mesh.combinedBsphere
3850
+ // Use combined bsphere for skinned models
3817
3851
  );
3818
3852
  if (filtered.count === 0) {
3819
3853
  spotCulledInstances += geometry.instanceCount;
@@ -4042,6 +4076,184 @@ class ShadowPass extends BasePass {
4042
4076
  getLastSlotInfo() {
4043
4077
  return this.lastSlotInfo;
4044
4078
  }
4079
+ /**
4080
+ * Get cascade matrices as JavaScript arrays (for CPU-side calculations)
4081
+ */
4082
+ getCascadeMatrices() {
4083
+ return this.cascadeMatrices;
4084
+ }
4085
+ /**
4086
+ * Create resources for camera shadow detection
4087
+ * Uses a compute shader to sample shadow at camera position
4088
+ */
4089
+ async _createCameraShadowDetection() {
4090
+ const { device } = this.engine;
4091
+ this._cameraShadowUniformBuffer = device.createBuffer({
4092
+ label: "Camera Shadow Detection Uniforms",
4093
+ size: 256,
4094
+ // Aligned
4095
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
4096
+ });
4097
+ this._cameraShadowBuffer = device.createBuffer({
4098
+ label: "Camera Shadow Result",
4099
+ size: 4,
4100
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
4101
+ });
4102
+ this._cameraShadowReadBuffer = device.createBuffer({
4103
+ label: "Camera Shadow Readback",
4104
+ size: 4,
4105
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
4106
+ });
4107
+ const shaderModule = device.createShaderModule({
4108
+ label: "Camera Shadow Detection Shader",
4109
+ code: `
4110
+ struct Uniforms {
4111
+ cameraPosition: vec3f,
4112
+ _pad0: f32,
4113
+ cascadeMatrix0: mat4x4f,
4114
+ cascadeMatrix1: mat4x4f,
4115
+ cascadeMatrix2: mat4x4f,
4116
+ }
4117
+
4118
+ @group(0) @binding(0) var<uniform> uniforms: Uniforms;
4119
+ @group(0) @binding(1) var shadowMap: texture_depth_2d_array;
4120
+ @group(0) @binding(2) var shadowSampler: sampler_comparison;
4121
+ @group(0) @binding(3) var<storage, read_write> result: f32;
4122
+
4123
+ fn sampleShadowCascade(worldPos: vec3f, cascadeMatrix: mat4x4f, cascadeIndex: i32) -> f32 {
4124
+ let lightSpacePos = cascadeMatrix * vec4f(worldPos, 1.0);
4125
+ let projCoords = lightSpacePos.xyz / lightSpacePos.w;
4126
+
4127
+ // Convert to UV space
4128
+ let uv = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);
4129
+
4130
+ // Check bounds
4131
+ if (uv.x < 0.01 || uv.x > 0.99 || uv.y < 0.01 || uv.y > 0.99 ||
4132
+ projCoords.z < 0.0 || projCoords.z > 1.0) {
4133
+ return -1.0; // Out of bounds, try next cascade
4134
+ }
4135
+
4136
+ let bias = 0.005;
4137
+ let depth = projCoords.z - bias;
4138
+ return textureSampleCompareLevel(shadowMap, shadowSampler, uv, cascadeIndex, depth);
4139
+ }
4140
+
4141
+ @compute @workgroup_size(1)
4142
+ fn main() {
4143
+ let pos = uniforms.cameraPosition;
4144
+
4145
+ // Sample multiple points around camera (5m sphere)
4146
+ var totalShadow = 0.0;
4147
+ var sampleCount = 0.0;
4148
+
4149
+ let offsets = array<vec3f, 7>(
4150
+ vec3f(0.0, 0.0, 0.0), // Center
4151
+ vec3f(0.0, 3.0, 0.0), // Above
4152
+ vec3f(0.0, -2.0, 0.0), // Below
4153
+ vec3f(4.0, 0.0, 0.0), // Right
4154
+ vec3f(-4.0, 0.0, 0.0), // Left
4155
+ vec3f(0.0, 0.0, 4.0), // Front
4156
+ vec3f(0.0, 0.0, -4.0), // Back
4157
+ );
4158
+
4159
+ for (var i = 0; i < 7; i++) {
4160
+ let samplePos = pos + offsets[i];
4161
+
4162
+ // Try cascade 0 first (closest)
4163
+ var shadow = sampleShadowCascade(samplePos, uniforms.cascadeMatrix0, 0);
4164
+ if (shadow < 0.0) {
4165
+ // Try cascade 1
4166
+ shadow = sampleShadowCascade(samplePos, uniforms.cascadeMatrix1, 1);
4167
+ }
4168
+ if (shadow < 0.0) {
4169
+ // Try cascade 2
4170
+ shadow = sampleShadowCascade(samplePos, uniforms.cascadeMatrix2, 2);
4171
+ }
4172
+
4173
+ if (shadow >= 0.0) {
4174
+ totalShadow += shadow;
4175
+ sampleCount += 1.0;
4176
+ }
4177
+ }
4178
+
4179
+ // Average shadow (0 = all in shadow, 1 = all lit)
4180
+ // If no valid samples, assume lit
4181
+ if (sampleCount > 0.0) {
4182
+ result = totalShadow / sampleCount;
4183
+ } else {
4184
+ result = 1.0;
4185
+ }
4186
+ }
4187
+ `
4188
+ });
4189
+ this._cameraShadowBGL = device.createBindGroupLayout({
4190
+ label: "Camera Shadow Detection BGL",
4191
+ entries: [
4192
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
4193
+ { binding: 1, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: "depth", viewDimension: "2d-array" } },
4194
+ { binding: 2, visibility: GPUShaderStage.COMPUTE, sampler: { type: "comparison" } },
4195
+ { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } }
4196
+ ]
4197
+ });
4198
+ this._cameraShadowPipeline = await device.createComputePipelineAsync({
4199
+ label: "Camera Shadow Detection Pipeline",
4200
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this._cameraShadowBGL] }),
4201
+ compute: { module: shaderModule, entryPoint: "main" }
4202
+ });
4203
+ }
4204
+ /**
4205
+ * Update camera shadow detection (called during execute)
4206
+ * Dispatches compute shader and starts async readback
4207
+ */
4208
+ _updateCameraShadowDetection(camera) {
4209
+ if (!this._cameraShadowPipeline || !this.directionalShadowMap) return;
4210
+ if (this._cameraShadowPending) return;
4211
+ const { device } = this.engine;
4212
+ const cameraPos = camera.position || [0, 0, 0];
4213
+ const data = new Float32Array(64);
4214
+ data[0] = cameraPos[0];
4215
+ data[1] = cameraPos[1];
4216
+ data[2] = cameraPos[2];
4217
+ data[3] = 0;
4218
+ if (this.cascadeMatrices[0]) data.set(this.cascadeMatrices[0], 4);
4219
+ if (this.cascadeMatrices[1]) data.set(this.cascadeMatrices[1], 20);
4220
+ if (this.cascadeMatrices[2]) data.set(this.cascadeMatrices[2], 36);
4221
+ device.queue.writeBuffer(this._cameraShadowUniformBuffer, 0, data);
4222
+ const bindGroup = device.createBindGroup({
4223
+ layout: this._cameraShadowBGL,
4224
+ entries: [
4225
+ { binding: 0, resource: { buffer: this._cameraShadowUniformBuffer } },
4226
+ { binding: 1, resource: this.directionalShadowMapView },
4227
+ { binding: 2, resource: this.shadowSampler },
4228
+ { binding: 3, resource: { buffer: this._cameraShadowBuffer } }
4229
+ ]
4230
+ });
4231
+ const encoder = device.createCommandEncoder({ label: "Camera Shadow Detection" });
4232
+ const pass = encoder.beginComputePass();
4233
+ pass.setPipeline(this._cameraShadowPipeline);
4234
+ pass.setBindGroup(0, bindGroup);
4235
+ pass.dispatchWorkgroups(1);
4236
+ pass.end();
4237
+ encoder.copyBufferToBuffer(this._cameraShadowBuffer, 0, this._cameraShadowReadBuffer, 0, 4);
4238
+ device.queue.submit([encoder.finish()]);
4239
+ this._cameraShadowPending = true;
4240
+ this._cameraShadowReadBuffer.mapAsync(GPUMapMode.READ).then(() => {
4241
+ const data2 = new Float32Array(this._cameraShadowReadBuffer.getMappedRange());
4242
+ const shadowValue = data2[0];
4243
+ this._cameraShadowReadBuffer.unmap();
4244
+ this._cameraShadowPending = false;
4245
+ this._cameraInShadow = shadowValue < 0.3;
4246
+ }).catch(() => {
4247
+ this._cameraShadowPending = false;
4248
+ });
4249
+ }
4250
+ /**
4251
+ * Check if camera is in shadow (uses async readback result from previous frames)
4252
+ * @returns {boolean} True if camera is mostly in shadow
4253
+ */
4254
+ isCameraInShadow() {
4255
+ return this._cameraInShadow;
4256
+ }
4045
4257
  }
4046
4258
  class ProbeCapture {
4047
4259
  constructor(engine) {
@@ -5699,8 +5911,10 @@ class Pipeline {
5699
5911
  // Optional tile light indices buffer for tiled lighting
5700
5912
  lightBuffer = null,
5701
5913
  // Optional light storage buffer for tiled lighting
5702
- noiseTexture = null
5914
+ noiseTexture = null,
5703
5915
  // Optional noise texture for alpha hashing
5916
+ doubleSided = false
5917
+ // Optional: disable backface culling for double-sided materials
5704
5918
  }) {
5705
5919
  let texture = textures[0];
5706
5920
  const { canvas, device, canvasFormat, options } = engine;
@@ -5762,7 +5976,7 @@ class Pipeline {
5762
5976
  geometry.vertexBufferLayout,
5763
5977
  geometry.instanceBufferLayout
5764
5978
  ];
5765
- pipelineDescriptor.primitive.cullMode = "back";
5979
+ pipelineDescriptor.primitive.cullMode = doubleSided ? "none" : "back";
5766
5980
  pipelineDescriptor.depthStencil = {
5767
5981
  depthWriteEnabled: true,
5768
5982
  depthCompare: "less",
@@ -5990,6 +6204,7 @@ class Pipeline {
5990
6204
  p.tileLightBuffer = tileLightBuffer;
5991
6205
  p.lightBuffer = lightBuffer;
5992
6206
  p.noiseTexture = noiseTexture;
6207
+ p.doubleSided = doubleSided;
5993
6208
  return p;
5994
6209
  }
5995
6210
  static async pipelineFromTextures(engine, pipelineDescriptor, label, textures, uniformBuffer, skin = null, noiseTexture = null) {
@@ -6164,7 +6379,7 @@ class Pipeline {
6164
6379
  }
6165
6380
  }
6166
6381
  }
6167
- var geometry_default = "struct VertexInput {\n @location(0) position: vec3f,\n @location(1) uv: vec2f,\n @location(2) normal: vec3f,\n @location(3) color: vec4f,\n @location(4) weights: vec4f,\n @location(5) joints: vec4u,\n}\n\nstruct VertexOutput {\n @invariant @builtin(position) position: vec4f, \n @location(0) worldPos: vec3f,\n @location(1) uv: vec2f,\n @location(2) normal: vec3f,\n @location(3) viewZ: f32, \n @location(4) currClipPos: vec4f, \n @location(5) prevClipPos: vec4f, \n @location(6) instanceColor: vec4f, \n @location(7) anchorY: f32, \n}\n\nstruct Uniforms {\n viewMatrix: mat4x4f,\n projectionMatrix: mat4x4f,\n prevViewProjMatrix: mat4x4f, \n mipBias: f32,\n skinEnabled: f32, \n numJoints: f32, \n near: f32, \n far: f32, \n jitterFadeDistance: f32, \n jitterOffset: vec2f, \n screenSize: vec2f, \n emissionFactor: vec4f,\n clipPlaneY: f32, \n clipPlaneEnabled: f32, \n clipPlaneDirection: f32, \n pixelRounding: f32, \n pixelExpansion: f32, \n positionRounding: f32, \n alphaHashEnabled: f32, \n alphaHashScale: f32, \n luminanceToAlpha: f32, \n noiseSize: f32, \n noiseOffsetX: f32, \n noiseOffsetY: f32, \n cameraPosition: vec3f, \n distanceFadeStart: f32, \n distanceFadeEnd: f32, \n \n billboardMode: f32, \n billboardCameraRight: vec3f, \n _pad1: f32,\n billboardCameraUp: vec3f, \n _pad2: f32,\n billboardCameraForward: vec3f, \n specularBoost: f32, \n}\n\nstruct InstanceData {\n modelMatrix: mat4x4f,\n posRadius: vec4f,\n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var albedoTexture: texture_2d<f32>;\n@group(0) @binding(2) var albedoSampler: sampler;\n@group(0) @binding(3) var normalTexture: texture_2d<f32>;\n@group(0) @binding(4) var normalSampler: sampler;\n@group(0) @binding(5) var ambientTexture: texture_2d<f32>;\n@group(0) @binding(6) var ambientSampler: sampler;\n@group(0) @binding(7) var rmTexture: texture_2d<f32>;\n@group(0) @binding(8) var rmSampler: sampler;\n@group(0) @binding(9) var emissionTexture: texture_2d<f32>;\n@group(0) @binding(10) var emissionSampler: sampler;\n@group(0) @binding(11) var jointTexture: texture_2d<f32>;\n@group(0) @binding(12) var jointSampler: sampler;\n@group(0) @binding(13) var prevJointTexture: texture_2d<f32>; \n@group(0) @binding(14) var noiseTexture: texture_2d<f32>; \n\nfn sampleNoise(screenPos: vec2f) -> f32 {\n let noiseSize = i32(uniforms.noiseSize);\n let noiseOffsetX = i32(uniforms.noiseOffsetX * f32(noiseSize));\n let noiseOffsetY = i32(uniforms.noiseOffsetY * f32(noiseSize));\n\n let texCoord = vec2i(\n (i32(screenPos.x) + noiseOffsetX) % noiseSize,\n (i32(screenPos.y) + noiseOffsetY) % noiseSize\n );\n return textureLoad(noiseTexture, texCoord, 0).r;\n}\n\nfn getJointMatrix(jointIndex: u32) -> mat4x4f {\n let row = i32(jointIndex);\n let col0 = textureLoad(jointTexture, vec2i(0, row), 0);\n let col1 = textureLoad(jointTexture, vec2i(1, row), 0);\n let col2 = textureLoad(jointTexture, vec2i(2, row), 0);\n let col3 = textureLoad(jointTexture, vec2i(3, row), 0);\n return mat4x4f(col0, col1, col2, col3);\n}\n\nfn getPrevJointMatrix(jointIndex: u32) -> mat4x4f {\n let row = i32(jointIndex);\n let col0 = textureLoad(prevJointTexture, vec2i(0, row), 0);\n let col1 = textureLoad(prevJointTexture, vec2i(1, row), 0);\n let col2 = textureLoad(prevJointTexture, vec2i(2, row), 0);\n let col3 = textureLoad(prevJointTexture, vec2i(3, row), 0);\n return mat4x4f(col0, col1, col2, col3);\n}\n\nfn applySkinning(position: vec3f, joints: vec4u, weights: vec4f) -> vec3f {\n var skinnedPos = vec3f(0.0);\n\n let m0 = getJointMatrix(joints.x);\n let m1 = getJointMatrix(joints.y);\n let m2 = getJointMatrix(joints.z);\n let m3 = getJointMatrix(joints.w);\n\n skinnedPos += (m0 * vec4f(position, 1.0)).xyz * weights.x;\n skinnedPos += (m1 * vec4f(position, 1.0)).xyz * weights.y;\n skinnedPos += (m2 * vec4f(position, 1.0)).xyz * weights.z;\n skinnedPos += (m3 * vec4f(position, 1.0)).xyz * weights.w;\n\n return skinnedPos;\n}\n\nfn applySkinningPrev(position: vec3f, joints: vec4u, weights: vec4f) -> vec3f {\n var skinnedPos = vec3f(0.0);\n\n let m0 = getPrevJointMatrix(joints.x);\n let m1 = getPrevJointMatrix(joints.y);\n let m2 = getPrevJointMatrix(joints.z);\n let m3 = getPrevJointMatrix(joints.w);\n\n skinnedPos += (m0 * vec4f(position, 1.0)).xyz * weights.x;\n skinnedPos += (m1 * vec4f(position, 1.0)).xyz * weights.y;\n skinnedPos += (m2 * vec4f(position, 1.0)).xyz * weights.z;\n skinnedPos += (m3 * vec4f(position, 1.0)).xyz * weights.w;\n\n return skinnedPos;\n}\n\nfn applySkinningNormal(normal: vec3f, joints: vec4u, weights: vec4f) -> vec3f {\n var skinnedNormal = vec3f(0.0);\n\n let m0 = getJointMatrix(joints.x);\n let m1 = getJointMatrix(joints.y);\n let m2 = getJointMatrix(joints.z);\n let m3 = getJointMatrix(joints.w);\n\n \n let r0 = mat3x3f(m0[0].xyz, m0[1].xyz, m0[2].xyz);\n let r1 = mat3x3f(m1[0].xyz, m1[1].xyz, m1[2].xyz);\n let r2 = mat3x3f(m2[0].xyz, m2[1].xyz, m2[2].xyz);\n let r3 = mat3x3f(m3[0].xyz, m3[1].xyz, m3[2].xyz);\n\n skinnedNormal += (r0 * normal) * weights.x;\n skinnedNormal += (r1 * normal) * weights.y;\n skinnedNormal += (r2 * normal) * weights.z;\n skinnedNormal += (r3 * normal) * weights.w;\n\n return normalize(skinnedNormal);\n}\n\n@vertex\nfn vertexMain(\n input: VertexInput,\n @builtin(instance_index) instanceIdx : u32,\n @location(6) instanceModelMatrix0: vec4f,\n @location(7) instanceModelMatrix1: vec4f,\n @location(8) instanceModelMatrix2: vec4f,\n @location(9) instanceModelMatrix3: vec4f,\n @location(10) posRadius: vec4f,\n @location(11) uvTransform: vec4f, \n @location(12) instanceColor: vec4f, \n) -> VertexOutput {\n var output: VertexOutput;\n\n \n let instanceModelMatrix = mat4x4f(\n instanceModelMatrix0,\n instanceModelMatrix1,\n instanceModelMatrix2,\n instanceModelMatrix3\n );\n\n var localPos = input.position;\n var prevLocalPos = input.position; \n var localNormal = input.normal;\n var billboardWorldPos: vec3f;\n var useBillboardWorldPos = false;\n\n \n if (uniforms.billboardMode > 0.5) {\n \n let entityPos = instanceModelMatrix[3].xyz;\n\n \n let scaleX = length(instanceModelMatrix[0].xyz);\n let scaleY = length(instanceModelMatrix[1].xyz);\n\n if (uniforms.billboardMode < 1.5) {\n \n \n let toCamera = uniforms.cameraPosition - entityPos;\n let toCameraDist = length(toCamera);\n\n var right: vec3f;\n var up: vec3f;\n\n if (toCameraDist > 0.001) {\n let forward = toCamera / toCameraDist;\n \n right = cross(vec3f(0.0, 1.0, 0.0), forward);\n let rightLen = length(right);\n if (rightLen < 0.001) {\n \n right = uniforms.billboardCameraRight;\n up = vec3f(0.0, 0.0, 1.0); \n } else {\n right = right / rightLen;\n \n up = cross(forward, right);\n }\n } else {\n right = uniforms.billboardCameraRight;\n up = vec3f(0.0, 1.0, 0.0);\n }\n\n let offset = right * input.position.x * scaleX + up * input.position.y * scaleY;\n billboardWorldPos = entityPos + offset;\n useBillboardWorldPos = true;\n localNormal = vec3f(0.0, 1.0, 0.0); \n\n } else if (uniforms.billboardMode < 2.5) {\n \n \n let toCamera = uniforms.cameraPosition - entityPos;\n let toCameraDist = length(toCamera);\n\n var right: vec3f;\n var billUp: vec3f;\n\n if (toCameraDist > 0.001) {\n let forward = toCamera / toCameraDist; \n \n right = cross(vec3f(0.0, 1.0, 0.0), forward);\n let rightLen = length(right);\n if (rightLen < 0.001) {\n \n right = uniforms.billboardCameraRight;\n billUp = uniforms.billboardCameraUp;\n } else {\n right = right / rightLen;\n \n billUp = cross(forward, right);\n }\n } else {\n \n right = uniforms.billboardCameraRight;\n billUp = uniforms.billboardCameraUp;\n }\n\n let offset = right * input.position.x * scaleX + billUp * input.position.y * scaleY;\n billboardWorldPos = entityPos + offset;\n useBillboardWorldPos = true;\n localNormal = vec3f(0.0, 1.0, 0.0); \n\n } else {\n \n \n \n \n localNormal = vec3f(0.0, 1.0, 0.0); \n }\n }\n\n \n if (uniforms.skinEnabled > 0.5) {\n \n let weightSum = input.weights.x + input.weights.y + input.weights.z + input.weights.w;\n if (weightSum > 0.001) {\n localPos = applySkinning(input.position, input.joints, input.weights);\n prevLocalPos = applySkinningPrev(input.position, input.joints, input.weights);\n localNormal = applySkinningNormal(input.normal, input.joints, input.weights);\n }\n }\n\n \n \n \n var worldPos: vec3f;\n if (useBillboardWorldPos) {\n worldPos = billboardWorldPos;\n } else {\n worldPos = (instanceModelMatrix * vec4f(localPos, 1.0)).xyz;\n }\n var viewPos = uniforms.viewMatrix * vec4f(worldPos, 1.0);\n\n \n let allowRounding = posRadius.w >= 0.0;\n\n \n \n if (allowRounding && uniforms.positionRounding > 0.0) {\n let snap = uniforms.positionRounding;\n viewPos = vec4f(\n floor(viewPos.x / snap) * snap,\n floor(viewPos.y / snap) * snap,\n viewPos.z,\n viewPos.w\n );\n }\n\n var clipPos = uniforms.projectionMatrix * viewPos;\n\n \n \n \n var prevWorldPos: vec3f;\n if (useBillboardWorldPos) {\n prevWorldPos = billboardWorldPos; \n } else {\n prevWorldPos = (instanceModelMatrix * vec4f(prevLocalPos, 1.0)).xyz;\n }\n let prevClipPos = uniforms.prevViewProjMatrix * vec4f(prevWorldPos, 1.0);\n\n \n \n let viewDist = -viewPos.z; \n let jitterFade = saturate(1.0 - viewDist / uniforms.jitterFadeDistance);\n\n \n \n let jitterClip = uniforms.jitterOffset * 2.0 / uniforms.screenSize * jitterFade;\n clipPos.x += jitterClip.x * clipPos.w;\n clipPos.y += jitterClip.y * clipPos.w;\n\n \n \n if (allowRounding && uniforms.pixelRounding > 0.5) {\n let gridSize = uniforms.pixelRounding; \n let ndc = clipPos.xy / clipPos.w;\n\n \n let pixelCoords = (ndc + 1.0) * 0.5 * uniforms.screenSize;\n\n \n var snappedPixel = floor(pixelCoords / gridSize) * gridSize;\n\n \n if (uniforms.pixelExpansion > 0.0) {\n let screenCenter = uniforms.screenSize * 0.5;\n let fromCenter = snappedPixel - screenCenter;\n snappedPixel += sign(fromCenter) * uniforms.pixelExpansion;\n }\n\n \n let snappedNDC = (snappedPixel / uniforms.screenSize) * 2.0 - 1.0;\n\n \n clipPos.x = snappedNDC.x * clipPos.w;\n clipPos.y = snappedNDC.y * clipPos.w;\n }\n\n output.position = clipPos;\n output.worldPos = worldPos;\n \n output.uv = input.uv * uvTransform.zw + uvTransform.xy;\n output.viewZ = viewDist;\n output.currClipPos = clipPos;\n output.prevClipPos = prevClipPos;\n output.instanceColor = instanceColor;\n\n \n \n \n \n if (uniforms.billboardMode > 0.5) {\n let entityPos = instanceModelMatrix[3].xyz;\n output.anchorY = entityPos.y;\n } else {\n output.anchorY = worldPos.y; \n }\n\n \n \n if (uniforms.billboardMode > 0.5) {\n output.normal = localNormal; \n } else {\n let normalMatrix = mat3x3f(\n instanceModelMatrix[0].xyz,\n instanceModelMatrix[1].xyz,\n instanceModelMatrix[2].xyz\n );\n output.normal = normalMatrix * localNormal;\n }\n return output;\n}\n\nstruct GBufferOutput {\n @location(0) albedo: vec4f,\n @location(1) normal: vec4f,\n @location(2) arm: vec4f, \n @location(3) emission: vec4f,\n @location(4) velocity: vec2f, \n @builtin(frag_depth) depth: f32, \n}\n\nfn interleavedGradientNoise(screenPos: vec2f) -> f32 {\n let magic = vec3f(0.06711056, 0.00583715, 52.9829189);\n return fract(magic.z * fract(dot(screenPos, magic.xy)));\n}\n\nfn screenHash(screenPos: vec2f) -> f32 {\n let p = fract(screenPos * vec2f(0.1031, 0.1030));\n let p3 = p.xyx * (p.yxy + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput) -> GBufferOutput {\n \n \n \n \n \n if (uniforms.clipPlaneEnabled > 0.5 && uniforms.billboardMode < 0.5) {\n let clipDist = (input.worldPos.y - uniforms.clipPlaneY) * uniforms.clipPlaneDirection;\n if (clipDist < 0.0) {\n discard;\n }\n }\n\n \n \n if (uniforms.distanceFadeEnd > 0.0) {\n let distToCamera = length(input.worldPos - uniforms.cameraPosition);\n if (distToCamera >= uniforms.distanceFadeEnd) {\n discard; \n }\n if (distToCamera > uniforms.distanceFadeStart) {\n \n let fadeRange = uniforms.distanceFadeEnd - uniforms.distanceFadeStart;\n let fadeFactor = 1.0 - (distToCamera - uniforms.distanceFadeStart) / fadeRange;\n \n let noise = sampleNoise(input.position.xy);\n if (fadeFactor < noise) {\n discard; \n }\n }\n }\n\n var output: GBufferOutput;\n\n \n let mipBias = uniforms.mipBias; \n output.albedo = textureSampleBias(albedoTexture, albedoSampler, input.uv, mipBias);\n\n \n output.albedo = output.albedo * input.instanceColor;\n\n \n \n if (uniforms.luminanceToAlpha > 0.5) {\n let luminance = dot(output.albedo.rgb, vec3f(0.299, 0.587, 0.114));\n if (luminance < 0.004) {\n discard; \n }\n output.albedo.a = 1.0; \n }\n\n \n \n if (uniforms.alphaHashEnabled > 0.5 && uniforms.luminanceToAlpha < 0.5) {\n let alpha = output.albedo.a * uniforms.alphaHashScale;\n let noise = sampleNoise(input.position.xy);\n\n \n if (alpha < 0.5) {\n discard;\n }\n \n \n let remappedAlpha = (alpha - 0.5) * 2.0;\n if (remappedAlpha < noise) {\n discard;\n }\n }\n\n \n\n \n let nsample = textureSampleBias(normalTexture, normalSampler, input.uv, mipBias).rgb;\n let tangentNormal = normalize(nsample * 2.0 - 1.0);\n\n \n let N = normalize(input.normal);\n \n let refVec = select(vec3f(0.0, 1.0, 0.0), vec3f(1.0, 0.0, 0.0), abs(N.y) > 0.9);\n let T = normalize(cross(N, refVec));\n let B = cross(N, T);\n let TBN = mat3x3f(T, B, N);\n\n \n let normal = normalize(TBN * tangentNormal);\n \n output.normal = vec4f(normal, input.worldPos.y);\n\n \n var ambient = textureSampleBias(ambientTexture, ambientSampler, input.uv, mipBias).rgb;\n if (ambient.r < 0.04) {\n ambient.r = 1.0;\n }\n let rm = textureSampleBias(rmTexture, rmSampler, input.uv, mipBias).rgb;\n output.arm = vec4f(ambient.r, rm.g, rm.b, uniforms.specularBoost);\n\n \n output.emission = textureSampleBias(emissionTexture, emissionSampler, input.uv, mipBias) * uniforms.emissionFactor;\n\n \n \n let currNDC = input.currClipPos.xy / input.currClipPos.w;\n let prevNDC = input.prevClipPos.xy / input.prevClipPos.w;\n\n \n \n \n let velocityNDC = currNDC - prevNDC;\n output.velocity = velocityNDC * uniforms.screenSize * 0.5;\n\n \n let near = uniforms.near;\n let far = uniforms.far;\n let z = input.viewZ;\n output.depth = (z - near) / (far - near);\n\n return output;\n}";
6382
+ var geometry_default = "struct VertexInput {\n @location(0) position: vec3f,\n @location(1) uv: vec2f,\n @location(2) normal: vec3f,\n @location(3) color: vec4f,\n @location(4) weights: vec4f,\n @location(5) joints: vec4u,\n}\n\nstruct VertexOutput {\n @invariant @builtin(position) position: vec4f, \n @location(0) worldPos: vec3f,\n @location(1) uv: vec2f,\n @location(2) normal: vec3f,\n @location(3) viewZ: f32, \n @location(4) currClipPos: vec4f, \n @location(5) prevClipPos: vec4f, \n @location(6) instanceColor: vec4f, \n @location(7) anchorY: f32, \n}\n\nstruct Uniforms {\n viewMatrix: mat4x4f,\n projectionMatrix: mat4x4f,\n prevViewProjMatrix: mat4x4f, \n mipBias: f32,\n skinEnabled: f32, \n numJoints: f32, \n near: f32, \n far: f32, \n jitterFadeDistance: f32, \n jitterOffset: vec2f, \n screenSize: vec2f, \n emissionFactor: vec4f,\n clipPlaneY: f32, \n clipPlaneEnabled: f32, \n clipPlaneDirection: f32, \n pixelRounding: f32, \n pixelExpansion: f32, \n positionRounding: f32, \n alphaHashEnabled: f32, \n alphaHashScale: f32, \n luminanceToAlpha: f32, \n noiseSize: f32, \n noiseOffsetX: f32, \n noiseOffsetY: f32, \n cameraPosition: vec3f, \n distanceFadeStart: f32, \n distanceFadeEnd: f32, \n \n billboardMode: f32, \n billboardCameraRight: vec3f, \n _pad1: f32,\n billboardCameraUp: vec3f, \n _pad2: f32,\n billboardCameraForward: vec3f, \n specularBoost: f32, \n}\n\nstruct InstanceData {\n modelMatrix: mat4x4f,\n posRadius: vec4f,\n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var albedoTexture: texture_2d<f32>;\n@group(0) @binding(2) var albedoSampler: sampler;\n@group(0) @binding(3) var normalTexture: texture_2d<f32>;\n@group(0) @binding(4) var normalSampler: sampler;\n@group(0) @binding(5) var ambientTexture: texture_2d<f32>;\n@group(0) @binding(6) var ambientSampler: sampler;\n@group(0) @binding(7) var rmTexture: texture_2d<f32>;\n@group(0) @binding(8) var rmSampler: sampler;\n@group(0) @binding(9) var emissionTexture: texture_2d<f32>;\n@group(0) @binding(10) var emissionSampler: sampler;\n@group(0) @binding(11) var jointTexture: texture_2d<f32>;\n@group(0) @binding(12) var jointSampler: sampler;\n@group(0) @binding(13) var prevJointTexture: texture_2d<f32>; \n@group(0) @binding(14) var noiseTexture: texture_2d<f32>; \n\nfn sampleNoise(screenPos: vec2f) -> f32 {\n let noiseSize = i32(uniforms.noiseSize);\n let noiseOffsetX = i32(uniforms.noiseOffsetX * f32(noiseSize));\n let noiseOffsetY = i32(uniforms.noiseOffsetY * f32(noiseSize));\n\n let texCoord = vec2i(\n (i32(screenPos.x) + noiseOffsetX) % noiseSize,\n (i32(screenPos.y) + noiseOffsetY) % noiseSize\n );\n return textureLoad(noiseTexture, texCoord, 0).r;\n}\n\nfn getJointMatrix(jointIndex: u32) -> mat4x4f {\n let row = i32(jointIndex);\n let col0 = textureLoad(jointTexture, vec2i(0, row), 0);\n let col1 = textureLoad(jointTexture, vec2i(1, row), 0);\n let col2 = textureLoad(jointTexture, vec2i(2, row), 0);\n let col3 = textureLoad(jointTexture, vec2i(3, row), 0);\n return mat4x4f(col0, col1, col2, col3);\n}\n\nfn getPrevJointMatrix(jointIndex: u32) -> mat4x4f {\n let row = i32(jointIndex);\n let col0 = textureLoad(prevJointTexture, vec2i(0, row), 0);\n let col1 = textureLoad(prevJointTexture, vec2i(1, row), 0);\n let col2 = textureLoad(prevJointTexture, vec2i(2, row), 0);\n let col3 = textureLoad(prevJointTexture, vec2i(3, row), 0);\n return mat4x4f(col0, col1, col2, col3);\n}\n\nfn applySkinning(position: vec3f, joints: vec4u, weights: vec4f) -> vec3f {\n var skinnedPos = vec3f(0.0);\n\n let m0 = getJointMatrix(joints.x);\n let m1 = getJointMatrix(joints.y);\n let m2 = getJointMatrix(joints.z);\n let m3 = getJointMatrix(joints.w);\n\n skinnedPos += (m0 * vec4f(position, 1.0)).xyz * weights.x;\n skinnedPos += (m1 * vec4f(position, 1.0)).xyz * weights.y;\n skinnedPos += (m2 * vec4f(position, 1.0)).xyz * weights.z;\n skinnedPos += (m3 * vec4f(position, 1.0)).xyz * weights.w;\n\n return skinnedPos;\n}\n\nfn applySkinningPrev(position: vec3f, joints: vec4u, weights: vec4f) -> vec3f {\n var skinnedPos = vec3f(0.0);\n\n let m0 = getPrevJointMatrix(joints.x);\n let m1 = getPrevJointMatrix(joints.y);\n let m2 = getPrevJointMatrix(joints.z);\n let m3 = getPrevJointMatrix(joints.w);\n\n skinnedPos += (m0 * vec4f(position, 1.0)).xyz * weights.x;\n skinnedPos += (m1 * vec4f(position, 1.0)).xyz * weights.y;\n skinnedPos += (m2 * vec4f(position, 1.0)).xyz * weights.z;\n skinnedPos += (m3 * vec4f(position, 1.0)).xyz * weights.w;\n\n return skinnedPos;\n}\n\nfn applySkinningNormal(normal: vec3f, joints: vec4u, weights: vec4f) -> vec3f {\n var skinnedNormal = vec3f(0.0);\n\n let m0 = getJointMatrix(joints.x);\n let m1 = getJointMatrix(joints.y);\n let m2 = getJointMatrix(joints.z);\n let m3 = getJointMatrix(joints.w);\n\n \n let r0 = mat3x3f(m0[0].xyz, m0[1].xyz, m0[2].xyz);\n let r1 = mat3x3f(m1[0].xyz, m1[1].xyz, m1[2].xyz);\n let r2 = mat3x3f(m2[0].xyz, m2[1].xyz, m2[2].xyz);\n let r3 = mat3x3f(m3[0].xyz, m3[1].xyz, m3[2].xyz);\n\n skinnedNormal += (r0 * normal) * weights.x;\n skinnedNormal += (r1 * normal) * weights.y;\n skinnedNormal += (r2 * normal) * weights.z;\n skinnedNormal += (r3 * normal) * weights.w;\n\n return normalize(skinnedNormal);\n}\n\n@vertex\nfn vertexMain(\n input: VertexInput,\n @builtin(instance_index) instanceIdx : u32,\n @location(6) instanceModelMatrix0: vec4f,\n @location(7) instanceModelMatrix1: vec4f,\n @location(8) instanceModelMatrix2: vec4f,\n @location(9) instanceModelMatrix3: vec4f,\n @location(10) posRadius: vec4f,\n @location(11) uvTransform: vec4f, \n @location(12) instanceColor: vec4f, \n) -> VertexOutput {\n var output: VertexOutput;\n\n \n let instanceModelMatrix = mat4x4f(\n instanceModelMatrix0,\n instanceModelMatrix1,\n instanceModelMatrix2,\n instanceModelMatrix3\n );\n\n var localPos = input.position;\n var prevLocalPos = input.position; \n var localNormal = input.normal;\n var billboardWorldPos: vec3f;\n var useBillboardWorldPos = false;\n\n \n if (uniforms.billboardMode > 0.5) {\n \n let entityPos = instanceModelMatrix[3].xyz;\n\n \n let scaleX = length(instanceModelMatrix[0].xyz);\n let scaleY = length(instanceModelMatrix[1].xyz);\n\n if (uniforms.billboardMode < 1.5) {\n \n \n let toCamera = uniforms.cameraPosition - entityPos;\n let toCameraDist = length(toCamera);\n\n var right: vec3f;\n var up: vec3f;\n\n if (toCameraDist > 0.001) {\n let forward = toCamera / toCameraDist;\n \n right = cross(vec3f(0.0, 1.0, 0.0), forward);\n let rightLen = length(right);\n if (rightLen < 0.001) {\n \n right = uniforms.billboardCameraRight;\n up = vec3f(0.0, 0.0, 1.0); \n } else {\n right = right / rightLen;\n \n up = cross(forward, right);\n }\n } else {\n right = uniforms.billboardCameraRight;\n up = vec3f(0.0, 1.0, 0.0);\n }\n\n let offset = right * input.position.x * scaleX + up * input.position.y * scaleY;\n billboardWorldPos = entityPos + offset;\n useBillboardWorldPos = true;\n localNormal = vec3f(0.0, 1.0, 0.0); \n\n } else if (uniforms.billboardMode < 2.5) {\n \n \n let toCamera = uniforms.cameraPosition - entityPos;\n let toCameraDist = length(toCamera);\n\n var right: vec3f;\n var billUp: vec3f;\n\n if (toCameraDist > 0.001) {\n let forward = toCamera / toCameraDist; \n \n right = cross(vec3f(0.0, 1.0, 0.0), forward);\n let rightLen = length(right);\n if (rightLen < 0.001) {\n \n right = uniforms.billboardCameraRight;\n billUp = uniforms.billboardCameraUp;\n } else {\n right = right / rightLen;\n \n billUp = cross(forward, right);\n }\n } else {\n \n right = uniforms.billboardCameraRight;\n billUp = uniforms.billboardCameraUp;\n }\n\n let offset = right * input.position.x * scaleX + billUp * input.position.y * scaleY;\n billboardWorldPos = entityPos + offset;\n useBillboardWorldPos = true;\n localNormal = vec3f(0.0, 1.0, 0.0); \n\n } else {\n \n \n \n \n localNormal = vec3f(0.0, 1.0, 0.0); \n }\n }\n\n \n if (uniforms.skinEnabled > 0.5) {\n \n let weightSum = input.weights.x + input.weights.y + input.weights.z + input.weights.w;\n if (weightSum > 0.001) {\n localPos = applySkinning(input.position, input.joints, input.weights);\n prevLocalPos = applySkinningPrev(input.position, input.joints, input.weights);\n localNormal = applySkinningNormal(input.normal, input.joints, input.weights);\n }\n }\n\n \n \n \n var worldPos: vec3f;\n if (useBillboardWorldPos) {\n worldPos = billboardWorldPos;\n } else {\n worldPos = (instanceModelMatrix * vec4f(localPos, 1.0)).xyz;\n }\n var viewPos = uniforms.viewMatrix * vec4f(worldPos, 1.0);\n\n \n let allowRounding = posRadius.w >= 0.0;\n\n \n \n if (allowRounding && uniforms.positionRounding > 0.0) {\n let snap = uniforms.positionRounding;\n viewPos = vec4f(\n floor(viewPos.x / snap) * snap,\n floor(viewPos.y / snap) * snap,\n viewPos.z,\n viewPos.w\n );\n }\n\n var clipPos = uniforms.projectionMatrix * viewPos;\n\n \n \n \n var prevWorldPos: vec3f;\n if (useBillboardWorldPos) {\n prevWorldPos = billboardWorldPos; \n } else {\n prevWorldPos = (instanceModelMatrix * vec4f(prevLocalPos, 1.0)).xyz;\n }\n let prevClipPos = uniforms.prevViewProjMatrix * vec4f(prevWorldPos, 1.0);\n\n \n \n let viewDist = -viewPos.z; \n let jitterFade = saturate(1.0 - viewDist / uniforms.jitterFadeDistance);\n\n \n \n let jitterClip = uniforms.jitterOffset * 2.0 / uniforms.screenSize * jitterFade;\n clipPos.x += jitterClip.x * clipPos.w;\n clipPos.y += jitterClip.y * clipPos.w;\n\n \n \n if (allowRounding && uniforms.pixelRounding > 0.5) {\n let gridSize = uniforms.pixelRounding; \n let ndc = clipPos.xy / clipPos.w;\n\n \n let pixelCoords = (ndc + 1.0) * 0.5 * uniforms.screenSize;\n\n \n var snappedPixel = floor(pixelCoords / gridSize) * gridSize;\n\n \n if (uniforms.pixelExpansion > 0.0) {\n let screenCenter = uniforms.screenSize * 0.5;\n let fromCenter = snappedPixel - screenCenter;\n snappedPixel += sign(fromCenter) * uniforms.pixelExpansion;\n }\n\n \n let snappedNDC = (snappedPixel / uniforms.screenSize) * 2.0 - 1.0;\n\n \n clipPos.x = snappedNDC.x * clipPos.w;\n clipPos.y = snappedNDC.y * clipPos.w;\n }\n\n output.position = clipPos;\n output.worldPos = worldPos;\n \n output.uv = input.uv * uvTransform.zw + uvTransform.xy;\n output.viewZ = viewDist;\n output.currClipPos = clipPos;\n output.prevClipPos = prevClipPos;\n output.instanceColor = instanceColor;\n\n \n \n \n \n if (uniforms.billboardMode > 0.5) {\n let entityPos = instanceModelMatrix[3].xyz;\n output.anchorY = entityPos.y;\n } else {\n output.anchorY = worldPos.y; \n }\n\n \n \n if (uniforms.billboardMode > 0.5) {\n output.normal = localNormal; \n } else {\n let normalMatrix = mat3x3f(\n instanceModelMatrix[0].xyz,\n instanceModelMatrix[1].xyz,\n instanceModelMatrix[2].xyz\n );\n output.normal = normalMatrix * localNormal;\n }\n return output;\n}\n\nstruct GBufferOutput {\n @location(0) albedo: vec4f,\n @location(1) normal: vec4f,\n @location(2) arm: vec4f, \n @location(3) emission: vec4f,\n @location(4) velocity: vec2f, \n @builtin(frag_depth) depth: f32, \n}\n\nfn interleavedGradientNoise(screenPos: vec2f) -> f32 {\n let magic = vec3f(0.06711056, 0.00583715, 52.9829189);\n return fract(magic.z * fract(dot(screenPos, magic.xy)));\n}\n\nfn screenHash(screenPos: vec2f) -> f32 {\n let p = fract(screenPos * vec2f(0.1031, 0.1030));\n let p3 = p.xyx * (p.yxy + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> GBufferOutput {\n \n \n \n \n \n if (uniforms.clipPlaneEnabled > 0.5 && uniforms.billboardMode < 0.5) {\n let clipDist = (input.worldPos.y - uniforms.clipPlaneY) * uniforms.clipPlaneDirection;\n if (clipDist < 0.0) {\n discard;\n }\n }\n\n \n \n if (uniforms.distanceFadeEnd > 0.0) {\n let distToCamera = length(input.worldPos - uniforms.cameraPosition);\n if (distToCamera >= uniforms.distanceFadeEnd) {\n discard; \n }\n if (distToCamera > uniforms.distanceFadeStart) {\n \n let fadeRange = uniforms.distanceFadeEnd - uniforms.distanceFadeStart;\n let fadeFactor = 1.0 - (distToCamera - uniforms.distanceFadeStart) / fadeRange;\n \n let noise = sampleNoise(input.position.xy);\n if (fadeFactor < noise) {\n discard; \n }\n }\n }\n\n var output: GBufferOutput;\n\n \n let mipBias = uniforms.mipBias; \n output.albedo = textureSampleBias(albedoTexture, albedoSampler, input.uv, mipBias);\n\n \n output.albedo = output.albedo * input.instanceColor;\n\n \n \n if (uniforms.luminanceToAlpha > 0.5) {\n let luminance = dot(output.albedo.rgb, vec3f(0.299, 0.587, 0.114));\n if (luminance < 0.004) {\n discard; \n }\n output.albedo.a = 1.0; \n }\n\n \n \n if (uniforms.alphaHashEnabled > 0.5 && uniforms.luminanceToAlpha < 0.5) {\n let alpha = output.albedo.a * uniforms.alphaHashScale;\n let noise = sampleNoise(input.position.xy);\n\n \n if (alpha < 0.5) {\n discard;\n }\n \n \n let remappedAlpha = (alpha - 0.5) * 2.0;\n if (remappedAlpha < noise) {\n discard;\n }\n }\n\n \n\n \n let nsample = textureSampleBias(normalTexture, normalSampler, input.uv, mipBias).rgb;\n let tangentNormal = normalize(nsample * 2.0 - 1.0);\n\n \n \n let dPdx = dpdx(input.worldPos);\n let dPdy = dpdy(input.worldPos);\n let dUVdx = dpdx(input.uv);\n let dUVdy = dpdy(input.uv);\n\n let N = normalize(input.normal);\n\n \n let dp2perp = cross(dPdy, N);\n let dp1perp = cross(N, dPdx);\n\n \n \n var T = -(dp2perp * dUVdx.x + dp1perp * dUVdy.x);\n var B = dp2perp * dUVdx.y + dp1perp * dUVdy.y;\n\n \n let invmax = inverseSqrt(max(dot(T, T), dot(B, B)));\n T = T * invmax;\n B = B * invmax;\n\n \n if (length(T) < 0.001 || length(B) < 0.001) {\n let refVec = select(vec3f(0.0, 1.0, 0.0), vec3f(1.0, 0.0, 0.0), abs(N.y) > 0.9);\n T = normalize(cross(N, refVec));\n B = cross(N, T);\n }\n\n \n if (!frontFacing) {\n T = -T;\n B = -B;\n }\n\n let TBN = mat3x3f(T, B, N);\n\n \n let normal = normalize(TBN * tangentNormal);\n \n output.normal = vec4f(normal, input.worldPos.y);\n\n \n var ambient = textureSampleBias(ambientTexture, ambientSampler, input.uv, mipBias).rgb;\n if (ambient.r < 0.04) {\n ambient.r = 1.0;\n }\n let rm = textureSampleBias(rmTexture, rmSampler, input.uv, mipBias).rgb;\n output.arm = vec4f(ambient.r, rm.g, rm.b, uniforms.specularBoost);\n\n \n output.emission = textureSampleBias(emissionTexture, emissionSampler, input.uv, mipBias) * uniforms.emissionFactor;\n\n \n \n let currNDC = input.currClipPos.xy / input.currClipPos.w;\n let prevNDC = input.prevClipPos.xy / input.prevClipPos.w;\n\n \n \n \n let velocityNDC = currNDC - prevNDC;\n output.velocity = velocityNDC * uniforms.screenSize * 0.5;\n\n \n let near = uniforms.near;\n let far = uniforms.far;\n let z = input.viewZ;\n output.depth = (z - near) / (far - near);\n\n return output;\n}";
6168
6383
  class GBuffer {
6169
6384
  constructor() {
6170
6385
  this.isGBuffer = true;
@@ -6385,7 +6600,8 @@ class GBufferPass extends BasePass {
6385
6600
  const isSkinned = mesh.hasSkin && mesh.skin;
6386
6601
  const meshId = mesh.uid || mesh.geometry?.uid || "default";
6387
6602
  const forceEmissive = mesh.material?.forceEmissive ? "_emissive" : "";
6388
- return `${mesh.material.uid}_${meshId}${isSkinned ? "_skinned" : ""}${forceEmissive}`;
6603
+ const doubleSided = mesh.material?.doubleSided ? "_dbl" : "";
6604
+ return `${mesh.material.uid}_${meshId}${isSkinned ? "_skinned" : ""}${forceEmissive}${doubleSided}`;
6389
6605
  }
6390
6606
  /**
6391
6607
  * Check if pipeline is ready for a mesh (non-blocking)
@@ -6425,7 +6641,8 @@ class GBufferPass extends BasePass {
6425
6641
  textures: mesh.material.textures,
6426
6642
  renderTarget: this.gbuffer,
6427
6643
  skin: isSkinned ? mesh.skin : null,
6428
- noiseTexture: this.noiseTexture
6644
+ noiseTexture: this.noiseTexture,
6645
+ doubleSided: mesh.material?.doubleSided ?? false
6429
6646
  }).then((pipeline) => {
6430
6647
  this.pendingPipelines.delete(key);
6431
6648
  pipeline._warmupFrames = 2;
@@ -6460,7 +6677,8 @@ class GBufferPass extends BasePass {
6460
6677
  textures: mesh.material.textures,
6461
6678
  renderTarget: this.gbuffer,
6462
6679
  skin: isSkinned ? mesh.skin : null,
6463
- noiseTexture: this.noiseTexture
6680
+ noiseTexture: this.noiseTexture,
6681
+ doubleSided: mesh.material?.doubleSided ?? false
6464
6682
  });
6465
6683
  pipeline._warmupFrames = 2;
6466
6684
  pipelinesMap.set(key, pipeline);
@@ -6483,6 +6701,8 @@ class GBufferPass extends BasePass {
6483
6701
  const prevViewProjMatrix = prevData?.hasValidHistory ? prevData.viewProj : camera.viewProj;
6484
6702
  const emissionFactor = this.settings?.environment?.emissionFactor ?? [1, 1, 1, 4];
6485
6703
  const mipBias = this.settings?.rendering?.mipBias ?? options.mipBias ?? 0;
6704
+ const animationSpeed = this.settings?.animation?.speed ?? 1;
6705
+ const globalAnimTime = performance.now() / 1e3 * animationSpeed;
6486
6706
  stats.drawCalls = 0;
6487
6707
  stats.triangles = 0;
6488
6708
  camera.aspect = canvas.width / canvas.height;
@@ -6491,12 +6711,18 @@ class GBufferPass extends BasePass {
6491
6711
  this._extractCameraVectors(camera.view);
6492
6712
  let commandEncoder = null;
6493
6713
  let passEncoder = null;
6714
+ const updatedSkins = /* @__PURE__ */ new Set();
6494
6715
  if (batches && batches.size > 0) {
6495
6716
  for (const [modelId, batch] of batches) {
6496
6717
  const mesh = batch.mesh;
6497
6718
  if (!mesh) continue;
6498
- if (batch.hasSkin && batch.skin && !batch.skin.externallyManaged) {
6499
- batch.skin.update(dt);
6719
+ if (batch.hasSkin && batch.skin && !batch.skin.externallyManaged && !updatedSkins.has(batch.skin)) {
6720
+ if (batch.skin._animStartTime === void 0) {
6721
+ batch.skin._animStartTime = globalAnimTime;
6722
+ }
6723
+ const skinAnimTime = globalAnimTime - batch.skin._animStartTime;
6724
+ batch.skin.updateAtTime(skinAnimTime);
6725
+ updatedSkins.add(batch.skin);
6500
6726
  }
6501
6727
  const pipeline = await this._getOrCreatePipeline(mesh);
6502
6728
  if (pipeline._warmupFrames > 0) {
@@ -6615,8 +6841,13 @@ class GBufferPass extends BasePass {
6615
6841
  pipeline._warmupFrames--;
6616
6842
  }
6617
6843
  this.legacyCullingStats.rendered++;
6618
- if (mesh.skin && mesh.hasSkin && !mesh.skin.externallyManaged) {
6619
- mesh.skin.update(dt);
6844
+ if (mesh.skin && mesh.hasSkin && !mesh.skin.externallyManaged && !updatedSkins.has(mesh.skin)) {
6845
+ if (mesh.skin._animStartTime === void 0) {
6846
+ mesh.skin._animStartTime = globalAnimTime;
6847
+ }
6848
+ const skinAnimTime = globalAnimTime - mesh.skin._animStartTime;
6849
+ mesh.skin.updateAtTime(skinAnimTime);
6850
+ updatedSkins.add(mesh.skin);
6620
6851
  }
6621
6852
  if (pipeline.geometry !== mesh.geometry) {
6622
6853
  pipeline.geometry = mesh.geometry;
@@ -7239,9 +7470,21 @@ class LightingPass extends BasePass {
7239
7470
  getOutputTexture() {
7240
7471
  return this.outputTexture;
7241
7472
  }
7473
+ /**
7474
+ * Get the light buffer for volumetric fog
7475
+ */
7476
+ getLightBuffer() {
7477
+ return this.lightBuffer;
7478
+ }
7479
+ /**
7480
+ * Get the current light count
7481
+ */
7482
+ getLightCount() {
7483
+ return this.lights?.length ?? 0;
7484
+ }
7242
7485
  }
7243
7486
  var particle_simulate_default = "const WORKGROUP_SIZE: u32 = 64u;\nconst MAX_EMITTERS: u32 = 16u;\nconst CASCADE_COUNT = 3;\nconst MAX_LIGHTS = 64u;\nconst MAX_SPOT_SHADOWS = 8;\nconst LIGHTING_FADE_TIME: f32 = 0.3; \n\nconst SPOT_ATLAS_WIDTH: f32 = 2048.0;\nconst SPOT_ATLAS_HEIGHT: f32 = 2048.0;\nconst SPOT_TILE_SIZE: f32 = 512.0;\nconst SPOT_TILES_PER_ROW: i32 = 4;\n\nstruct Particle {\n position: vec3f, \n lifetime: f32, \n velocity: vec3f, \n maxLifetime: f32, \n color: vec4f, \n size: vec2f, \n rotation: f32, \n flags: u32, \n lighting: vec3f, \n lightingPad: f32, \n}\n\nstruct Light {\n enabled: u32,\n position: vec3f,\n color: vec4f,\n direction: vec3f,\n geom: vec4f, \n shadowIndex: i32,\n}\n\nstruct CascadeMatrices {\n matrices: array<mat4x4<f32>, CASCADE_COUNT>,\n}\n\nstruct SpotShadowMatrices {\n matrices: array<mat4x4<f32>, MAX_SPOT_SHADOWS>,\n}\n\nstruct SpawnRequest {\n position: vec3f,\n lifetime: f32,\n velocity: vec3f,\n maxLifetime: f32,\n color: vec4f,\n startSize: f32,\n endSize: f32,\n rotation: f32, \n flags: u32, \n}\n\nstruct EmitterSettings {\n gravity: vec3f,\n drag: f32,\n turbulence: f32,\n fadeIn: f32,\n fadeOut: f32,\n rotationSpeed: f32,\n startSize: f32,\n endSize: f32,\n baseAlpha: f32,\n padding: f32,\n}\n\nstruct SimulationUniforms {\n dt: f32,\n time: f32,\n maxParticles: u32,\n emitterCount: u32,\n \n cameraPosition: vec3f,\n shadowBias: f32,\n lightDir: vec3f,\n shadowStrength: f32,\n lightColor: vec4f,\n ambientColor: vec4f,\n cascadeSizes: vec4f,\n lightCount: u32,\n pad1: u32,\n pad2: u32,\n pad3: u32,\n}\n\nstruct Counters {\n aliveCount: atomic<u32>,\n nextFreeIndex: atomic<u32>,\n spawnCount: u32,\n frameCount: u32,\n}\n\n@group(0) @binding(0) var<uniform> uniforms: SimulationUniforms;\n@group(0) @binding(1) var<storage, read_write> particles: array<Particle>;\n@group(0) @binding(2) var<storage, read_write> counters: Counters;\n@group(0) @binding(3) var<storage, read> spawnRequests: array<SpawnRequest>;\n@group(0) @binding(4) var<storage, read> emitterSettings: array<EmitterSettings>;\n\n@group(0) @binding(5) var<storage, read> emitterRenderSettings: array<EmitterRenderSettings>;\n@group(0) @binding(6) var shadowMapArray: texture_depth_2d_array;\n@group(0) @binding(7) var shadowSampler: sampler_comparison;\n@group(0) @binding(8) var<storage, read> cascadeMatrices: CascadeMatrices;\n@group(0) @binding(9) var<storage, read> lights: array<Light, MAX_LIGHTS>;\n@group(0) @binding(10) var spotShadowAtlas: texture_depth_2d;\n@group(0) @binding(11) var<storage, read> spotMatrices: SpotShadowMatrices;\n\nstruct EmitterRenderSettings {\n lit: f32,\n emissive: f32,\n softness: f32,\n zOffset: f32,\n}\n\nfn hash(p: vec3f) -> f32 {\n var p3 = fract(p * 0.1031);\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn noise3D(p: vec3f) -> vec3f {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n\n return vec3f(\n mix(hash(i), hash(i + vec3f(1.0, 0.0, 0.0)), u.x),\n mix(hash(i + vec3f(0.0, 1.0, 0.0)), hash(i + vec3f(1.0, 1.0, 0.0)), u.x),\n mix(hash(i + vec3f(0.0, 0.0, 1.0)), hash(i + vec3f(1.0, 0.0, 1.0)), u.x)\n ) * 2.0 - 1.0;\n}\n\nfn squircleDistanceXZ(offset: vec2f, size: f32) -> f32 {\n let normalized = offset / size;\n let absNorm = abs(normalized);\n return pow(pow(absNorm.x, 4.0) + pow(absNorm.y, 4.0), 0.25);\n}\n\nfn sampleCascadeShadow(worldPos: vec3f, cascadeIndex: i32) -> f32 {\n let bias = uniforms.shadowBias;\n let lightMatrix = cascadeMatrices.matrices[cascadeIndex];\n let lightSpacePos = lightMatrix * vec4f(worldPos, 1.0);\n let projCoords = lightSpacePos.xyz / lightSpacePos.w;\n let shadowUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n let currentDepth = projCoords.z - bias;\n\n \n if (shadowUV.x < 0.0 || shadowUV.x > 1.0 || shadowUV.y < 0.0 || shadowUV.y > 1.0 ||\n currentDepth < 0.0 || currentDepth > 1.0) {\n return 1.0;\n }\n\n \n return textureSampleCompareLevel(shadowMapArray, shadowSampler, shadowUV, cascadeIndex, currentDepth);\n}\n\nfn calculateParticleShadow(worldPos: vec3f) -> f32 {\n let camXZ = vec2f(uniforms.cameraPosition.x, uniforms.cameraPosition.z);\n let posXZ = vec2f(worldPos.x, worldPos.z);\n let offsetXZ = posXZ - camXZ;\n\n let dist0 = squircleDistanceXZ(offsetXZ, uniforms.cascadeSizes.x);\n let dist1 = squircleDistanceXZ(offsetXZ, uniforms.cascadeSizes.y);\n let dist2 = squircleDistanceXZ(offsetXZ, uniforms.cascadeSizes.z);\n\n var shadow = 1.0;\n if (dist0 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, 0);\n } else if (dist1 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, 1);\n } else if (dist2 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, 2);\n }\n\n return mix(1.0 - uniforms.shadowStrength, 1.0, shadow);\n}\n\nfn calculateSpotShadow(worldPos: vec3f, slotIndex: i32) -> f32 {\n if (slotIndex < 0 || slotIndex >= MAX_SPOT_SHADOWS) {\n return 1.0;\n }\n\n let lightMatrix = spotMatrices.matrices[slotIndex];\n let lightSpacePos = lightMatrix * vec4f(worldPos, 1.0);\n let w = max(abs(lightSpacePos.w), 0.0001) * sign(lightSpacePos.w + 0.0001);\n let projCoords = lightSpacePos.xyz / w;\n\n if (projCoords.z < 0.0 || projCoords.z > 1.0 || abs(projCoords.x) > 1.0 || abs(projCoords.y) > 1.0) {\n return 1.0;\n }\n\n let col = slotIndex % SPOT_TILES_PER_ROW;\n let row = slotIndex / SPOT_TILES_PER_ROW;\n let localUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n let tileOffset = vec2f(f32(col), f32(row)) * SPOT_TILE_SIZE;\n let atlasUV = (tileOffset + localUV * SPOT_TILE_SIZE) / vec2f(SPOT_ATLAS_WIDTH, SPOT_ATLAS_HEIGHT);\n let currentDepth = clamp(projCoords.z - uniforms.shadowBias * 3.0, 0.001, 0.999);\n\n return textureSampleCompareLevel(spotShadowAtlas, shadowSampler, atlasUV, currentDepth);\n}\n\nfn calculateParticleLighting(worldPos: vec3f, emitterIdx: u32) -> vec3f {\n let renderSettings = emitterRenderSettings[min(emitterIdx, MAX_EMITTERS - 1u)];\n\n \n if (renderSettings.lit < 0.5) {\n return vec3f(renderSettings.emissive);\n }\n\n \n let normal = vec3f(0.0, 1.0, 0.0);\n\n \n var lighting = uniforms.ambientColor.rgb * uniforms.ambientColor.a;\n\n \n let shadow = calculateParticleShadow(worldPos);\n let NdotL = max(dot(normal, uniforms.lightDir), 0.0);\n lighting += uniforms.lightColor.rgb * uniforms.lightColor.a * NdotL * shadow;\n\n \n let lightCount = uniforms.lightCount;\n for (var i = 0u; i < min(lightCount, MAX_LIGHTS); i++) {\n let light = lights[i];\n if (light.enabled == 0u) { continue; }\n\n let lightVec = light.position - worldPos;\n let dist = length(lightVec);\n let lightDir = normalize(lightVec);\n let distanceFade = light.geom.w;\n\n \n let radius = max(light.geom.x, 0.001);\n let attenuation = max(0.0, 1.0 - dist / radius);\n let attenuationSq = attenuation * attenuation * distanceFade;\n if (attenuationSq <= 0.001) { continue; }\n\n \n let innerCone = light.geom.y;\n let outerCone = light.geom.z;\n var spotAttenuation = 1.0;\n let isSpotlight = outerCone > 0.0;\n if (isSpotlight) {\n let spotCos = dot(-lightDir, normalize(light.direction));\n spotAttenuation = smoothstep(outerCone, innerCone, spotCos);\n }\n if (spotAttenuation <= 0.001) { continue; }\n\n \n var lightShadow = 1.0;\n if (isSpotlight && light.shadowIndex >= 0) {\n lightShadow = calculateSpotShadow(worldPos, light.shadowIndex);\n }\n\n \n let pNdotL = max(dot(normal, lightDir), 0.0);\n let pIntensity = light.color.a * attenuationSq * spotAttenuation * lightShadow * pNdotL;\n lighting += pIntensity * light.color.rgb;\n }\n\n return lighting * renderSettings.emissive;\n}\n\n@compute @workgroup_size(WORKGROUP_SIZE)\nfn spawn(@builtin(global_invocation_id) globalId: vec3u) {\n let idx = globalId.x;\n\n \n if (idx >= counters.spawnCount) {\n return;\n }\n\n \n let maxAttempts = 8u; \n var particleIdx = 0u;\n var foundSlot = false;\n\n for (var attempt = 0u; attempt < maxAttempts; attempt++) {\n let rawIdx = atomicAdd(&counters.nextFreeIndex, 1u);\n particleIdx = rawIdx % uniforms.maxParticles;\n\n \n let existing = particles[particleIdx];\n if ((existing.flags & 1u) == 0u || existing.lifetime <= 0.0) {\n foundSlot = true;\n break;\n }\n }\n\n \n if (!foundSlot) {\n return;\n }\n\n \n let req = spawnRequests[idx];\n\n \n var p: Particle;\n p.position = req.position;\n p.lifetime = req.lifetime;\n p.velocity = req.velocity;\n p.maxLifetime = req.maxLifetime;\n p.color = req.color;\n p.size = vec2f(req.startSize, req.startSize);\n p.rotation = req.rotation; \n p.flags = req.flags;\n \n p.lighting = uniforms.ambientColor.rgb * uniforms.ambientColor.a;\n p.lightingPad = 0.0;\n\n \n particles[particleIdx] = p;\n\n \n atomicAdd(&counters.aliveCount, 1u);\n}\n\n@compute @workgroup_size(WORKGROUP_SIZE)\nfn simulate(@builtin(global_invocation_id) globalId: vec3u) {\n let idx = globalId.x;\n\n \n if (idx >= uniforms.maxParticles) {\n return;\n }\n\n var p = particles[idx];\n\n \n if ((p.flags & 1u) == 0u || p.lifetime <= 0.0) {\n return;\n }\n\n \n let emitterIdx = (p.flags >> 8u) & 0xFFu;\n let settings = emitterSettings[min(emitterIdx, MAX_EMITTERS - 1u)];\n\n let dt = uniforms.dt;\n\n \n p.lifetime -= dt;\n\n \n if (p.lifetime <= 0.0) {\n p.flags = 0u; \n p.lifetime = 0.0;\n atomicSub(&counters.aliveCount, 1u);\n particles[idx] = p;\n return;\n }\n\n \n let lifeProgress = 1.0 - (p.lifetime / p.maxLifetime);\n\n \n p.velocity += settings.gravity * dt;\n\n \n let dragFactor = 1.0 - settings.drag * dt;\n p.velocity *= max(dragFactor, 0.0);\n\n \n if (settings.turbulence > 0.0) {\n let noisePos = p.position * 0.5 + vec3f(uniforms.time * 0.1, 0.0, 0.0);\n let turbulenceForce = noise3D(noisePos + vec3f(p.rotation * 10.0)) * settings.turbulence;\n p.velocity += turbulenceForce * dt * 10.0;\n }\n\n \n p.position += p.velocity * dt;\n\n \n let currentSize = mix(settings.startSize, settings.endSize, lifeProgress);\n p.size = vec2f(currentSize, currentSize);\n\n \n let rotationDir = select(-1.0, 1.0, p.rotation >= 0.0);\n p.rotation += rotationDir * settings.rotationSpeed * dt;\n\n \n var alpha = settings.baseAlpha;\n\n \n let fadeInDuration = settings.fadeIn / p.maxLifetime;\n if (lifeProgress < fadeInDuration && fadeInDuration > 0.0) {\n alpha *= lifeProgress / fadeInDuration;\n }\n\n \n let fadeOutStart = 1.0 - (settings.fadeOut / p.maxLifetime);\n if (lifeProgress > fadeOutStart && fadeOutStart < 1.0) {\n let fadeOutProgress = (lifeProgress - fadeOutStart) / (1.0 - fadeOutStart);\n alpha *= 1.0 - fadeOutProgress;\n }\n\n \n p.color.a = alpha;\n\n \n let targetLighting = calculateParticleLighting(p.position, emitterIdx);\n\n \n \n let lerpFactor = clamp(dt / LIGHTING_FADE_TIME, 0.0, 1.0);\n p.lighting = mix(p.lighting, targetLighting, lerpFactor);\n\n \n particles[idx] = p;\n}\n\n@compute @workgroup_size(1)\nfn resetCounters() {\n \n atomicStore(&counters.nextFreeIndex, 0u);\n}";
7244
- var particle_render_default = "const PI = 3.14159265359;\nconst CASCADE_COUNT = 3;\nconst MAX_EMITTERS = 16u;\nconst MAX_LIGHTS = 64u;\nconst MAX_SPOT_SHADOWS = 8;\n\nconst SPOT_ATLAS_WIDTH: f32 = 2048.0;\nconst SPOT_ATLAS_HEIGHT: f32 = 2048.0;\nconst SPOT_TILE_SIZE: f32 = 512.0;\nconst SPOT_TILES_PER_ROW: i32 = 4;\n\nstruct Particle {\n position: vec3f,\n lifetime: f32,\n velocity: vec3f,\n maxLifetime: f32,\n color: vec4f,\n size: vec2f,\n rotation: f32, \n flags: u32,\n lighting: vec3f, \n lightingPad: f32,\n}\n\nstruct Light {\n enabled: u32,\n position: vec3f,\n color: vec4f,\n direction: vec3f,\n geom: vec4f, \n shadowIndex: i32, \n}\n\nstruct EmitterRenderSettings {\n lit: f32, \n emissive: f32, \n softness: f32,\n zOffset: f32,\n}\n\nstruct SpotShadowMatrices {\n matrices: array<mat4x4<f32>, MAX_SPOT_SHADOWS>,\n}\n\nstruct ParticleUniforms {\n viewMatrix: mat4x4f,\n projectionMatrix: mat4x4f,\n cameraPosition: vec3f,\n time: f32,\n cameraRight: vec3f,\n softness: f32,\n cameraUp: vec3f,\n zOffset: f32,\n screenSize: vec2f,\n near: f32,\n far: f32,\n blendMode: f32, \n lit: f32, \n shadowBias: f32,\n shadowStrength: f32,\n \n lightDir: vec3f,\n shadowMapSize: f32,\n lightColor: vec4f,\n ambientColor: vec4f,\n cascadeSizes: vec4f, \n \n envParams: vec4f, \n \n lightParams: vec4u, \n \n fogColor: vec3f,\n fogEnabled: f32,\n fogDistances: vec3f, \n fogBrightResist: f32,\n fogAlphas: vec3f, \n fogPad1: f32,\n fogHeightFade: vec2f, \n fogPad2: vec2f,\n}\n\nstruct CascadeMatrices {\n matrices: array<mat4x4<f32>, CASCADE_COUNT>,\n}\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n @location(1) color: vec4f,\n @location(2) viewZ: f32,\n @location(3) linearDepth: f32, \n @location(4) lighting: vec3f, \n @location(5) @interpolate(flat) emitterIdx: u32, \n @location(6) worldPos: vec3f, \n}\n\n@group(0) @binding(0) var<uniform> uniforms: ParticleUniforms;\n@group(0) @binding(1) var<storage, read> particles: array<Particle>;\n@group(0) @binding(2) var particleTexture: texture_2d<f32>;\n@group(0) @binding(3) var particleSampler: sampler;\n@group(0) @binding(4) var depthTexture: texture_depth_2d;\n@group(0) @binding(5) var shadowMapArray: texture_depth_2d_array;\n@group(0) @binding(6) var shadowSampler: sampler_comparison;\n@group(0) @binding(7) var<storage, read> cascadeMatrices: CascadeMatrices;\n@group(0) @binding(8) var<storage, read> emitterSettings: array<EmitterRenderSettings, MAX_EMITTERS>;\n@group(0) @binding(9) var envMap: texture_2d<f32>;\n@group(0) @binding(10) var envSampler: sampler;\n\n@group(0) @binding(11) var<storage, read> lights: array<Light, MAX_LIGHTS>;\n\n@group(0) @binding(12) var spotShadowAtlas: texture_depth_2d;\n@group(0) @binding(13) var spotShadowSampler: sampler_comparison;\n@group(0) @binding(14) var<storage, read> spotMatrices: SpotShadowMatrices;\n\nfn getQuadVertex(vertexId: u32) -> vec2f {\n \n \n \n var corners = array<vec2f, 6>(\n vec2f(-0.5, -0.5), \n vec2f(0.5, -0.5), \n vec2f(-0.5, 0.5), \n vec2f(0.5, -0.5), \n vec2f(0.5, 0.5), \n vec2f(-0.5, 0.5) \n );\n return corners[vertexId];\n}\n\nfn getQuadUV(vertexId: u32) -> vec2f {\n var uvs = array<vec2f, 6>(\n vec2f(0.0, 1.0), \n vec2f(1.0, 1.0), \n vec2f(0.0, 0.0), \n vec2f(1.0, 1.0), \n vec2f(1.0, 0.0), \n vec2f(0.0, 0.0) \n );\n return uvs[vertexId];\n}\n\n@vertex\nfn vertexMain(\n @builtin(vertex_index) vertexIndex: u32,\n @builtin(instance_index) instanceIndex: u32\n) -> VertexOutput {\n var output: VertexOutput;\n\n \n let particle = particles[instanceIndex];\n\n \n let emitterIdx = (particle.flags >> 8u) & 0xFFu;\n output.emitterIdx = emitterIdx;\n\n \n if ((particle.flags & 1u) == 0u || particle.lifetime <= 0.0) {\n \n output.position = vec4f(0.0, 0.0, 0.0, 0.0);\n output.uv = vec2f(0.0, 0.0);\n output.color = vec4f(0.0, 0.0, 0.0, 0.0);\n output.viewZ = 0.0;\n output.linearDepth = 0.0;\n output.lighting = vec3f(0.0);\n output.worldPos = vec3f(0.0);\n return output;\n }\n\n \n \n let particleIsAdditive = (particle.flags & 2u) != 0u;\n let renderingAdditive = uniforms.blendMode > 0.5;\n if (particleIsAdditive != renderingAdditive) {\n \n output.position = vec4f(0.0, 0.0, 0.0, 0.0);\n output.uv = vec2f(0.0, 0.0);\n output.color = vec4f(0.0, 0.0, 0.0, 0.0);\n output.viewZ = 0.0;\n output.linearDepth = 0.0;\n output.lighting = vec3f(0.0);\n output.worldPos = vec3f(0.0);\n return output;\n }\n\n \n let localVertexId = vertexIndex % 6u;\n var quadPos = getQuadVertex(localVertexId);\n output.uv = getQuadUV(localVertexId);\n\n \n let cosR = cos(particle.rotation);\n let sinR = sin(particle.rotation);\n let rotatedPos = vec2f(\n quadPos.x * cosR - quadPos.y * sinR,\n quadPos.x * sinR + quadPos.y * cosR\n );\n\n \n let particleWorldPos = particle.position;\n\n \n let scaledOffset = rotatedPos * particle.size;\n\n \n let right = uniforms.cameraRight;\n let up = uniforms.cameraUp;\n let billboardPos = particleWorldPos + right * scaledOffset.x + up * scaledOffset.y;\n\n \n let toCamera = normalize(uniforms.cameraPosition - particleWorldPos);\n let offsetPos = billboardPos + toCamera * uniforms.zOffset;\n\n \n let viewPos = uniforms.viewMatrix * vec4f(offsetPos, 1.0);\n output.position = uniforms.projectionMatrix * viewPos;\n output.viewZ = -viewPos.z; \n\n \n let z = -viewPos.z; \n output.linearDepth = (z - uniforms.near) / (uniforms.far - uniforms.near);\n\n \n output.lighting = particle.lighting;\n\n \n output.color = particle.color;\n\n \n output.worldPos = particleWorldPos;\n\n return output;\n}\n\nfn SphToUV(n: vec3f) -> vec2f {\n var uv: vec2f;\n uv.x = atan2(-n.x, n.z);\n uv.x = (uv.x + PI / 2.0) / (PI * 2.0) + PI * (28.670 / 360.0);\n uv.y = acos(n.y) / PI;\n return uv;\n}\n\nfn octEncode(n: vec3f) -> vec2f {\n var n2 = n / (abs(n.x) + abs(n.y) + abs(n.z));\n if (n2.y < 0.0) {\n let signX = select(-1.0, 1.0, n2.x >= 0.0);\n let signZ = select(-1.0, 1.0, n2.z >= 0.0);\n n2 = vec3f(\n (1.0 - abs(n2.z)) * signX,\n n2.y,\n (1.0 - abs(n2.x)) * signZ\n );\n }\n return n2.xz * 0.5 + 0.5;\n}\n\nfn getEnvUV(dir: vec3f) -> vec2f {\n if (uniforms.envParams.z > 0.5) {\n return octEncode(dir);\n }\n return SphToUV(dir);\n}\n\nfn getIBLSample(dir: vec3f, lod: f32) -> vec3f {\n let envRGBE = textureSampleLevel(envMap, envSampler, getEnvUV(dir), lod);\n \n let envColor = envRGBE.rgb * pow(2.0, envRGBE.a * 255.0 - 128.0);\n return envColor;\n}\n\nfn squircleDistanceXZ(offset: vec2f, size: f32) -> f32 {\n let normalized = offset / size;\n let absNorm = abs(normalized);\n return pow(pow(absNorm.x, 4.0) + pow(absNorm.y, 4.0), 0.25);\n}\n\nfn sampleCascadeShadow(worldPos: vec3f, normal: vec3f, cascadeIndex: i32) -> f32 {\n let bias = uniforms.shadowBias;\n let shadowMapSize = uniforms.shadowMapSize;\n\n \n let biasedPos = worldPos + normal * bias * 0.5;\n\n \n let lightMatrix = cascadeMatrices.matrices[cascadeIndex];\n\n \n let lightSpacePos = lightMatrix * vec4f(biasedPos, 1.0);\n let projCoords = lightSpacePos.xyz / lightSpacePos.w;\n\n \n let shadowUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n let currentDepth = projCoords.z - bias;\n\n \n let inBoundsX = shadowUV.x >= 0.0 && shadowUV.x <= 1.0;\n let inBoundsY = shadowUV.y >= 0.0 && shadowUV.y <= 1.0;\n let inBoundsZ = currentDepth >= 0.0 && currentDepth <= 1.0;\n\n if (!inBoundsX || !inBoundsY || !inBoundsZ) {\n return 1.0; \n }\n\n let clampedUV = clamp(shadowUV, vec2f(0.001), vec2f(0.999));\n let clampedDepth = clamp(currentDepth, 0.001, 0.999);\n\n \n let texelSize = 1.0 / shadowMapSize;\n var shadow = 0.0;\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(-texelSize, 0.0), cascadeIndex, clampedDepth);\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(texelSize, 0.0), cascadeIndex, clampedDepth);\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(0.0, -texelSize), cascadeIndex, clampedDepth);\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(0.0, texelSize), cascadeIndex, clampedDepth);\n shadow /= 4.0;\n\n return shadow;\n}\n\nfn calculateParticleShadow(worldPos: vec3f, normal: vec3f) -> f32 {\n let shadowStrength = uniforms.shadowStrength;\n\n \n let camXZ = vec2f(uniforms.cameraPosition.x, uniforms.cameraPosition.z);\n let posXZ = vec2f(worldPos.x, worldPos.z);\n let offsetXZ = posXZ - camXZ;\n\n \n let cascade0Size = uniforms.cascadeSizes.x;\n let cascade1Size = uniforms.cascadeSizes.y;\n let cascade2Size = uniforms.cascadeSizes.z;\n\n let dist0 = squircleDistanceXZ(offsetXZ, cascade0Size);\n let dist1 = squircleDistanceXZ(offsetXZ, cascade1Size);\n let dist2 = squircleDistanceXZ(offsetXZ, cascade2Size);\n\n var shadow = 1.0;\n\n \n if (dist0 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, normal, 0);\n } else if (dist1 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, normal, 1);\n } else if (dist2 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, normal, 2);\n }\n\n \n return mix(1.0 - shadowStrength, 1.0, shadow);\n}\n\nfn calculateSpotShadow(worldPos: vec3f, normal: vec3f, slotIndex: i32) -> f32 {\n if (slotIndex < 0 || slotIndex >= MAX_SPOT_SHADOWS) {\n return 1.0; \n }\n\n let bias = uniforms.shadowBias;\n let normalBias = bias * 2.0; \n\n \n let biasedPos = worldPos + normal * normalBias;\n\n \n let lightMatrix = spotMatrices.matrices[slotIndex];\n\n \n let lightSpacePos = lightMatrix * vec4f(biasedPos, 1.0);\n\n \n let w = max(abs(lightSpacePos.w), 0.0001) * sign(lightSpacePos.w + 0.0001);\n let projCoords = lightSpacePos.xyz / w;\n\n \n if (projCoords.z < 0.0 || projCoords.z > 1.0 ||\n abs(projCoords.x) > 1.0 || abs(projCoords.y) > 1.0) {\n return 1.0; \n }\n\n \n let col = slotIndex % SPOT_TILES_PER_ROW;\n let row = slotIndex / SPOT_TILES_PER_ROW;\n\n \n let localUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n\n \n let tileOffset = vec2f(f32(col), f32(row)) * SPOT_TILE_SIZE;\n let atlasUV = (tileOffset + localUV * SPOT_TILE_SIZE) / vec2f(SPOT_ATLAS_WIDTH, SPOT_ATLAS_HEIGHT);\n\n \n let texelSize = 1.0 / SPOT_TILE_SIZE;\n let currentDepth = clamp(projCoords.z - bias * 3.0, 0.001, 0.999);\n\n var shadowSample = 0.0;\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(-texelSize, 0.0), currentDepth);\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(texelSize, 0.0), currentDepth);\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(0.0, -texelSize), currentDepth);\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(0.0, texelSize), currentDepth);\n shadowSample /= 4.0;\n\n return shadowSample;\n}\n\nfn applyLighting(baseColor: vec3f, lighting: vec3f) -> vec3f {\n \n \n return baseColor * lighting;\n}\n\nstruct FragmentOutput {\n @location(0) color: vec4f,\n @builtin(frag_depth) depth: f32,\n}\n\nfn calcSoftFade(fragPos: vec4f, particleLinearDepth: f32) -> f32 {\n if (uniforms.softness <= 0.0) {\n return 1.0;\n }\n\n \n let screenPos = vec2i(fragPos.xy);\n let screenSize = vec2i(uniforms.screenSize);\n\n \n if (screenPos.x < 0 || screenPos.x >= screenSize.x ||\n screenPos.y < 0 || screenPos.y >= screenSize.y) {\n return 1.0;\n }\n\n \n let sceneDepthNorm = textureLoad(depthTexture, screenPos, 0);\n\n \n if (sceneDepthNorm <= 0.0) {\n return 1.0;\n }\n\n \n let sceneDepth = uniforms.near + sceneDepthNorm * (uniforms.far - uniforms.near);\n let particleDepth = uniforms.near + particleLinearDepth * (uniforms.far - uniforms.near);\n\n \n let depthDiff = sceneDepth - particleDepth;\n return saturate(depthDiff / uniforms.softness);\n}\n\n@fragment\nfn fragmentMainAlpha(input: VertexOutput) -> FragmentOutput {\n var output: FragmentOutput;\n\n \n let texColor = textureSample(particleTexture, particleSampler, input.uv);\n\n \n var alpha = texColor.a * input.color.a;\n\n \n alpha *= calcSoftFade(input.position, input.linearDepth);\n\n \n if (alpha < 0.001) {\n discard;\n }\n\n \n let baseColor = texColor.rgb * input.color.rgb;\n let litColor = applyLighting(baseColor, input.lighting);\n\n \n output.color = vec4f(litColor, alpha);\n output.depth = input.linearDepth;\n return output;\n}\n\n@fragment\nfn fragmentMainAdditive(input: VertexOutput) -> FragmentOutput {\n var output: FragmentOutput;\n\n \n let texColor = textureSample(particleTexture, particleSampler, input.uv);\n\n \n var alpha = texColor.a * input.color.a;\n\n \n alpha *= calcSoftFade(input.position, input.linearDepth);\n\n \n if (alpha < 0.001) {\n discard;\n }\n\n \n let baseColor = texColor.rgb * input.color.rgb;\n let litColor = applyLighting(baseColor, input.lighting);\n\n \n \n let rgb = litColor * alpha;\n output.color = vec4f(rgb, alpha);\n output.depth = input.linearDepth;\n return output;\n}";
7487
+ var particle_render_default = "const PI = 3.14159265359;\nconst CASCADE_COUNT = 3;\nconst MAX_EMITTERS = 16u;\nconst MAX_LIGHTS = 64u;\nconst MAX_SPOT_SHADOWS = 8;\n\nconst SPOT_ATLAS_WIDTH: f32 = 2048.0;\nconst SPOT_ATLAS_HEIGHT: f32 = 2048.0;\nconst SPOT_TILE_SIZE: f32 = 512.0;\nconst SPOT_TILES_PER_ROW: i32 = 4;\n\nstruct Particle {\n position: vec3f,\n lifetime: f32,\n velocity: vec3f,\n maxLifetime: f32,\n color: vec4f,\n size: vec2f,\n rotation: f32, \n flags: u32,\n lighting: vec3f, \n lightingPad: f32,\n}\n\nstruct Light {\n enabled: u32,\n position: vec3f,\n color: vec4f,\n direction: vec3f,\n geom: vec4f, \n shadowIndex: i32, \n}\n\nstruct EmitterRenderSettings {\n lit: f32, \n emissive: f32, \n softness: f32,\n zOffset: f32,\n}\n\nstruct SpotShadowMatrices {\n matrices: array<mat4x4<f32>, MAX_SPOT_SHADOWS>,\n}\n\nstruct ParticleUniforms {\n viewMatrix: mat4x4f,\n projectionMatrix: mat4x4f,\n cameraPosition: vec3f,\n time: f32,\n cameraRight: vec3f,\n softness: f32,\n cameraUp: vec3f,\n zOffset: f32,\n screenSize: vec2f,\n near: f32,\n far: f32,\n blendMode: f32, \n lit: f32, \n shadowBias: f32,\n shadowStrength: f32,\n \n lightDir: vec3f,\n shadowMapSize: f32,\n lightColor: vec4f,\n ambientColor: vec4f,\n cascadeSizes: vec4f, \n \n envParams: vec4f, \n \n lightParams: vec4u, \n \n fogColor: vec3f,\n fogEnabled: f32,\n fogDistances: vec3f, \n fogBrightResist: f32,\n fogAlphas: vec3f, \n fogPad1: f32,\n fogHeightFade: vec2f, \n fogDebug: f32, \n fogPad2: f32,\n}\n\nstruct CascadeMatrices {\n matrices: array<mat4x4<f32>, CASCADE_COUNT>,\n}\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n @location(1) color: vec4f,\n @location(2) viewZ: f32,\n @location(3) linearDepth: f32, \n @location(4) lighting: vec3f, \n @location(5) @interpolate(flat) emitterIdx: u32, \n @location(6) worldPos: vec3f, \n @location(7) @interpolate(flat) centerViewZ: f32, \n}\n\n@group(0) @binding(0) var<uniform> uniforms: ParticleUniforms;\n@group(0) @binding(1) var<storage, read> particles: array<Particle>;\n@group(0) @binding(2) var particleTexture: texture_2d<f32>;\n@group(0) @binding(3) var particleSampler: sampler;\n@group(0) @binding(4) var depthTexture: texture_depth_2d;\n@group(0) @binding(5) var shadowMapArray: texture_depth_2d_array;\n@group(0) @binding(6) var shadowSampler: sampler_comparison;\n@group(0) @binding(7) var<storage, read> cascadeMatrices: CascadeMatrices;\n@group(0) @binding(8) var<storage, read> emitterSettings: array<EmitterRenderSettings, MAX_EMITTERS>;\n@group(0) @binding(9) var envMap: texture_2d<f32>;\n@group(0) @binding(10) var envSampler: sampler;\n\n@group(0) @binding(11) var<storage, read> lights: array<Light, MAX_LIGHTS>;\n\n@group(0) @binding(12) var spotShadowAtlas: texture_depth_2d;\n@group(0) @binding(13) var spotShadowSampler: sampler_comparison;\n@group(0) @binding(14) var<storage, read> spotMatrices: SpotShadowMatrices;\n\nfn getQuadVertex(vertexId: u32) -> vec2f {\n \n \n \n var corners = array<vec2f, 6>(\n vec2f(-0.5, -0.5), \n vec2f(0.5, -0.5), \n vec2f(-0.5, 0.5), \n vec2f(0.5, -0.5), \n vec2f(0.5, 0.5), \n vec2f(-0.5, 0.5) \n );\n return corners[vertexId];\n}\n\nfn getQuadUV(vertexId: u32) -> vec2f {\n var uvs = array<vec2f, 6>(\n vec2f(0.0, 1.0), \n vec2f(1.0, 1.0), \n vec2f(0.0, 0.0), \n vec2f(1.0, 1.0), \n vec2f(1.0, 0.0), \n vec2f(0.0, 0.0) \n );\n return uvs[vertexId];\n}\n\n@vertex\nfn vertexMain(\n @builtin(vertex_index) vertexIndex: u32,\n @builtin(instance_index) instanceIndex: u32\n) -> VertexOutput {\n var output: VertexOutput;\n\n \n let particle = particles[instanceIndex];\n\n \n let emitterIdx = (particle.flags >> 8u) & 0xFFu;\n output.emitterIdx = emitterIdx;\n\n \n if ((particle.flags & 1u) == 0u || particle.lifetime <= 0.0) {\n \n output.position = vec4f(0.0, 0.0, 0.0, 0.0);\n output.uv = vec2f(0.0, 0.0);\n output.color = vec4f(0.0, 0.0, 0.0, 0.0);\n output.viewZ = 0.0;\n output.linearDepth = 0.0;\n output.lighting = vec3f(0.0);\n output.worldPos = vec3f(0.0);\n output.centerViewZ = 0.0;\n return output;\n }\n\n \n \n let particleIsAdditive = (particle.flags & 2u) != 0u;\n let renderingAdditive = uniforms.blendMode > 0.5;\n\n if (particleIsAdditive != renderingAdditive) {\n \n output.position = vec4f(0.0, 0.0, 0.0, 0.0);\n output.uv = vec2f(0.0, 0.0);\n output.color = vec4f(0.0, 0.0, 0.0, 0.0);\n output.viewZ = 0.0;\n output.linearDepth = 0.0;\n output.lighting = vec3f(0.0);\n output.worldPos = vec3f(0.0);\n output.centerViewZ = 0.0;\n return output;\n }\n\n \n let localVertexId = vertexIndex % 6u;\n var quadPos = getQuadVertex(localVertexId);\n output.uv = getQuadUV(localVertexId);\n\n \n let cosR = cos(particle.rotation);\n let sinR = sin(particle.rotation);\n let rotatedPos = vec2f(\n quadPos.x * cosR - quadPos.y * sinR,\n quadPos.x * sinR + quadPos.y * cosR\n );\n\n \n let particleWorldPos = particle.position;\n\n \n let scaledOffset = rotatedPos * particle.size;\n\n \n let right = uniforms.cameraRight;\n let up = uniforms.cameraUp;\n let billboardPos = particleWorldPos + right * scaledOffset.x + up * scaledOffset.y;\n\n \n let toCamera = normalize(uniforms.cameraPosition - particleWorldPos);\n let offsetPos = billboardPos + toCamera * uniforms.zOffset;\n\n \n let viewPos = uniforms.viewMatrix * vec4f(offsetPos, 1.0);\n output.position = uniforms.projectionMatrix * viewPos;\n output.viewZ = -viewPos.z; \n\n \n let z = -viewPos.z; \n output.linearDepth = (z - uniforms.near) / (uniforms.far - uniforms.near);\n\n \n output.lighting = particle.lighting;\n\n \n output.color = particle.color;\n\n \n output.worldPos = particleWorldPos;\n\n \n \n \n output.centerViewZ = output.viewZ;\n\n return output;\n}\n\nfn SphToUV(n: vec3f) -> vec2f {\n var uv: vec2f;\n uv.x = atan2(-n.x, n.z);\n uv.x = (uv.x + PI / 2.0) / (PI * 2.0) + PI * (28.670 / 360.0);\n uv.y = acos(n.y) / PI;\n return uv;\n}\n\nfn octEncode(n: vec3f) -> vec2f {\n var n2 = n / (abs(n.x) + abs(n.y) + abs(n.z));\n if (n2.y < 0.0) {\n let signX = select(-1.0, 1.0, n2.x >= 0.0);\n let signZ = select(-1.0, 1.0, n2.z >= 0.0);\n n2 = vec3f(\n (1.0 - abs(n2.z)) * signX,\n n2.y,\n (1.0 - abs(n2.x)) * signZ\n );\n }\n return n2.xz * 0.5 + 0.5;\n}\n\nfn getEnvUV(dir: vec3f) -> vec2f {\n if (uniforms.envParams.z > 0.5) {\n return octEncode(dir);\n }\n return SphToUV(dir);\n}\n\nfn getIBLSample(dir: vec3f, lod: f32) -> vec3f {\n let envRGBE = textureSampleLevel(envMap, envSampler, getEnvUV(dir), lod);\n \n let envColor = envRGBE.rgb * pow(2.0, envRGBE.a * 255.0 - 128.0);\n return envColor;\n}\n\nfn squircleDistanceXZ(offset: vec2f, size: f32) -> f32 {\n let normalized = offset / size;\n let absNorm = abs(normalized);\n return pow(pow(absNorm.x, 4.0) + pow(absNorm.y, 4.0), 0.25);\n}\n\nfn sampleCascadeShadow(worldPos: vec3f, normal: vec3f, cascadeIndex: i32) -> f32 {\n let bias = uniforms.shadowBias;\n let shadowMapSize = uniforms.shadowMapSize;\n\n \n let biasedPos = worldPos + normal * bias * 0.5;\n\n \n let lightMatrix = cascadeMatrices.matrices[cascadeIndex];\n\n \n let lightSpacePos = lightMatrix * vec4f(biasedPos, 1.0);\n let projCoords = lightSpacePos.xyz / lightSpacePos.w;\n\n \n let shadowUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n let currentDepth = projCoords.z - bias;\n\n \n let inBoundsX = shadowUV.x >= 0.0 && shadowUV.x <= 1.0;\n let inBoundsY = shadowUV.y >= 0.0 && shadowUV.y <= 1.0;\n let inBoundsZ = currentDepth >= 0.0 && currentDepth <= 1.0;\n\n if (!inBoundsX || !inBoundsY || !inBoundsZ) {\n return 1.0; \n }\n\n let clampedUV = clamp(shadowUV, vec2f(0.001), vec2f(0.999));\n let clampedDepth = clamp(currentDepth, 0.001, 0.999);\n\n \n let texelSize = 1.0 / shadowMapSize;\n var shadow = 0.0;\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(-texelSize, 0.0), cascadeIndex, clampedDepth);\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(texelSize, 0.0), cascadeIndex, clampedDepth);\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(0.0, -texelSize), cascadeIndex, clampedDepth);\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(0.0, texelSize), cascadeIndex, clampedDepth);\n shadow /= 4.0;\n\n return shadow;\n}\n\nfn calculateParticleShadow(worldPos: vec3f, normal: vec3f) -> f32 {\n let shadowStrength = uniforms.shadowStrength;\n\n \n let camXZ = vec2f(uniforms.cameraPosition.x, uniforms.cameraPosition.z);\n let posXZ = vec2f(worldPos.x, worldPos.z);\n let offsetXZ = posXZ - camXZ;\n\n \n let cascade0Size = uniforms.cascadeSizes.x;\n let cascade1Size = uniforms.cascadeSizes.y;\n let cascade2Size = uniforms.cascadeSizes.z;\n\n let dist0 = squircleDistanceXZ(offsetXZ, cascade0Size);\n let dist1 = squircleDistanceXZ(offsetXZ, cascade1Size);\n let dist2 = squircleDistanceXZ(offsetXZ, cascade2Size);\n\n var shadow = 1.0;\n\n \n if (dist0 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, normal, 0);\n } else if (dist1 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, normal, 1);\n } else if (dist2 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, normal, 2);\n }\n\n \n return mix(1.0 - shadowStrength, 1.0, shadow);\n}\n\nfn calculateSpotShadow(worldPos: vec3f, normal: vec3f, slotIndex: i32) -> f32 {\n if (slotIndex < 0 || slotIndex >= MAX_SPOT_SHADOWS) {\n return 1.0; \n }\n\n let bias = uniforms.shadowBias;\n let normalBias = bias * 2.0; \n\n \n let biasedPos = worldPos + normal * normalBias;\n\n \n let lightMatrix = spotMatrices.matrices[slotIndex];\n\n \n let lightSpacePos = lightMatrix * vec4f(biasedPos, 1.0);\n\n \n let w = max(abs(lightSpacePos.w), 0.0001) * sign(lightSpacePos.w + 0.0001);\n let projCoords = lightSpacePos.xyz / w;\n\n \n if (projCoords.z < 0.0 || projCoords.z > 1.0 ||\n abs(projCoords.x) > 1.0 || abs(projCoords.y) > 1.0) {\n return 1.0; \n }\n\n \n let col = slotIndex % SPOT_TILES_PER_ROW;\n let row = slotIndex / SPOT_TILES_PER_ROW;\n\n \n let localUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n\n \n let tileOffset = vec2f(f32(col), f32(row)) * SPOT_TILE_SIZE;\n let atlasUV = (tileOffset + localUV * SPOT_TILE_SIZE) / vec2f(SPOT_ATLAS_WIDTH, SPOT_ATLAS_HEIGHT);\n\n \n let texelSize = 1.0 / SPOT_TILE_SIZE;\n let currentDepth = clamp(projCoords.z - bias * 3.0, 0.001, 0.999);\n\n var shadowSample = 0.0;\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(-texelSize, 0.0), currentDepth);\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(texelSize, 0.0), currentDepth);\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(0.0, -texelSize), currentDepth);\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(0.0, texelSize), currentDepth);\n shadowSample /= 4.0;\n\n return shadowSample;\n}\n\nfn applyLighting(baseColor: vec3f, lighting: vec3f) -> vec3f {\n \n \n return baseColor * lighting;\n}\n\nstruct FragmentOutput {\n @location(0) color: vec4f,\n @builtin(frag_depth) depth: f32,\n}\n\nfn calcSoftFade(fragPos: vec4f, particleLinearDepth: f32) -> f32 {\n if (uniforms.softness <= 0.0) {\n return 1.0;\n }\n\n \n let screenPos = vec2i(fragPos.xy);\n let screenSize = vec2i(uniforms.screenSize);\n\n \n if (screenPos.x < 0 || screenPos.x >= screenSize.x ||\n screenPos.y < 0 || screenPos.y >= screenSize.y) {\n return 1.0;\n }\n\n \n let sceneDepthNorm = textureLoad(depthTexture, screenPos, 0);\n\n \n if (sceneDepthNorm <= 0.0) {\n return 1.0;\n }\n\n \n let sceneDepth = uniforms.near + sceneDepthNorm * (uniforms.far - uniforms.near);\n let particleDepth = uniforms.near + particleLinearDepth * (uniforms.far - uniforms.near);\n\n \n let depthDiff = sceneDepth - particleDepth;\n return saturate(depthDiff / uniforms.softness);\n}\n\nfn calcFog(cameraDistance: f32, worldPosY: f32) -> f32 {\n if (uniforms.fogEnabled < 0.5) {\n return 0.0;\n }\n\n \n var distanceFog: f32;\n let d0 = uniforms.fogDistances.x;\n let d1 = uniforms.fogDistances.y;\n let d2 = uniforms.fogDistances.z;\n let a0 = uniforms.fogAlphas.x;\n let a1 = uniforms.fogAlphas.y;\n let a2 = uniforms.fogAlphas.z;\n\n if (cameraDistance <= d0) {\n distanceFog = a0;\n } else if (cameraDistance <= d1) {\n let t = (cameraDistance - d0) / max(d1 - d0, 0.001);\n distanceFog = mix(a0, a1, t);\n } else if (cameraDistance <= d2) {\n let t = (cameraDistance - d1) / max(d2 - d1, 0.001);\n distanceFog = mix(a1, a2, t);\n } else {\n distanceFog = a2;\n }\n\n \n let bottomY = uniforms.fogHeightFade.x;\n let topY = uniforms.fogHeightFade.y;\n var heightFactor = clamp((worldPosY - bottomY) / max(topY - bottomY, 0.001), 0.0, 1.0);\n if (worldPosY < bottomY) {\n heightFactor = 0.0;\n }\n\n return distanceFog * (1.0 - heightFactor);\n}\n\nfn applyFog(color: vec3f, cameraDistance: f32, worldPosY: f32) -> vec3f {\n let fogAlpha = calcFog(cameraDistance, worldPosY);\n if (fogAlpha <= 0.0) {\n return color;\n }\n\n return mix(color, uniforms.fogColor, fogAlpha);\n}\n\n@fragment\nfn fragmentMainAlpha(input: VertexOutput) -> FragmentOutput {\n var output: FragmentOutput;\n\n \n let texColor = textureSample(particleTexture, particleSampler, input.uv);\n\n \n var alpha = texColor.a * input.color.a;\n\n \n alpha *= calcSoftFade(input.position, input.linearDepth);\n\n \n if (alpha < 0.001) {\n discard;\n }\n\n \n let baseColor = texColor.rgb * input.color.rgb;\n let litColor = applyLighting(baseColor, input.lighting);\n\n \n let centerViewZ = input.centerViewZ;\n\n \n \n if (uniforms.fogDebug > 0.5) {\n \n if (uniforms.fogDebug < 1.5) {\n let dist = clamp(centerViewZ / 100.0, 0.0, 1.0);\n output.color = vec4f(vec3f(dist), 1.0);\n }\n \n \n else if (uniforms.fogDebug < 2.5) {\n let r = clamp(centerViewZ / 10.0, 0.0, 1.0);\n let g = clamp(centerViewZ / 100.0, 0.0, 1.0);\n let b = clamp(centerViewZ / 1000.0, 0.0, 1.0);\n output.color = vec4f(r, g, b, 1.0);\n }\n \n \n else if (uniforms.fogDebug < 3.5) {\n let center = clamp(centerViewZ / 100.0, 0.0, 1.0);\n let vertex = clamp(input.viewZ / 100.0, 0.0, 1.0);\n let diff = abs(center - vertex);\n output.color = vec4f(center, vertex, diff * 10.0, 1.0);\n }\n \n else if (uniforms.fogDebug < 4.5) {\n let r = clamp(abs(input.worldPos.x) / 100.0, 0.0, 1.0);\n let g = clamp(abs(input.worldPos.y) / 100.0, 0.0, 1.0);\n let b = clamp(abs(input.worldPos.z) / 100.0, 0.0, 1.0);\n output.color = vec4f(r, g, b, 1.0);\n }\n \n else {\n output.color = vec4f(uniforms.fogColor, 1.0);\n }\n output.depth = input.linearDepth;\n return output;\n }\n\n let foggedColor = applyFog(litColor, centerViewZ, input.worldPos.y);\n\n output.color = vec4f(foggedColor, alpha);\n output.depth = input.linearDepth;\n return output;\n}\n\n@fragment\nfn fragmentMainAdditive(input: VertexOutput) -> FragmentOutput {\n var output: FragmentOutput;\n\n \n let texColor = textureSample(particleTexture, particleSampler, input.uv);\n\n \n var alpha = texColor.a * input.color.a;\n\n \n alpha *= calcSoftFade(input.position, input.linearDepth);\n\n \n if (alpha < 0.001) {\n discard;\n }\n\n \n let baseColor = texColor.rgb * input.color.rgb;\n let litColor = applyLighting(baseColor, input.lighting);\n\n \n let centerViewZ = input.centerViewZ;\n\n \n \n if (uniforms.fogDebug > 0.5) {\n \n if (uniforms.fogDebug < 1.5) {\n let dist = clamp(centerViewZ / 100.0, 0.0, 1.0);\n output.color = vec4f(vec3f(dist), 1.0);\n }\n \n else if (uniforms.fogDebug < 2.5) {\n let r = clamp(centerViewZ / 10.0, 0.0, 1.0);\n let g = clamp(centerViewZ / 100.0, 0.0, 1.0);\n let b = clamp(centerViewZ / 1000.0, 0.0, 1.0);\n output.color = vec4f(r, g, b, 1.0);\n }\n \n else if (uniforms.fogDebug < 3.5) {\n let center = clamp(centerViewZ / 100.0, 0.0, 1.0);\n let vertex = clamp(input.viewZ / 100.0, 0.0, 1.0);\n let diff = abs(center - vertex);\n output.color = vec4f(center, vertex, diff * 10.0, 1.0);\n }\n \n else if (uniforms.fogDebug < 4.5) {\n let r = clamp(abs(input.worldPos.x) / 100.0, 0.0, 1.0);\n let g = clamp(abs(input.worldPos.y) / 100.0, 0.0, 1.0);\n let b = clamp(abs(input.worldPos.z) / 100.0, 0.0, 1.0);\n output.color = vec4f(r, g, b, 1.0);\n }\n \n else {\n output.color = vec4f(uniforms.fogColor, 1.0);\n }\n output.depth = input.linearDepth;\n return output;\n }\n\n \n \n let fogAlpha = calcFog(centerViewZ, input.worldPos.y);\n let fadedColor = litColor * (1.0 - fogAlpha);\n\n \n let rgb = fadedColor * alpha;\n output.color = vec4f(rgb, alpha);\n output.depth = input.linearDepth;\n return output;\n}";
7245
7488
  class ParticlePass extends BasePass {
7246
7489
  constructor(engine = null) {
7247
7490
  super("Particles", engine);
@@ -7779,7 +8022,7 @@ class ParticlePass extends BasePass {
7779
8022
  uniformData[87] = 0;
7780
8023
  uniformData[88] = fogHeightFade[0];
7781
8024
  uniformData[89] = fogHeightFade[1];
7782
- uniformData[90] = 0;
8025
+ uniformData[90] = fogSettings.debug ?? 0;
7783
8026
  uniformData[91] = 0;
7784
8027
  const emitterRenderData = new Float32Array(16 * 4);
7785
8028
  for (let i = 0; i < Math.min(emitters.length, 16); i++) {
@@ -7822,7 +8065,8 @@ class ParticlePass extends BasePass {
7822
8065
  if (hasAlpha) {
7823
8066
  uniformData[48] = 0;
7824
8067
  device.queue.writeBuffer(this.renderUniformBuffer, 0, uniformData);
7825
- const renderPass = commandEncoder.beginRenderPass({
8068
+ const alphaEncoder = device.createCommandEncoder({ label: "Particle Alpha Pass" });
8069
+ const renderPass = alphaEncoder.beginRenderPass({
7826
8070
  colorAttachments: [{
7827
8071
  view: this.outputTexture.view,
7828
8072
  loadOp: "load",
@@ -7838,11 +8082,13 @@ class ParticlePass extends BasePass {
7838
8082
  renderPass.setBindGroup(0, renderBindGroup);
7839
8083
  renderPass.draw(6, maxParticles, 0, 0);
7840
8084
  renderPass.end();
8085
+ device.queue.submit([alphaEncoder.finish()]);
7841
8086
  }
7842
8087
  if (hasAdditive) {
7843
8088
  uniformData[48] = 1;
7844
8089
  device.queue.writeBuffer(this.renderUniformBuffer, 0, uniformData);
7845
- const renderPass = commandEncoder.beginRenderPass({
8090
+ const additiveEncoder = device.createCommandEncoder({ label: "Particle Additive Pass" });
8091
+ const renderPass = additiveEncoder.beginRenderPass({
7846
8092
  colorAttachments: [{
7847
8093
  view: this.outputTexture.view,
7848
8094
  loadOp: "load",
@@ -7858,6 +8104,7 @@ class ParticlePass extends BasePass {
7858
8104
  renderPass.setBindGroup(0, renderBindGroup);
7859
8105
  renderPass.draw(6, maxParticles, 0, 0);
7860
8106
  renderPass.end();
8107
+ device.queue.submit([additiveEncoder.finish()]);
7861
8108
  }
7862
8109
  }
7863
8110
  async _resize(width, height) {
@@ -7899,6 +8146,10 @@ class FogPass extends BasePass {
7899
8146
  get fogBrightResist() {
7900
8147
  return this.settings?.environment?.fog?.brightResist ?? 0.8;
7901
8148
  }
8149
+ get fogDebug() {
8150
+ return this.settings?.environment?.fog?.debug ?? 0;
8151
+ }
8152
+ // 0=off, 1=show fogAlpha, 2=show distance, 3=show heightFactor
7902
8153
  /**
7903
8154
  * Set the input texture (HDR lighting output)
7904
8155
  */
@@ -7975,6 +8226,8 @@ class FogPass extends BasePass {
7975
8226
  brightResist: f32, // float 47
7976
8227
  heightFade: vec2f, // floats 48-49
7977
8228
  screenSize: vec2f, // floats 50-51
8229
+ debug: f32, // float 52: 0=off, 1=fogAlpha, 2=distance, 3=heightFactor
8230
+ _pad: vec3f, // floats 53-55
7978
8231
  }
7979
8232
 
7980
8233
  @group(0) @binding(0) var<uniform> uniforms: Uniforms;
@@ -8103,6 +8356,26 @@ class FogPass extends BasePass {
8103
8356
  let brightnessResist = clamp((luminance - 1.0) / 2.0, 0.0, 1.0);
8104
8357
  fogAlpha *= (1.0 - brightnessResist * uniforms.brightResist);
8105
8358
 
8359
+ // Debug output
8360
+ let debugMode = i32(uniforms.debug);
8361
+ if (debugMode == 1) {
8362
+ // Show fog alpha as grayscale
8363
+ return vec4f(vec3f(fogAlpha), 1.0);
8364
+ } else if (debugMode == 2) {
8365
+ // Show distance (normalized to 0-100m range)
8366
+ let normDist = clamp(cameraDistance / 100.0, 0.0, 1.0);
8367
+ return vec4f(vec3f(normDist), 1.0);
8368
+ } else if (debugMode == 3) {
8369
+ // Show height factor
8370
+ return vec4f(vec3f(heightFactor), 1.0);
8371
+ } else if (debugMode == 4) {
8372
+ // Show distance fog (before height fade)
8373
+ return vec4f(vec3f(distanceFog), 1.0);
8374
+ } else if (debugMode == 5) {
8375
+ // Show the actual fog color being used (to verify it matches particles)
8376
+ return vec4f(uniforms.fogColor, 1.0);
8377
+ }
8378
+
8106
8379
  // Apply fog
8107
8380
  let foggedColor = mix(color.rgb, uniforms.fogColor, fogAlpha);
8108
8381
 
@@ -8177,6 +8450,7 @@ class FogPass extends BasePass {
8177
8450
  uniformData[49] = heightFade[1];
8178
8451
  uniformData[50] = this.width;
8179
8452
  uniformData[51] = this.height;
8453
+ uniformData[52] = this.fogDebug;
8180
8454
  device.queue.writeBuffer(this.uniformBuffer, 0, uniformData);
8181
8455
  const commandEncoder = device.createCommandEncoder({ label: "Fog Pass" });
8182
8456
  const renderPass = commandEncoder.beginRenderPass({
@@ -8575,6 +8849,7 @@ class HiZPass extends BasePass {
8575
8849
  this.screenWidth = 0;
8576
8850
  this.screenHeight = 0;
8577
8851
  this._destroyed = false;
8852
+ this._warmupFramesRemaining = 5;
8578
8853
  }
8579
8854
  /**
8580
8855
  * Set the depth texture to read from (from GBuffer)
@@ -8583,6 +8858,18 @@ class HiZPass extends BasePass {
8583
8858
  setDepthTexture(depth) {
8584
8859
  this.depthTexture = depth;
8585
8860
  }
8861
+ /**
8862
+ * Invalidate occlusion culling data and reset warmup period.
8863
+ * Call this after engine creation, scene loading, or major camera changes
8864
+ * to prevent incorrect occlusion culling with stale data.
8865
+ */
8866
+ invalidate() {
8867
+ this.hasValidHistory = false;
8868
+ this.hizDataReady = false;
8869
+ this._warmupFramesRemaining = 5;
8870
+ vec3$1.set(this.lastCameraPosition, 0, 0, 0);
8871
+ vec3$1.set(this.lastCameraDirection, 0, 0, 0);
8872
+ }
8586
8873
  async _init() {
8587
8874
  const { device, canvas } = this.engine;
8588
8875
  await this._createResources(canvas.width, canvas.height);
@@ -8666,6 +8953,7 @@ class HiZPass extends BasePass {
8666
8953
  this._destroyed = false;
8667
8954
  this.hizDataReady = false;
8668
8955
  this.pendingReadback = null;
8956
+ this._warmupFramesRemaining = 5;
8669
8957
  }
8670
8958
  /**
8671
8959
  * Check if camera has moved significantly, requiring HiZ invalidation
@@ -8699,6 +8987,9 @@ class HiZPass extends BasePass {
8699
8987
  const { device } = this.engine;
8700
8988
  const { camera } = context;
8701
8989
  this._frameCounter++;
8990
+ if (this._warmupFramesRemaining > 0) {
8991
+ this._warmupFramesRemaining--;
8992
+ }
8702
8993
  if (!this.settings?.occlusionCulling?.enabled) {
8703
8994
  return;
8704
8995
  }
@@ -8849,6 +9140,9 @@ class HiZPass extends BasePass {
8849
9140
  */
8850
9141
  testSphereOcclusion(bsphere, viewProj, near, far, cameraPos) {
8851
9142
  this.debugStats.tested++;
9143
+ if (this._warmupFramesRemaining > 0) {
9144
+ return false;
9145
+ }
8852
9146
  if (!this.hizDataReady || !this.hasValidHistory) {
8853
9147
  return false;
8854
9148
  }
@@ -9262,6 +9556,8 @@ class SSGITilePass extends BasePass {
9262
9556
  this.tilePropagateBuffer = null;
9263
9557
  this.tileCountX = 0;
9264
9558
  this.tileCountY = 0;
9559
+ this.renderWidth = 0;
9560
+ this.renderHeight = 0;
9265
9561
  this.prevHDRTexture = null;
9266
9562
  this.emissiveTexture = null;
9267
9563
  this.uniformBuffer = null;
@@ -9290,6 +9586,8 @@ class SSGITilePass extends BasePass {
9290
9586
  }
9291
9587
  async _createResources(width, height) {
9292
9588
  const { device } = this.engine;
9589
+ this.renderWidth = width;
9590
+ this.renderHeight = height;
9293
9591
  this.tileCountX = Math.ceil(width / TILE_SIZE$1);
9294
9592
  this.tileCountY = Math.ceil(height / TILE_SIZE$1);
9295
9593
  const totalTiles = this.tileCountX * this.tileCountY;
@@ -9357,13 +9655,13 @@ class SSGITilePass extends BasePass {
9357
9655
  this._needsRebuild = false;
9358
9656
  }
9359
9657
  async _execute(context) {
9360
- const { device, canvas } = this.engine;
9658
+ const { device } = this.engine;
9361
9659
  const ssgiSettings = this.settings?.ssgi;
9362
9660
  if (!ssgiSettings?.enabled) {
9363
9661
  return;
9364
9662
  }
9365
9663
  if (this._needsRebuild) {
9366
- await this._createResources(canvas.width, canvas.height);
9664
+ await this._createResources(this.renderWidth, this.renderHeight);
9367
9665
  }
9368
9666
  if (!this.prevHDRTexture || !this.emissiveTexture) {
9369
9667
  return;
@@ -9371,8 +9669,8 @@ class SSGITilePass extends BasePass {
9371
9669
  if (!this.accumulatePipeline || !this.propagatePipeline) {
9372
9670
  return;
9373
9671
  }
9374
- const width = canvas.width;
9375
- const height = canvas.height;
9672
+ const width = this.renderWidth;
9673
+ const height = this.renderHeight;
9376
9674
  const emissiveBoost = ssgiSettings.emissiveBoost ?? 2;
9377
9675
  const maxBrightness = ssgiSettings.maxBrightness ?? 4;
9378
9676
  device.queue.writeBuffer(this.uniformBuffer, 0, new Float32Array([
@@ -9568,7 +9866,7 @@ class SSGIPass extends BasePass {
9568
9866
  if (!this.pipeline) {
9569
9867
  return;
9570
9868
  }
9571
- this._updateUniforms(ssgiSettings, canvas.width, canvas.height);
9869
+ this._updateUniforms(ssgiSettings, this.width * 2, this.height * 2);
9572
9870
  const bindGroup = device.createBindGroup({
9573
9871
  label: "ssgiBindGroup",
9574
9872
  layout: this.bindGroupLayout,
@@ -10040,9 +10338,11 @@ class BloomPass extends BasePass {
10040
10338
  const scale = this.bloomScale;
10041
10339
  const bloomWidth = Math.max(1, Math.floor(width * scale));
10042
10340
  const bloomHeight = Math.max(1, Math.floor(height * scale));
10341
+ if (this.bloomWidth !== bloomWidth || this.bloomHeight !== bloomHeight) {
10342
+ console.log(`Bloom: ${width}x${height} -> ${bloomWidth}x${bloomHeight} (scale: ${scale})`);
10343
+ }
10043
10344
  this.bloomWidth = bloomWidth;
10044
10345
  this.bloomHeight = bloomHeight;
10045
- console.log(`Bloom: ${width}x${height} -> ${bloomWidth}x${bloomHeight} (scale: ${scale})`);
10046
10346
  const createBloomTexture = (label) => {
10047
10347
  const texture = device.createTexture({
10048
10348
  label,
@@ -11011,150 +11311,842 @@ fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
11011
11311
  this.pipelineCache.clear();
11012
11312
  }
11013
11313
  }
11014
- var postproc_default = "struct VertexOutput {\n @builtin(position) position : vec4<f32>,\n @location(0) uv : vec2<f32>,\n}\n\nstruct Uniforms {\n canvasSize: vec2f,\n noiseParams: vec4f, \n ditherParams: vec4f, \n bloomParams: vec4f, \n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var inputTexture : texture_2d<f32>;\n@group(0) @binding(2) var inputSampler : sampler;\n@group(0) @binding(3) var noiseTexture : texture_2d<f32>;\n@group(0) @binding(4) var noiseSampler : sampler;\n@group(0) @binding(5) var bloomTexture : texture_2d<f32>;\n@group(0) @binding(6) var bloomSampler : sampler;\n@group(0) @binding(7) var guiTexture : texture_2d<f32>;\n@group(0) @binding(8) var guiSampler : sampler;\n\nconst FXAA_EDGE_THRESHOLD: f32 = 0.125; \nconst FXAA_EDGE_THRESHOLD_MIN: f32 = 0.0156; \nconst FXAA_SUBPIX_QUALITY: f32 = 1.0; \n\nfn sampleNoise(screenPos: vec2f) -> f32 {\n let noiseSize = i32(uniforms.noiseParams.x);\n let noiseOffsetX = i32(uniforms.noiseParams.y * f32(noiseSize));\n let noiseOffsetY = i32(uniforms.noiseParams.z * f32(noiseSize));\n\n let texCoord = vec2i(\n (i32(screenPos.x) + noiseOffsetX) % noiseSize,\n (i32(screenPos.y) + noiseOffsetY) % noiseSize\n );\n return textureLoad(noiseTexture, texCoord, 0).r;\n}\n\nfn rgb2luma(rgb: vec3f) -> f32 {\n return dot(rgb, vec3f(0.299, 0.587, 0.114));\n}\n\nfn loadPixel(coord: vec2i) -> vec3f {\n let size = vec2i(textureDimensions(inputTexture, 0));\n let clampedCoord = clamp(coord, vec2i(0), size - vec2i(1));\n return textureLoad(inputTexture, clampedCoord, 0).rgb;\n}\n\nfn sampleBilinear(uv: vec2f) -> vec3f {\n let texSize = vec2f(textureDimensions(inputTexture, 0));\n let texelPos = uv * texSize - 0.5;\n let baseCoord = vec2i(floor(texelPos));\n let frac = fract(texelPos);\n\n let c00 = loadPixel(baseCoord);\n let c10 = loadPixel(baseCoord + vec2i(1, 0));\n let c01 = loadPixel(baseCoord + vec2i(0, 1));\n let c11 = loadPixel(baseCoord + vec2i(1, 1));\n\n let c0 = mix(c00, c10, frac.x);\n let c1 = mix(c01, c11, frac.x);\n return mix(c0, c1, frac.y);\n}\n\nfn fxaa(uv: vec2f) -> vec3f {\n let texSize = vec2f(textureDimensions(inputTexture, 0));\n let pixelCoord = vec2i(uv * texSize);\n\n \n let rgbM = loadPixel(pixelCoord);\n let rgbN = loadPixel(pixelCoord + vec2i(0, -1));\n let rgbS = loadPixel(pixelCoord + vec2i(0, 1));\n let rgbW = loadPixel(pixelCoord + vec2i(-1, 0));\n let rgbE = loadPixel(pixelCoord + vec2i(1, 0));\n let rgbNW = loadPixel(pixelCoord + vec2i(-1, -1));\n let rgbNE = loadPixel(pixelCoord + vec2i(1, -1));\n let rgbSW = loadPixel(pixelCoord + vec2i(-1, 1));\n let rgbSE = loadPixel(pixelCoord + vec2i(1, 1));\n\n \n let lumaM = rgb2luma(rgbM);\n let lumaN = rgb2luma(rgbN);\n let lumaS = rgb2luma(rgbS);\n let lumaW = rgb2luma(rgbW);\n let lumaE = rgb2luma(rgbE);\n let lumaNW = rgb2luma(rgbNW);\n let lumaNE = rgb2luma(rgbNE);\n let lumaSW = rgb2luma(rgbSW);\n let lumaSE = rgb2luma(rgbSE);\n\n \n let lumaMin = min(lumaM, min(min(lumaN, lumaS), min(lumaW, lumaE)));\n let lumaMax = max(lumaM, max(max(lumaN, lumaS), max(lumaW, lumaE)));\n let lumaRange = lumaMax - lumaMin;\n\n \n let isEdge = lumaRange >= max(FXAA_EDGE_THRESHOLD_MIN, lumaMax * FXAA_EDGE_THRESHOLD);\n\n \n let lumaL = (lumaN + lumaS + lumaW + lumaE) * 0.25;\n let rangeL = abs(lumaL - lumaM);\n var blendL = max(0.0, (rangeL / max(lumaRange, 0.0001)) - 0.25) * (1.0 / 0.75);\n blendL = min(1.0, blendL) * blendL * FXAA_SUBPIX_QUALITY;\n\n \n let edgeHorz = abs((lumaNW + lumaNE) - 2.0 * lumaN) +\n 2.0 * abs((lumaW + lumaE) - 2.0 * lumaM) +\n abs((lumaSW + lumaSE) - 2.0 * lumaS);\n let edgeVert = abs((lumaNW + lumaSW) - 2.0 * lumaW) +\n 2.0 * abs((lumaN + lumaS) - 2.0 * lumaM) +\n abs((lumaNE + lumaSE) - 2.0 * lumaE);\n let isHorizontal = edgeHorz >= edgeVert;\n\n \n let luma1 = select(lumaE, lumaS, isHorizontal);\n let luma2 = select(lumaW, lumaN, isHorizontal);\n let gradient1 = abs(luma1 - lumaM);\n let gradient2 = abs(luma2 - lumaM);\n let is1Steepest = gradient1 >= gradient2;\n let gradientScaled = 0.25 * max(gradient1, gradient2);\n\n \n let stepSign = select(-1.0, 1.0, is1Steepest);\n let lumaLocalAverage = 0.5 * (select(luma2, luma1, is1Steepest) + lumaM);\n\n \n let searchDir = select(vec2i(0, 1), vec2i(1, 0), isHorizontal);\n\n let luma1_1 = rgb2luma(loadPixel(pixelCoord - searchDir)) - lumaLocalAverage;\n let luma2_1 = rgb2luma(loadPixel(pixelCoord + searchDir)) - lumaLocalAverage;\n let luma1_2 = rgb2luma(loadPixel(pixelCoord - searchDir * 2)) - lumaLocalAverage;\n let luma2_2 = rgb2luma(loadPixel(pixelCoord + searchDir * 2)) - lumaLocalAverage;\n let luma1_3 = rgb2luma(loadPixel(pixelCoord - searchDir * 3)) - lumaLocalAverage;\n let luma2_3 = rgb2luma(loadPixel(pixelCoord + searchDir * 3)) - lumaLocalAverage;\n let luma1_4 = rgb2luma(loadPixel(pixelCoord - searchDir * 4)) - lumaLocalAverage;\n let luma2_4 = rgb2luma(loadPixel(pixelCoord + searchDir * 4)) - lumaLocalAverage;\n\n \n let reached1_1 = abs(luma1_1) >= gradientScaled;\n let reached1_2 = abs(luma1_2) >= gradientScaled;\n let reached1_3 = abs(luma1_3) >= gradientScaled;\n let reached2_1 = abs(luma2_1) >= gradientScaled;\n let reached2_2 = abs(luma2_2) >= gradientScaled;\n let reached2_3 = abs(luma2_3) >= gradientScaled;\n\n \n let dist1 = select(select(select(4.0, 3.0, reached1_3), 2.0, reached1_2), 1.0, reached1_1);\n let dist2 = select(select(select(4.0, 3.0, reached2_3), 2.0, reached2_2), 1.0, reached2_1);\n\n \n let lumaEnd1 = select(select(select(luma1_4, luma1_3, reached1_3), luma1_2, reached1_2), luma1_1, reached1_1);\n let lumaEnd2 = select(select(select(luma2_4, luma2_3, reached2_3), luma2_2, reached2_2), luma2_1, reached2_1);\n\n \n let distFinal = min(dist1, dist2);\n let edgeThickness = dist1 + dist2;\n let lumaEndCloser = select(lumaEnd2, lumaEnd1, dist1 < dist2);\n let correctVariation = (lumaEndCloser < 0.0) != (lumaM < lumaLocalAverage);\n var pixelOffset = select(0.0, -distFinal / max(edgeThickness, 0.0001) + 0.5, correctVariation);\n\n \n let finalOffset = max(max(pixelOffset, blendL), 0.5);\n\n \n let inverseVP = 1.0 / texSize;\n var finalUv = uv;\n let offsetAmount = finalOffset * stepSign;\n finalUv.x += select(offsetAmount * inverseVP.x, 0.0, isHorizontal);\n finalUv.y += select(0.0, offsetAmount * inverseVP.y, isHorizontal);\n\n \n let offsetColor = sampleBilinear(finalUv);\n\n \n \n let perpDir = select(vec2i(0, 1), vec2i(1, 0), isHorizontal);\n let perpColor1 = loadPixel(pixelCoord + perpDir);\n let perpColor2 = loadPixel(pixelCoord - perpDir);\n let neighborAvg = (perpColor1 + perpColor2) * 0.5;\n\n \n let fxaaColor = mix(mix(offsetColor, neighborAvg, 0.7), rgbM, 0.1);\n\n \n return select(rgbM, fxaaColor, isEdge);\n}\n\nfn sampleBloom(uv: vec2f, sceneBrightness: f32) -> vec3f {\n let bloom = textureSample(bloomTexture, bloomSampler, uv).rgb;\n\n \n \n let threshold = 0.5; \n let mask = saturate(1.0 - (sceneBrightness - threshold) / (1.0 - threshold));\n\n return bloom * mask * mask; \n}\n\nfn aces_tone_map(hdr: vec3<f32>) -> vec3<f32> {\n let m1 = mat3x3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777,\n );\n let m2 = mat3x3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602,\n );\n let v = m1 * hdr;\n let a = v * (v + 0.0245786) - 0.000090537;\n let b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return clamp(m2 * (a / b), vec3(0.0), vec3(1.0));\n}\n\n@vertex\nfn vertexMain(@builtin(vertex_index) vertexIndex : u32) -> VertexOutput {\n var output : VertexOutput;\n let x = f32(vertexIndex & 1u) * 4.0 - 1.0;\n let y = f32(vertexIndex >> 1u) * 4.0 - 1.0;\n output.position = vec4<f32>(x, y, 0.0, 1.0);\n output.uv = vec2<f32>((x + 1.0) * 0.5, (1.0 - y) * 0.5);\n return output;\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {\n \n let fxaaEnabled = uniforms.noiseParams.w > 0.5;\n let fxaaColor = fxaa(input.uv);\n let directColor = textureSample(inputTexture, inputSampler, input.uv).rgb;\n var color = select(directColor, fxaaColor, fxaaEnabled);\n\n \n \n if (uniforms.bloomParams.x > 0.5) {\n let sceneBrightness = rgb2luma(color);\n let bloom = sampleBloom(input.uv, sceneBrightness);\n color += bloom * uniforms.bloomParams.y; \n }\n\n var sdr = aces_tone_map(color);\n\n \n \n let gui = textureSample(guiTexture, guiSampler, input.uv);\n sdr = sdr * (1.0 - gui.a) + gui.rgb;\n\n \n \n if (uniforms.ditherParams.x > 0.5 && uniforms.noiseParams.x > 0.0) {\n let levels = uniforms.ditherParams.y;\n let noise = sampleNoise(input.position.xy);\n\n \n \n let dither = noise - 0.5;\n sdr = floor(sdr * (levels - 1.0) + dither + 0.5) / (levels - 1.0);\n sdr = clamp(sdr, vec3f(0.0), vec3f(1.0));\n }\n\n return vec4<f32>(sdr, 1.0);\n}";
11015
- class PostProcessPass extends BasePass {
11314
+ var volumetric_raymarch_default = "const MAX_LIGHTS: u32 = 768u;\nconst MAX_SPOT_SHADOWS: i32 = 16;\nconst SPOT_ATLAS_SIZE: f32 = 2048.0;\nconst SPOT_TILE_SIZE: f32 = 512.0;\nconst SPOT_TILES_PER_ROW: i32 = 4;\n\nstruct Uniforms {\n inverseProjection: mat4x4f,\n inverseView: mat4x4f,\n cameraPosition: vec3f,\n nearPlane: f32,\n farPlane: f32,\n maxSamples: f32,\n time: f32,\n fogDensity: f32,\n fogColor: vec3f,\n shadowsEnabled: f32,\n mainLightDir: vec3f,\n mainLightIntensity: f32,\n mainLightColor: vec3f,\n scatterStrength: f32,\n fogHeightFade: vec2f,\n maxDistance: f32,\n lightCount: f32,\n debugMode: f32,\n noiseStrength: f32, \n noiseAnimated: f32, \n mainLightScatter: f32, \n noiseScale: f32, \n mainLightSaturation: f32, \n}\n\nstruct Light {\n enabled: u32, \n _pad0: u32, \n _pad1: u32, \n _pad2: u32, \n position: vec3f, \n _pad3: f32, \n color: vec4f, \n direction: vec3f, \n _pad4: f32, \n geom: vec4f, \n shadowIndex: i32, \n _pad5: u32, \n _pad6: u32, \n _pad7: u32, \n}\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var depthTexture: texture_depth_2d;\n@group(0) @binding(2) var cascadeShadowMaps: texture_depth_2d_array;\n@group(0) @binding(3) var shadowSampler: sampler_comparison;\n@group(0) @binding(4) var<storage, read> cascadeMatrices: array<mat4x4f>;\n@group(0) @binding(5) var<storage, read> lights: array<Light, MAX_LIGHTS>;\n@group(0) @binding(6) var spotShadowAtlas: texture_depth_2d;\n@group(0) @binding(7) var<storage, read> spotMatrices: array<mat4x4f, MAX_SPOT_SHADOWS>;\n\n@vertex\nfn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {\n var output: VertexOutput;\n let x = f32(vertexIndex & 1u) * 4.0 - 1.0;\n let y = f32(vertexIndex >> 1u) * 4.0 - 1.0;\n output.position = vec4f(x, y, 0.0, 1.0);\n output.uv = vec2f((x + 1.0) * 0.5, (1.0 - y) * 0.5);\n return output;\n}\n\nfn hash(p: vec3f) -> f32 {\n var p3 = fract(p * 0.1031);\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn noise3d(p: vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n\n return mix(\n mix(mix(hash(i + vec3f(0,0,0)), hash(i + vec3f(1,0,0)), u.x),\n mix(hash(i + vec3f(0,1,0)), hash(i + vec3f(1,1,0)), u.x), u.y),\n mix(mix(hash(i + vec3f(0,0,1)), hash(i + vec3f(1,0,1)), u.x),\n mix(hash(i + vec3f(0,1,1)), hash(i + vec3f(1,1,1)), u.x), u.y),\n u.z\n );\n}\n\nfn fbm(p: vec3f) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var pos = p;\n for (var i = 0; i < 3; i++) {\n value += amplitude * noise3d(pos);\n pos *= 2.0;\n amplitude *= 0.5;\n }\n return value;\n}\n\nfn getFogDensity(worldPos: vec3f, dist: f32) -> f32 {\n \n let heightFade = uniforms.fogHeightFade;\n var heightMod = 1.0;\n if (heightFade.y > heightFade.x) {\n let range = heightFade.y - heightFade.x;\n let fadeZone = range * 0.1; \n let topFade = 1.0 - smoothstep(heightFade.y - fadeZone, heightFade.y, worldPos.y);\n let bottomFade = smoothstep(heightFade.x, heightFade.x + fadeZone, worldPos.y);\n heightMod = topFade * bottomFade;\n }\n\n \n if (uniforms.noiseStrength < 0.001) {\n return uniforms.fogDensity * heightMod;\n }\n\n \n let timeOffset = select(0.0, uniforms.time, uniforms.noiseAnimated > 0.5);\n\n \n \n let scale1 = uniforms.noiseScale; \n let scale2 = uniforms.noiseScale * 0.32; \n\n let noisePos = worldPos * scale1 + vec3f(timeOffset * 0.15, timeOffset * 0.02, timeOffset * 0.08);\n let noiseVal = fbm(noisePos);\n\n \n let noisePos2 = worldPos * scale2 + vec3f(timeOffset * 0.05, 0.0, timeOffset * 0.03);\n let noiseVal2 = fbm(noisePos2);\n\n \n let combinedNoise = noiseVal * 0.6 + noiseVal2 * 0.4;\n\n \n \n \n let noiseRange = combinedNoise * 1.0 + 0.2; \n let noiseMapped = mix(1.0, noiseRange, uniforms.noiseStrength);\n\n return uniforms.fogDensity * heightMod * noiseMapped;\n}\n\nfn sampleShadow(worldPos: vec3f) -> f32 {\n if (uniforms.shadowsEnabled < 0.5) {\n return 1.0;\n }\n\n \n for (var cascade = 0; cascade < 3; cascade++) {\n let lightSpacePos = cascadeMatrices[cascade] * vec4f(worldPos, 1.0);\n let projCoords = lightSpacePos.xyz / lightSpacePos.w;\n\n \n let uv = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n\n \n if (uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0 &&\n projCoords.z >= 0.0 && projCoords.z <= 1.0) {\n\n let bias = 0.003 * f32(cascade + 1);\n let depth = projCoords.z - bias;\n let clampedUV = clamp(uv, vec2f(0.002), vec2f(0.998));\n let clampedDepth = clamp(depth, 0.001, 0.999);\n\n return textureSampleCompareLevel(cascadeShadowMaps, shadowSampler, clampedUV, cascade, clampedDepth);\n }\n }\n\n return 1.0;\n}\n\nfn sampleSpotShadow(worldPos: vec3f, slotIndex: i32) -> f32 {\n if (slotIndex < 0 || slotIndex >= MAX_SPOT_SHADOWS) {\n return 1.0;\n }\n\n \n let lightMatrix = spotMatrices[slotIndex];\n\n \n let lightSpacePos = lightMatrix * vec4f(worldPos, 1.0);\n\n \n let w = max(abs(lightSpacePos.w), 0.0001) * sign(lightSpacePos.w + 0.0001);\n let projCoords = lightSpacePos.xyz / w;\n\n \n if (abs(projCoords.x) > 1.0 || abs(projCoords.y) > 1.0 ||\n projCoords.z < 0.0 || projCoords.z > 1.0) {\n return 1.0;\n }\n\n \n let col = slotIndex % SPOT_TILES_PER_ROW;\n let row = slotIndex / SPOT_TILES_PER_ROW;\n\n \n let tileUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n\n \n let tileOffsetX = f32(col) * SPOT_TILE_SIZE;\n let tileOffsetY = f32(row) * SPOT_TILE_SIZE;\n let atlasUV = vec2f(\n (tileOffsetX + tileUV.x * SPOT_TILE_SIZE) / SPOT_ATLAS_SIZE,\n (tileOffsetY + tileUV.y * SPOT_TILE_SIZE) / SPOT_ATLAS_SIZE\n );\n\n \n let bias = 0.005;\n let depth = clamp(projCoords.z - bias, 0.001, 0.999);\n\n return textureSampleCompareLevel(spotShadowAtlas, shadowSampler, atlasUV, depth);\n}\n\nfn calculateLightContribution(worldPos: vec3f, light: Light, worldRayDir: vec3f) -> vec3f {\n let toLight = light.position - worldPos;\n let dist = length(toLight);\n let radius = light.geom.x;\n\n \n if (dist > radius) {\n return vec3f(0.0);\n }\n\n \n \n let normalizedDist = dist / radius;\n\n \n \n let attenuation = 1.0 - smoothstep(0.0, 1.0, normalizedDist);\n\n \n let innerCone = light.geom.y;\n let outerCone = light.geom.z;\n var spotAttenuation = 1.0;\n let isSpotlight = outerCone > 0.0;\n\n if (isSpotlight) {\n let lightDir = normalize(-toLight);\n let spotCos = dot(lightDir, normalize(light.direction));\n spotAttenuation = smoothstep(outerCone, innerCone, spotCos);\n if (spotAttenuation <= 0.0) {\n return vec3f(0.0);\n }\n }\n\n \n var shadow = 1.0;\n if (isSpotlight && light.shadowIndex >= 0 && uniforms.shadowsEnabled > 0.5) {\n shadow = sampleSpotShadow(worldPos, light.shadowIndex);\n }\n\n \n let phase = 0.25; \n\n \n let intensity = light.color.a;\n return light.color.rgb * intensity * attenuation * spotAttenuation * shadow * phase;\n}\n\nfn phaseHG(cosTheta: f32, g: f32) -> f32 {\n let g2 = g * g;\n let denom = 1.0 + g2 - 2.0 * g * cosTheta;\n return (1.0 - g2) / (4.0 * 3.14159 * pow(denom, 1.5));\n}\n\nfn linearizeDepth(depth: f32) -> f32 {\n let near = uniforms.nearPlane;\n let far = uniforms.farPlane;\n return near + depth * (far - near);\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput) -> @location(0) vec4f {\n let uv = input.uv;\n\n \n let DEBUG = i32(uniforms.debugMode);\n\n \n \n var clipUV = uv;\n clipUV.y = 1.0 - clipUV.y; \n let ndc = vec4f(clipUV * 2.0 - 1.0, 0.0, 1.0);\n\n \n let viewRay4 = uniforms.inverseProjection * ndc;\n let viewDir = normalize(viewRay4.xyz / viewRay4.w);\n\n \n let worldRayDir = normalize((uniforms.inverseView * vec4f(viewDir, 0.0)).xyz);\n\n \n let depthTexSize = vec2f(textureDimensions(depthTexture));\n let depthCoord = vec2i(uv * depthTexSize);\n let rawDepth = textureLoad(depthTexture, depthCoord, 0);\n\n \n if (DEBUG == 1) {\n return vec4f(rawDepth, rawDepth, rawDepth, 1.0);\n }\n\n \n if (DEBUG == 2) {\n return vec4f(worldRayDir * 0.5 + 0.5, 1.0);\n }\n\n \n if (DEBUG == 4) {\n let vzVis = abs(viewDir.z);\n return vec4f(vzVis, vzVis, vzVis, 1.0);\n }\n\n \n \n \n \n if (DEBUG == 7) {\n let numLights = i32(uniforms.lightCount);\n if (numLights > 0) {\n let light = lights[0];\n \n let camToLightDist = length(light.position - uniforms.cameraPosition);\n let radius = light.geom.x;\n \n return vec4f(\n clamp(camToLightDist / 50.0, 0.0, 1.0),\n f32(light.enabled) * 0.5,\n clamp(radius / 50.0, 0.0, 1.0),\n 1.0\n );\n }\n return vec4f(1.0, 0.0, 0.0, 1.0); \n }\n\n \n \n if (DEBUG == 8) {\n let numLights = i32(uniforms.lightCount);\n if (numLights > 0) {\n let light = lights[0];\n \n return vec4f(\n (light.position.x + 100.0) / 200.0,\n (light.position.y + 10.0) / 60.0,\n (light.position.z + 100.0) / 200.0,\n 1.0\n );\n }\n return vec4f(0.0, 0.0, 0.0, 1.0);\n }\n\n \n var geometryDist = 1000000.0;\n if (rawDepth < 0.9999) {\n let linearDepth = linearizeDepth(rawDepth);\n geometryDist = linearDepth / max(0.001, -viewDir.z) * 0.98;\n }\n\n \n \n let fogBottom = uniforms.fogHeightFade.x;\n let fogTop = uniforms.fogHeightFade.y;\n let camY = uniforms.cameraPosition.y;\n\n \n \n \n \n var tStart = 0.0;\n var tEnd = geometryDist;\n\n if (abs(worldRayDir.y) > 0.0001) {\n let tBottom = (fogBottom - camY) / worldRayDir.y;\n let tTop = (fogTop - camY) / worldRayDir.y;\n\n \n let tEnter = min(tBottom, tTop);\n let tExit = max(tBottom, tTop);\n\n \n if (camY < fogBottom || camY > fogTop) {\n tStart = max(0.0, tEnter);\n }\n\n \n tEnd = min(tEnd, max(0.0, tExit));\n } else {\n \n if (camY < fogBottom || camY > fogTop) {\n return vec4f(0.0); \n }\n }\n\n \n if (tStart >= tEnd || (tEnd - tStart) < 0.5) {\n return vec4f(0.0);\n }\n\n \n \n if (DEBUG == 9) {\n let rayLength = tEnd - tStart;\n return vec4f(tStart / 50.0, tEnd / 50.0, rayLength / 50.0, 1.0);\n }\n\n \n \n if (DEBUG == 10) {\n let numLights = i32(uniforms.lightCount);\n if (numLights > 0) {\n let light = lights[0];\n let radius = light.geom.x;\n var minDist = 9999.0;\n var hitCount = 0.0;\n let rayLength = tEnd - tStart;\n for (var ti = 0; ti < 20; ti++) {\n let testT = tStart + rayLength * f32(ti) / 20.0;\n let testPos = uniforms.cameraPosition + worldRayDir * testT;\n let dist = length(light.position - testPos);\n minDist = min(minDist, dist);\n if (dist < radius) {\n hitCount += 1.0;\n }\n }\n \n return vec4f(minDist / 50.0, hitCount / 20.0, radius / 50.0, 1.0);\n }\n return vec4f(1.0, 0.0, 0.0, 1.0);\n }\n\n \n if (DEBUG == 3) {\n let samplePos = uniforms.cameraPosition + worldRayDir * min(10.0, tEnd);\n let n = getFogDensity(samplePos, 10.0);\n return vec4f(n, n, n, 1.0);\n }\n\n \n if (DEBUG == 5) {\n let worldPos = uniforms.cameraPosition + worldRayDir * min(5.0, tEnd);\n return vec4f(fract(worldPos * 0.1), 1.0);\n }\n\n \n \n let numSamples = i32(uniforms.maxSamples);\n let fNumSamples = f32(numSamples);\n\n \n let jitter = fract(sin(dot(input.position.xy, vec2f(12.9898, 78.233))) * 43758.5453);\n\n var accumulatedColor = vec3f(0.0);\n var accumulatedMainLight = vec3f(0.0); \n var accumulatedShadow = 0.0; \n var shadowWeight = 0.0; \n var accumulatedAlpha = 0.0;\n\n \n let lightDir = normalize(uniforms.mainLightDir);\n let viewToLight = dot(worldRayDir, lightDir);\n \n let hgPhase = phaseHG(viewToLight, 0.6);\n let phase = max(0.4, hgPhase); \n\n \n\n let rayLength = tEnd - tStart;\n let minStepSize = 0.25; \n let maxStepSize = 2.0; \n let stepSize = clamp(rayLength / fNumSamples, minStepSize, maxStepSize);\n \n\n \n var t = tStart + jitter * stepSize;\n\n \n var debugMaxLight = vec3f(0.0);\n var debugLightHits = 0.0;\n\n for (var i = 0; i < numSamples; i++) {\n \n if (t > tEnd) { break; }\n\n let samplePos = uniforms.cameraPosition + worldRayDir * t;\n\n \n t += stepSize;\n\n \n let density = getFogDensity(samplePos, t);\n \n \n if (density <= 0.0) { continue; }\n\n \n \n var mainLighting = vec3f(0.0);\n var pointLighting = vec3f(0.0);\n\n \n if (uniforms.mainLightIntensity > 0.0) {\n let rawShadow = sampleShadow(samplePos);\n\n \n let weight = density * stepSize;\n accumulatedShadow += rawShadow * weight;\n shadowWeight += weight;\n\n \n mainLighting = uniforms.mainLightColor * uniforms.mainLightIntensity * phase * uniforms.mainLightScatter;\n }\n\n \n let numLights = i32(uniforms.lightCount);\n for (var li = 0; li < numLights; li++) {\n let light = lights[li];\n if (light.enabled == 0u) { continue; }\n\n let contrib = calculateLightContribution(samplePos, light, worldRayDir);\n pointLighting += contrib;\n\n \n if (length(contrib) > 0.001) {\n debugLightHits += 1.0;\n debugMaxLight = max(debugMaxLight, contrib);\n }\n }\n\n \n let pointColor = pointLighting * uniforms.scatterStrength * uniforms.fogColor;\n let mainColor = mainLighting * uniforms.fogColor;\n\n \n \n \n let rawSampleAlpha = density * stepSize;\n let sampleAlpha = min(rawSampleAlpha, 0.03); \n\n \n let colorWeight = rawSampleAlpha * (1.0 - accumulatedAlpha);\n accumulatedColor += pointColor * colorWeight;\n accumulatedMainLight += mainColor * colorWeight;\n accumulatedAlpha += sampleAlpha * (1.0 - accumulatedAlpha);\n }\n\n \n accumulatedAlpha = min(accumulatedAlpha, 1.0);\n\n \n let avgShadow = select(1.0, accumulatedShadow / shadowWeight, shadowWeight > 0.001);\n\n \n \n \n let contrastShadow = smoothstep(0.25, 0.75, avgShadow);\n \n let finalShadow = mix(avgShadow, contrastShadow, 0.88);\n\n \n let sat = uniforms.mainLightSaturation;\n let saturatedShadow = sat * (1.0 - exp(-finalShadow * 3.0 / max(sat, 0.01)));\n\n \n accumulatedColor += accumulatedMainLight * saturatedShadow;\n\n \n \n if (DEBUG == 11) {\n return vec4f(length(debugMaxLight), debugLightHits / 100.0, 0.0, 1.0);\n }\n\n \n \n if (DEBUG == 12) {\n let distMarched = t - tStart;\n let iterCount = distMarched / stepSize;\n return vec4f(distMarched / 50.0, iterCount / fNumSamples, accumulatedAlpha, 1.0);\n }\n\n \n \n if (DEBUG == 13) {\n let numLights = i32(uniforms.lightCount);\n if (numLights > 0) {\n let light = lights[0];\n let radius = light.geom.x;\n var minDistToLight = 9999.0;\n var closestT = 0.0;\n let testRayLen = t - tStart; \n for (var ti = 0; ti < 32; ti++) {\n let testT = tStart + testRayLen * f32(ti) / 32.0;\n let testPos = uniforms.cameraPosition + worldRayDir * testT;\n let distToLight = length(light.position - testPos);\n if (distToLight < minDistToLight) {\n minDistToLight = distToLight;\n closestT = testT;\n }\n }\n \n \n \n return vec4f(minDistToLight / radius, closestT / 50.0, radius / 50.0, 1.0);\n }\n return vec4f(1.0, 0.0, 0.0, 1.0);\n }\n\n \n if (DEBUG == 6) {\n return vec4f(accumulatedColor, 1.0);\n }\n\n return vec4f(accumulatedColor, accumulatedAlpha);\n}";
11315
+ var volumetric_blur_default = "struct Uniforms {\n direction: vec2f,\n texelSize: vec2f,\n radius: f32,\n _pad0: f32,\n _pad1: f32,\n _pad2: f32,\n}\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var inputTexture: texture_2d<f32>;\n@group(0) @binding(2) var inputSampler: sampler;\n\n@vertex\nfn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {\n var output: VertexOutput;\n\n let x = f32(vertexIndex & 1u) * 4.0 - 1.0;\n let y = f32(vertexIndex >> 1u) * 4.0 - 1.0;\n\n output.position = vec4f(x, y, 0.0, 1.0);\n output.uv = vec2f((x + 1.0) * 0.5, (1.0 - y) * 0.5);\n\n return output;\n}\n\nfn gaussian(x: f32, sigma: f32) -> f32 {\n return exp(-(x * x) / (2.0 * sigma * sigma));\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput) -> @location(0) vec4f {\n let uv = input.uv;\n let radius = uniforms.radius;\n let sigma = radius / 3.0; \n\n \n var color = textureSample(inputTexture, inputSampler, uv);\n var totalWeight = 1.0;\n\n \n let stepSize = 1.5;\n let numSamples = i32(ceil(radius / stepSize));\n\n \n let dir = uniforms.direction * uniforms.texelSize;\n\n \n for (var i = 1; i <= numSamples; i++) {\n let offset = f32(i) * stepSize;\n let weight = gaussian(offset, sigma);\n\n \n let uvPos = uv + dir * offset;\n let samplePos = textureSample(inputTexture, inputSampler, uvPos);\n color += samplePos * weight;\n\n \n let uvNeg = uv - dir * offset;\n let sampleNeg = textureSample(inputTexture, inputSampler, uvNeg);\n color += sampleNeg * weight;\n\n totalWeight += weight * 2.0;\n }\n\n \n color /= totalWeight;\n\n return color;\n}";
11316
+ var volumetric_composite_default = "struct Uniforms {\n canvasSize: vec2f,\n renderSize: vec2f,\n texelSize: vec2f,\n \n brightnessThreshold: f32, \n minVisibility: f32, \n skyBrightness: f32, \n _pad: f32,\n}\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var sceneTexture: texture_2d<f32>;\n@group(0) @binding(2) var fogTexture: texture_2d<f32>;\n@group(0) @binding(3) var linearSampler: sampler;\n@group(0) @binding(4) var depthTexture: texture_depth_2d;\n\n@vertex\nfn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {\n var output: VertexOutput;\n\n let x = f32(vertexIndex & 1u) * 4.0 - 1.0;\n let y = f32(vertexIndex >> 1u) * 4.0 - 1.0;\n\n output.position = vec4f(x, y, 0.0, 1.0);\n output.uv = vec2f((x + 1.0) * 0.5, (1.0 - y) * 0.5);\n\n return output;\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput) -> @location(0) vec4f {\n let uv = input.uv;\n\n \n let sceneColor = textureSample(sceneTexture, linearSampler, uv).rgb;\n\n \n let fog = textureSample(fogTexture, linearSampler, uv);\n\n \n let depthCoord = vec2i(input.position.xy);\n let depth = textureLoad(depthTexture, depthCoord, 0);\n\n \n var luminance = dot(sceneColor, vec3f(0.299, 0.587, 0.114));\n\n \n \n let isSky = depth > 0.9999;\n if (isSky) {\n luminance = max(luminance, uniforms.skyBrightness);\n }\n\n \n \n let threshold = uniforms.brightnessThreshold;\n let minVis = uniforms.minVisibility;\n\n \n \n \n \n let falloff = 1.0 / (1.0 + luminance / max(threshold, 0.01));\n let fogVisibility = mix(minVis, 1.0, falloff);\n\n \n let finalColor = sceneColor + fog.rgb * fogVisibility;\n\n return vec4f(finalColor, 1.0);\n}";
11317
+ class VolumetricFogPass extends BasePass {
11016
11318
  constructor(engine = null) {
11017
- super("PostProcess", engine);
11018
- this.pipeline = null;
11319
+ super("VolumetricFog", engine);
11320
+ this.raymarchPipeline = null;
11321
+ this.blurHPipeline = null;
11322
+ this.blurVPipeline = null;
11323
+ this.compositePipeline = null;
11324
+ this.raymarchBGL = null;
11325
+ this.blurBGL = null;
11326
+ this.compositeBGL = null;
11327
+ this.raymarchTexture = null;
11328
+ this.blurTempTexture = null;
11329
+ this.blurredTexture = null;
11330
+ this.outputTexture = null;
11331
+ this.raymarchUniformBuffer = null;
11332
+ this.blurHUniformBuffer = null;
11333
+ this.blurVUniformBuffer = null;
11334
+ this.compositeUniformBuffer = null;
11335
+ this.linearSampler = null;
11019
11336
  this.inputTexture = null;
11020
- this.bloomTexture = null;
11021
- this.dummyBloomTexture = null;
11022
- this.noiseTexture = null;
11023
- this.noiseSize = 64;
11024
- this.noiseAnimated = true;
11025
- this.guiCanvas = null;
11026
- this.guiTexture = null;
11027
- this.guiSampler = null;
11337
+ this.gbuffer = null;
11338
+ this.shadowPass = null;
11339
+ this.lightingPass = null;
11340
+ this.canvasWidth = 0;
11341
+ this.canvasHeight = 0;
11342
+ this.renderWidth = 0;
11343
+ this.renderHeight = 0;
11344
+ this._currentMainLightScatter = null;
11345
+ this._lastUpdateTime = 0;
11346
+ this._cameraInShadowSmooth = 0;
11347
+ this._skyVisible = true;
11348
+ this._skyCheckPending = false;
11349
+ this._lastSkyCheckTime = 0;
11028
11350
  }
11029
- // Convenience getter for exposure setting
11030
- get exposure() {
11031
- return this.settings?.environment?.exposure ?? 1.6;
11351
+ // Settings getters
11352
+ get volumetricSettings() {
11353
+ return this.settings?.volumetricFog ?? {};
11032
11354
  }
11033
- // Convenience getter for fxaa setting
11034
- get fxaa() {
11035
- return this.settings?.rendering?.fxaa ?? true;
11355
+ get fogSettings() {
11356
+ return this.settings?.fog ?? {};
11036
11357
  }
11037
- // Convenience getter for dithering settings
11038
- get ditheringEnabled() {
11039
- return this.settings?.dithering?.enabled ?? true;
11358
+ get isVolumetricEnabled() {
11359
+ return this.volumetricSettings.enabled ?? false;
11040
11360
  }
11041
- get colorLevels() {
11042
- return this.settings?.dithering?.colorLevels ?? 32;
11361
+ get resolution() {
11362
+ return this.volumetricSettings.resolution ?? 0.25;
11043
11363
  }
11044
- // Convenience getters for bloom settings
11045
- get bloomEnabled() {
11046
- return this.settings?.bloom?.enabled ?? true;
11364
+ get maxSamples() {
11365
+ return this.volumetricSettings.maxSamples ?? 32;
11047
11366
  }
11048
- get bloomIntensity() {
11049
- return this.settings?.bloom?.intensity ?? 1;
11367
+ get blurRadius() {
11368
+ return this.volumetricSettings.blurRadius ?? 4;
11050
11369
  }
11051
- get bloomRadius() {
11052
- return this.settings?.bloom?.radius ?? 5;
11370
+ get fogDensity() {
11371
+ return this.volumetricSettings.density ?? this.volumetricSettings.densityMultiplier ?? 0.5;
11372
+ }
11373
+ get scatterStrength() {
11374
+ return this.volumetricSettings.scatterStrength ?? 1;
11375
+ }
11376
+ get maxDistance() {
11377
+ return this.volumetricSettings.maxDistance ?? 20;
11378
+ }
11379
+ get heightRange() {
11380
+ return this.volumetricSettings.heightRange ?? [-5, 20];
11381
+ }
11382
+ get shadowsEnabled() {
11383
+ return this.volumetricSettings.shadowsEnabled ?? true;
11384
+ }
11385
+ get noiseStrength() {
11386
+ return this.volumetricSettings.noiseStrength ?? 1;
11387
+ }
11388
+ // 0 = uniform fog, 1 = full noise
11389
+ get noiseAnimated() {
11390
+ return this.volumetricSettings.noiseAnimated ?? true;
11391
+ }
11392
+ get noiseScale() {
11393
+ return this.volumetricSettings.noiseScale ?? 0.25;
11394
+ }
11395
+ // Noise frequency (higher = finer detail)
11396
+ get mainLightScatter() {
11397
+ return this.volumetricSettings.mainLightScatter ?? 1;
11398
+ }
11399
+ // Scatter when camera in light
11400
+ get mainLightScatterDark() {
11401
+ return this.volumetricSettings.mainLightScatterDark ?? 3;
11402
+ }
11403
+ // Scatter when camera in shadow
11404
+ get mainLightSaturation() {
11405
+ return this.volumetricSettings.mainLightSaturation ?? 1;
11406
+ }
11407
+ // Max brightness cap
11408
+ // Brightness-based attenuation (fog less visible over bright surfaces)
11409
+ get brightnessThreshold() {
11410
+ return this.volumetricSettings.brightnessThreshold ?? 1;
11411
+ }
11412
+ // Scene luminance where fog starts fading
11413
+ get minVisibility() {
11414
+ return this.volumetricSettings.minVisibility ?? 0.15;
11415
+ }
11416
+ // Minimum fog visibility over very bright surfaces
11417
+ get skyBrightness() {
11418
+ return this.volumetricSettings.skyBrightness ?? 5;
11419
+ }
11420
+ // Virtual brightness for sky (far depth)
11421
+ // Debug mode: 0=normal, 1=depth, 2=ray dir, 3=noise, 4=viewDir.z, 5=worldPos, 6=accum, 7=light dist, 8=light pos
11422
+ get debugMode() {
11423
+ return this.volumetricSettings.debug ?? 0;
11053
11424
  }
11054
- /**
11055
- * Set the input texture (HDR image from LightingPass)
11056
- * @param {Texture} texture - Input HDR texture
11057
- */
11058
11425
  setInputTexture(texture) {
11059
- if (this.inputTexture !== texture) {
11060
- this.inputTexture = texture;
11061
- this._needsRebuild = true;
11062
- }
11426
+ this.inputTexture = texture;
11063
11427
  }
11064
- /**
11065
- * Set the bloom texture (from BloomPass)
11066
- * @param {Object} bloomTexture - Bloom texture with mip levels
11067
- */
11068
- setBloomTexture(bloomTexture) {
11069
- if (this.bloomTexture !== bloomTexture) {
11070
- this.bloomTexture = bloomTexture;
11071
- this._needsRebuild = true;
11072
- }
11428
+ setGBuffer(gbuffer) {
11429
+ this.gbuffer = gbuffer;
11073
11430
  }
11074
- /**
11075
- * Set the noise texture for dithering
11076
- * @param {Texture} texture - Noise texture (blue noise or bayer dither)
11077
- * @param {number} size - Texture size
11078
- * @param {boolean} animated - Whether to animate noise offset each frame
11079
- */
11080
- setNoise(texture, size = 64, animated = true) {
11081
- this.noiseTexture = texture;
11082
- this.noiseSize = size;
11083
- this.noiseAnimated = animated;
11084
- this._needsRebuild = true;
11431
+ setShadowPass(shadowPass) {
11432
+ this.shadowPass = shadowPass;
11085
11433
  }
11086
- /**
11087
- * Set the GUI canvas for overlay rendering
11088
- * @param {HTMLCanvasElement} canvas - 2D canvas with GUI content
11089
- */
11090
- setGuiCanvas(canvas) {
11091
- this.guiCanvas = canvas;
11434
+ setLightingPass(lightingPass) {
11435
+ this.lightingPass = lightingPass;
11436
+ }
11437
+ getOutputTexture() {
11438
+ return this.outputTexture;
11439
+ }
11440
+ // Unused setters (kept for API compatibility)
11441
+ setHiZPass() {
11092
11442
  }
11093
11443
  async _init() {
11094
11444
  const { device } = this.engine;
11095
- const dummyTexture = device.createTexture({
11096
- label: "Dummy Bloom Texture",
11097
- size: [1, 1, 1],
11098
- format: "rgba16float",
11099
- mipLevelCount: 1,
11100
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
11101
- });
11102
- device.queue.writeTexture(
11103
- { texture: dummyTexture },
11104
- new Float32Array([0, 0, 0, 0]).buffer,
11105
- { bytesPerRow: 8 },
11106
- { width: 1, height: 1 }
11107
- );
11108
- const dummySampler = device.createSampler({
11109
- label: "Dummy Bloom Sampler",
11445
+ this.linearSampler = device.createSampler({
11446
+ label: "Volumetric Linear Sampler",
11110
11447
  minFilter: "linear",
11111
- magFilter: "linear"
11448
+ magFilter: "linear",
11449
+ addressModeU: "clamp-to-edge",
11450
+ addressModeV: "clamp-to-edge"
11112
11451
  });
11113
- this.dummyBloomTexture = {
11114
- texture: dummyTexture,
11115
- view: dummyTexture.createView(),
11116
- sampler: dummySampler,
11117
- mipCount: 1
11118
- };
11119
- this.guiSampler = device.createSampler({
11120
- label: "GUI Sampler",
11121
- minFilter: "linear",
11122
- magFilter: "linear"
11452
+ this.fallbackShadowSampler = device.createSampler({
11453
+ label: "Volumetric Fallback Shadow Sampler",
11454
+ compare: "less"
11123
11455
  });
11124
- const dummyGuiTexture = device.createTexture({
11125
- label: "Dummy GUI Texture",
11456
+ this.fallbackCascadeShadowMap = device.createTexture({
11457
+ label: "Volumetric Fallback Cascade Shadow",
11458
+ size: [1, 1, 3],
11459
+ format: "depth32float",
11460
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT
11461
+ });
11462
+ const identityMatrix = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
11463
+ const matrixData = new Float32Array(16 * 3);
11464
+ for (let i = 0; i < 3; i++) matrixData.set(identityMatrix, i * 16);
11465
+ this.fallbackCascadeMatrices = device.createBuffer({
11466
+ label: "Volumetric Fallback Cascade Matrices",
11467
+ size: 16 * 4 * 3,
11468
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
11469
+ });
11470
+ device.queue.writeBuffer(this.fallbackCascadeMatrices, 0, matrixData);
11471
+ this.fallbackLightsBuffer = device.createBuffer({
11472
+ label: "Volumetric Fallback Lights",
11473
+ size: 768 * 96,
11474
+ // MAX_LIGHTS * 96 bytes per light
11475
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
11476
+ });
11477
+ this.fallbackSpotShadowAtlas = device.createTexture({
11478
+ label: "Volumetric Fallback Spot Shadow Atlas",
11126
11479
  size: [1, 1, 1],
11127
- format: "rgba8unorm",
11128
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
11480
+ format: "depth32float",
11481
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT
11129
11482
  });
11130
- device.queue.writeTexture(
11131
- { texture: dummyGuiTexture },
11132
- new Uint8Array([0, 0, 0, 0]),
11133
- { bytesPerRow: 4 },
11134
- { width: 1, height: 1 }
11135
- );
11136
- this.dummyGuiTexture = {
11137
- texture: dummyGuiTexture,
11138
- view: dummyGuiTexture.createView(),
11139
- sampler: this.guiSampler
11140
- };
11483
+ const spotMatrixData = new Float32Array(16 * 16);
11484
+ for (let i = 0; i < 16; i++) spotMatrixData.set(identityMatrix, i * 16);
11485
+ this.fallbackSpotMatrices = device.createBuffer({
11486
+ label: "Volumetric Fallback Spot Matrices",
11487
+ size: 16 * 4 * 16,
11488
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
11489
+ });
11490
+ device.queue.writeBuffer(this.fallbackSpotMatrices, 0, spotMatrixData);
11491
+ await this._createResources(this.engine.canvas.width, this.engine.canvas.height);
11141
11492
  }
11142
- /**
11143
- * Build or rebuild the pipeline
11144
- */
11145
- async _buildPipeline() {
11146
- if (!this.inputTexture) {
11147
- return;
11148
- }
11149
- const textures = [this.inputTexture];
11150
- if (this.noiseTexture) {
11151
- textures.push(this.noiseTexture);
11152
- }
11153
- const effectiveBloomTexture = this.bloomTexture || this.dummyBloomTexture;
11154
- textures.push(effectiveBloomTexture);
11155
- const effectiveGuiTexture = this.guiTexture || this.dummyGuiTexture;
11156
- textures.push(effectiveGuiTexture);
11157
- const hasBloom = this.bloomTexture && this.bloomEnabled;
11493
+ async _createResources(width, height) {
11494
+ const { device } = this.engine;
11495
+ this.canvasWidth = width;
11496
+ this.canvasHeight = height;
11497
+ this.renderWidth = Math.max(1, Math.floor(width * this.resolution));
11498
+ this.renderHeight = Math.max(1, Math.floor(height * this.resolution));
11499
+ this._destroyTextures();
11500
+ this.raymarchTexture = this._create2DTexture("Raymarch Output", this.renderWidth, this.renderHeight);
11501
+ this.blurTempTexture = this._create2DTexture("Blur Temp", this.renderWidth, this.renderHeight);
11502
+ this.blurredTexture = this._create2DTexture("Blurred Fog", this.renderWidth, this.renderHeight);
11503
+ this.outputTexture = await Texture.renderTarget(this.engine, "rgba16float", width, height);
11504
+ this.raymarchUniformBuffer = this._createUniformBuffer("Raymarch Uniforms", 256);
11505
+ this.blurHUniformBuffer = this._createUniformBuffer("Blur H Uniforms", 32);
11506
+ this.blurVUniformBuffer = this._createUniformBuffer("Blur V Uniforms", 32);
11507
+ this.compositeUniformBuffer = this._createUniformBuffer("Composite Uniforms", 48);
11508
+ await this._createPipelines();
11509
+ }
11510
+ _create2DTexture(label, width, height) {
11511
+ const texture = this.engine.device.createTexture({
11512
+ label,
11513
+ size: [width, height, 1],
11514
+ format: "rgba16float",
11515
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
11516
+ });
11517
+ return { texture, view: texture.createView(), width, height };
11518
+ }
11519
+ _createUniformBuffer(label, size) {
11520
+ return this.engine.device.createBuffer({
11521
+ label,
11522
+ size,
11523
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
11524
+ });
11525
+ }
11526
+ async _createPipelines() {
11527
+ const { device } = this.engine;
11528
+ const raymarchModule = device.createShaderModule({
11529
+ label: "Volumetric Raymarch Shader",
11530
+ code: volumetric_raymarch_default
11531
+ });
11532
+ this.raymarchBGL = device.createBindGroupLayout({
11533
+ label: "Volumetric Raymarch BGL",
11534
+ entries: [
11535
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT | GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
11536
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
11537
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth", viewDimension: "2d-array" } },
11538
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
11539
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
11540
+ // cascade matrices
11541
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
11542
+ // lights
11543
+ { binding: 6, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
11544
+ // spot shadow atlas
11545
+ { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } }
11546
+ // spot matrices
11547
+ ]
11548
+ });
11549
+ this.raymarchPipeline = await device.createRenderPipelineAsync({
11550
+ label: "Volumetric Raymarch Pipeline",
11551
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.raymarchBGL] }),
11552
+ vertex: { module: raymarchModule, entryPoint: "vertexMain" },
11553
+ fragment: {
11554
+ module: raymarchModule,
11555
+ entryPoint: "fragmentMain",
11556
+ targets: [{ format: "rgba16float" }]
11557
+ },
11558
+ primitive: { topology: "triangle-list" }
11559
+ });
11560
+ const blurModule = device.createShaderModule({
11561
+ label: "Volumetric Blur Shader",
11562
+ code: volumetric_blur_default
11563
+ });
11564
+ this.blurBGL = device.createBindGroupLayout({
11565
+ label: "Volumetric Blur BGL",
11566
+ entries: [
11567
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT | GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
11568
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
11569
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } }
11570
+ ]
11571
+ });
11572
+ const blurPipelineDesc = {
11573
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.blurBGL] }),
11574
+ vertex: { module: blurModule, entryPoint: "vertexMain" },
11575
+ fragment: {
11576
+ module: blurModule,
11577
+ entryPoint: "fragmentMain",
11578
+ targets: [{ format: "rgba16float" }]
11579
+ },
11580
+ primitive: { topology: "triangle-list" }
11581
+ };
11582
+ this.blurHPipeline = await device.createRenderPipelineAsync({ ...blurPipelineDesc, label: "Volumetric Blur H" });
11583
+ this.blurVPipeline = await device.createRenderPipelineAsync({ ...blurPipelineDesc, label: "Volumetric Blur V" });
11584
+ const compositeModule = device.createShaderModule({
11585
+ label: "Volumetric Composite Shader",
11586
+ code: volumetric_composite_default
11587
+ });
11588
+ this.compositeBGL = device.createBindGroupLayout({
11589
+ label: "Volumetric Composite BGL",
11590
+ entries: [
11591
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT | GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
11592
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
11593
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
11594
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
11595
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } }
11596
+ ]
11597
+ });
11598
+ this.compositePipeline = await device.createRenderPipelineAsync({
11599
+ label: "Volumetric Composite Pipeline",
11600
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.compositeBGL] }),
11601
+ vertex: { module: compositeModule, entryPoint: "vertexMain" },
11602
+ fragment: {
11603
+ module: compositeModule,
11604
+ entryPoint: "fragmentMain",
11605
+ targets: [{ format: "rgba16float" }]
11606
+ },
11607
+ primitive: { topology: "triangle-list" }
11608
+ });
11609
+ }
11610
+ async _execute(context) {
11611
+ if (!this.isVolumetricEnabled) return;
11612
+ if (!this.inputTexture || !this.gbuffer) return;
11613
+ const { device } = this.engine;
11614
+ const { camera, mainLight, lights } = context;
11615
+ const time = performance.now() / 1e3;
11616
+ this._updateAdaptiveScatter(camera, mainLight, time);
11617
+ const lightCount = lights?.length ?? this.lightingPass?.lightCount ?? 0;
11618
+ const commandEncoder = device.createCommandEncoder({ label: "Volumetric Fog Pass" });
11619
+ this._updateRaymarchUniforms(camera, mainLight, time, lightCount);
11620
+ const raymarchBindGroup = this._createRaymarchBindGroup();
11621
+ if (raymarchBindGroup) {
11622
+ const raymarchPass = commandEncoder.beginRenderPass({
11623
+ label: "Volumetric Raymarch",
11624
+ colorAttachments: [{
11625
+ view: this.raymarchTexture.view,
11626
+ loadOp: "clear",
11627
+ storeOp: "store",
11628
+ clearValue: { r: 0, g: 0, b: 0, a: 0 }
11629
+ }]
11630
+ });
11631
+ raymarchPass.setPipeline(this.raymarchPipeline);
11632
+ raymarchPass.setBindGroup(0, raymarchBindGroup);
11633
+ raymarchPass.draw(3);
11634
+ raymarchPass.end();
11635
+ }
11636
+ this._updateBlurUniforms(this.blurHUniformBuffer, 1, 0);
11637
+ this._updateBlurUniforms(this.blurVUniformBuffer, 0, 1);
11638
+ const blurHBindGroup = this._createBlurBindGroup(this.raymarchTexture, this.blurHUniformBuffer);
11639
+ const blurHPass = commandEncoder.beginRenderPass({
11640
+ label: "Volumetric Blur H",
11641
+ colorAttachments: [{
11642
+ view: this.blurTempTexture.view,
11643
+ loadOp: "clear",
11644
+ storeOp: "store",
11645
+ clearValue: { r: 0, g: 0, b: 0, a: 0 }
11646
+ }]
11647
+ });
11648
+ blurHPass.setPipeline(this.blurHPipeline);
11649
+ blurHPass.setBindGroup(0, blurHBindGroup);
11650
+ blurHPass.draw(3);
11651
+ blurHPass.end();
11652
+ const blurVBindGroup = this._createBlurBindGroup(this.blurTempTexture, this.blurVUniformBuffer);
11653
+ const blurVPass = commandEncoder.beginRenderPass({
11654
+ label: "Volumetric Blur V",
11655
+ colorAttachments: [{
11656
+ view: this.blurredTexture.view,
11657
+ loadOp: "clear",
11658
+ storeOp: "store",
11659
+ clearValue: { r: 0, g: 0, b: 0, a: 0 }
11660
+ }]
11661
+ });
11662
+ blurVPass.setPipeline(this.blurVPipeline);
11663
+ blurVPass.setBindGroup(0, blurVBindGroup);
11664
+ blurVPass.draw(3);
11665
+ blurVPass.end();
11666
+ this._updateCompositeUniforms();
11667
+ const compositeBindGroup = this._createCompositeBindGroup();
11668
+ if (compositeBindGroup) {
11669
+ const compositePass = commandEncoder.beginRenderPass({
11670
+ label: "Volumetric Composite",
11671
+ colorAttachments: [{
11672
+ view: this.outputTexture.view,
11673
+ loadOp: "clear",
11674
+ storeOp: "store",
11675
+ clearValue: { r: 0, g: 0, b: 0, a: 1 }
11676
+ }]
11677
+ });
11678
+ compositePass.setPipeline(this.compositePipeline);
11679
+ compositePass.setBindGroup(0, compositeBindGroup);
11680
+ compositePass.draw(3);
11681
+ compositePass.end();
11682
+ }
11683
+ device.queue.submit([commandEncoder.finish()]);
11684
+ }
11685
+ /**
11686
+ * Update adaptive main light scatter based on camera shadow state and sky visibility
11687
+ * Smoothly transitions between light/dark scatter values
11688
+ * Only uses dark scatter if camera is in shadow AND there's something overhead (no sky)
11689
+ */
11690
+ _updateAdaptiveScatter(camera, mainLight, currentTime) {
11691
+ if (this._currentMainLightScatter === null) {
11692
+ this._currentMainLightScatter = this.mainLightScatter;
11693
+ }
11694
+ const deltaTime = this._lastUpdateTime > 0 ? currentTime - this._lastUpdateTime : 0.016;
11695
+ this._lastUpdateTime = currentTime;
11696
+ let cameraInShadow = false;
11697
+ if (this.shadowPass && this.shadowsEnabled && mainLight?.enabled !== false) {
11698
+ const cameraPos = camera.position || [0, 0, 0];
11699
+ cameraInShadow = this._isCameraInShadow(cameraPos);
11700
+ if (!this._skyCheckPending && currentTime - this._lastSkyCheckTime > 0.5) {
11701
+ this._checkSkyVisibility(cameraPos);
11702
+ }
11703
+ }
11704
+ const inDarkArea = cameraInShadow && !this._skyVisible;
11705
+ const targetShadowState = inDarkArea ? 1 : 0;
11706
+ const transitionSpeed = inDarkArea ? 1 / 5 : 1 / 1;
11707
+ const t = 1 - Math.exp(-transitionSpeed * deltaTime * 3);
11708
+ this._cameraInShadowSmooth += (targetShadowState - this._cameraInShadowSmooth) * t;
11709
+ this._cameraInShadowSmooth = Math.max(0, Math.min(1, this._cameraInShadowSmooth));
11710
+ const lightScatter = this.mainLightScatter;
11711
+ const darkScatter = this.mainLightScatterDark;
11712
+ this._currentMainLightScatter = lightScatter + (darkScatter - lightScatter) * this._cameraInShadowSmooth;
11713
+ }
11714
+ /**
11715
+ * Check if sky is visible above the camera using raycaster
11716
+ * This is async and updates _skyVisible when complete
11717
+ */
11718
+ _checkSkyVisibility(cameraPos) {
11719
+ const raycaster = this.engine?.raycaster;
11720
+ if (!raycaster) return;
11721
+ this._skyCheckPending = true;
11722
+ this._lastSkyCheckTime = this._lastUpdateTime;
11723
+ const skyCheckDistance = this.volumetricSettings.skyCheckDistance ?? 100;
11724
+ const debugSkyCheck = this.volumetricSettings.debugSkyCheck ?? false;
11725
+ raycaster.cast(
11726
+ cameraPos,
11727
+ [0, 1, 0],
11728
+ // Straight up
11729
+ skyCheckDistance,
11730
+ (result) => {
11731
+ this._skyVisible;
11732
+ this._skyVisible = !result.hit;
11733
+ this._skyCheckPending = false;
11734
+ if (debugSkyCheck) {
11735
+ const pos = cameraPos.map((v) => v.toFixed(1)).join(", ");
11736
+ if (result.hit) {
11737
+ console.log(`Sky check from [${pos}]: HIT ${result.meshName || result.candidateId} at dist=${result.distance?.toFixed(1)}`);
11738
+ } else {
11739
+ console.log(`Sky check from [${pos}]: NO HIT (sky visible)`);
11740
+ }
11741
+ }
11742
+ },
11743
+ { backfaces: true, debug: debugSkyCheck }
11744
+ // Need backfaces to hit ceilings from below
11745
+ );
11746
+ }
11747
+ /**
11748
+ * Check if camera position is in shadow
11749
+ * Uses the shadow pass's isCameraInShadow method if available,
11750
+ * otherwise falls back to checking fog height bounds
11751
+ */
11752
+ _isCameraInShadow(cameraPos) {
11753
+ if (typeof this.shadowPass?.isCameraInShadow === "function") {
11754
+ return this.shadowPass.isCameraInShadow(cameraPos);
11755
+ }
11756
+ const heightRange = this.heightRange;
11757
+ const fogBottom = heightRange[0];
11758
+ const fogTop = heightRange[1];
11759
+ const lowerThird = fogBottom + (fogTop - fogBottom) * 0.33;
11760
+ if (cameraPos[1] < lowerThird) {
11761
+ return true;
11762
+ }
11763
+ const mainLightDir = this.engine?.settings?.mainLight?.direction;
11764
+ if (mainLightDir) {
11765
+ const sunAngle = mainLightDir[1];
11766
+ if (sunAngle > 0.7) {
11767
+ return true;
11768
+ }
11769
+ }
11770
+ return false;
11771
+ }
11772
+ _updateRaymarchUniforms(camera, mainLight, time, lightCount) {
11773
+ const { device } = this.engine;
11774
+ if (!camera.iProj || !camera.iView) {
11775
+ console.warn("VolumetricFogPass: Camera missing iProj or iView matrices");
11776
+ return;
11777
+ }
11778
+ const invProj = camera.iProj;
11779
+ const invView = camera.iView;
11780
+ const cameraPos = camera.position || [0, 0, 0];
11781
+ const mainLightEnabled = mainLight?.enabled !== false;
11782
+ const mainLightDir = mainLight?.direction ?? [-1, 1, -0.5];
11783
+ const mainLightColor = mainLight?.color ?? [1, 0.95, 0.9];
11784
+ const mainLightIntensity = mainLightEnabled ? mainLight?.intensity ?? 1 : 0;
11785
+ const fogColor = this.fogSettings.color ?? [0.8, 0.85, 0.9];
11786
+ const heightFade = this.heightRange;
11787
+ const shadowsEnabled = this.shadowsEnabled && this.shadowPass != null;
11788
+ const data = new Float32Array(64);
11789
+ let offset = 0;
11790
+ data.set(invProj, offset);
11791
+ offset += 16;
11792
+ data.set(invView, offset);
11793
+ offset += 16;
11794
+ data[offset++] = cameraPos[0];
11795
+ data[offset++] = cameraPos[1];
11796
+ data[offset++] = cameraPos[2];
11797
+ data[offset++] = camera.near ?? 0.1;
11798
+ data[offset++] = camera.far ?? 1e3;
11799
+ data[offset++] = this.maxSamples;
11800
+ data[offset++] = time;
11801
+ data[offset++] = this.fogDensity;
11802
+ data[offset++] = fogColor[0];
11803
+ data[offset++] = fogColor[1];
11804
+ data[offset++] = fogColor[2];
11805
+ data[offset++] = shadowsEnabled ? 1 : 0;
11806
+ data[offset++] = mainLightDir[0];
11807
+ data[offset++] = mainLightDir[1];
11808
+ data[offset++] = mainLightDir[2];
11809
+ data[offset++] = mainLightIntensity;
11810
+ data[offset++] = mainLightColor[0];
11811
+ data[offset++] = mainLightColor[1];
11812
+ data[offset++] = mainLightColor[2];
11813
+ data[offset++] = this.scatterStrength;
11814
+ data[offset++] = heightFade[0];
11815
+ data[offset++] = heightFade[1];
11816
+ data[offset++] = this.maxDistance;
11817
+ data[offset++] = lightCount;
11818
+ data[offset++] = this.debugMode;
11819
+ data[offset++] = this.noiseStrength;
11820
+ data[offset++] = this.noiseAnimated ? 1 : 0;
11821
+ data[offset++] = this._currentMainLightScatter;
11822
+ data[offset++] = this.noiseScale;
11823
+ data[offset++] = this.mainLightSaturation;
11824
+ device.queue.writeBuffer(this.raymarchUniformBuffer, 0, data);
11825
+ }
11826
+ _updateBlurUniforms(buffer, dirX, dirY) {
11827
+ const data = new Float32Array([
11828
+ dirX,
11829
+ dirY,
11830
+ 1 / this.renderWidth,
11831
+ 1 / this.renderHeight,
11832
+ this.blurRadius,
11833
+ 0,
11834
+ 0,
11835
+ 0
11836
+ ]);
11837
+ this.engine.device.queue.writeBuffer(buffer, 0, data);
11838
+ }
11839
+ _updateCompositeUniforms() {
11840
+ const data = new Float32Array([
11841
+ this.canvasWidth,
11842
+ this.canvasHeight,
11843
+ this.renderWidth,
11844
+ this.renderHeight,
11845
+ 1 / this.canvasWidth,
11846
+ 1 / this.canvasHeight,
11847
+ this.brightnessThreshold,
11848
+ this.minVisibility,
11849
+ this.skyBrightness,
11850
+ 0,
11851
+ // skyBrightness + padding
11852
+ 0,
11853
+ 0
11854
+ // extra padding to 48 bytes
11855
+ ]);
11856
+ this.engine.device.queue.writeBuffer(this.compositeUniformBuffer, 0, data);
11857
+ }
11858
+ _createRaymarchBindGroup() {
11859
+ const { device } = this.engine;
11860
+ const depthTexture = this.gbuffer?.depth;
11861
+ if (!depthTexture) return null;
11862
+ const cascadeShadows = this.shadowPass?.getShadowMap?.() ?? this.fallbackCascadeShadowMap;
11863
+ const cascadeMatrices = this.shadowPass?.getCascadeMatricesBuffer?.() ?? this.fallbackCascadeMatrices;
11864
+ const shadowSampler = this.shadowPass?.getShadowSampler?.() ?? this.fallbackShadowSampler;
11865
+ const lightsBuffer = this.lightingPass?.getLightBuffer?.() ?? this.fallbackLightsBuffer;
11866
+ const spotShadowAtlas = this.shadowPass?.getSpotShadowAtlasView?.() ?? this.fallbackSpotShadowAtlas.createView();
11867
+ const spotMatrices = this.shadowPass?.getSpotMatricesBuffer?.() ?? this.fallbackSpotMatrices;
11868
+ return device.createBindGroup({
11869
+ label: "Volumetric Raymarch Bind Group",
11870
+ layout: this.raymarchBGL,
11871
+ entries: [
11872
+ { binding: 0, resource: { buffer: this.raymarchUniformBuffer } },
11873
+ { binding: 1, resource: depthTexture.texture.createView({ aspect: "depth-only" }) },
11874
+ { binding: 2, resource: cascadeShadows.createView({ dimension: "2d-array", aspect: "depth-only" }) },
11875
+ { binding: 3, resource: shadowSampler },
11876
+ { binding: 4, resource: { buffer: cascadeMatrices } },
11877
+ { binding: 5, resource: { buffer: lightsBuffer } },
11878
+ { binding: 6, resource: spotShadowAtlas },
11879
+ { binding: 7, resource: { buffer: spotMatrices } }
11880
+ ]
11881
+ });
11882
+ }
11883
+ _createBlurBindGroup(inputTexture, uniformBuffer) {
11884
+ return this.engine.device.createBindGroup({
11885
+ label: "Volumetric Blur Bind Group",
11886
+ layout: this.blurBGL,
11887
+ entries: [
11888
+ { binding: 0, resource: { buffer: uniformBuffer } },
11889
+ { binding: 1, resource: inputTexture.view },
11890
+ { binding: 2, resource: this.linearSampler }
11891
+ ]
11892
+ });
11893
+ }
11894
+ _createCompositeBindGroup() {
11895
+ if (!this.inputTexture) return null;
11896
+ const depthTexture = this.gbuffer?.depth;
11897
+ if (!depthTexture) return null;
11898
+ return this.engine.device.createBindGroup({
11899
+ label: "Volumetric Composite Bind Group",
11900
+ layout: this.compositeBGL,
11901
+ entries: [
11902
+ { binding: 0, resource: { buffer: this.compositeUniformBuffer } },
11903
+ { binding: 1, resource: this.inputTexture.view },
11904
+ { binding: 2, resource: this.blurredTexture.view },
11905
+ { binding: 3, resource: this.linearSampler },
11906
+ { binding: 4, resource: depthTexture.texture.createView({ aspect: "depth-only" }) }
11907
+ ]
11908
+ });
11909
+ }
11910
+ _destroyTextures() {
11911
+ const textures = [this.raymarchTexture, this.blurTempTexture, this.blurredTexture, this.outputTexture];
11912
+ for (const tex of textures) {
11913
+ if (tex?.texture) tex.texture.destroy();
11914
+ }
11915
+ this.raymarchTexture = null;
11916
+ this.blurTempTexture = null;
11917
+ this.blurredTexture = null;
11918
+ this.outputTexture = null;
11919
+ }
11920
+ async _resize(width, height) {
11921
+ await this._createResources(width, height);
11922
+ }
11923
+ _destroy() {
11924
+ this._destroyTextures();
11925
+ const buffers = [this.raymarchUniformBuffer, this.blurHUniformBuffer, this.blurVUniformBuffer, this.compositeUniformBuffer];
11926
+ for (const buf of buffers) {
11927
+ if (buf) buf.destroy();
11928
+ }
11929
+ this.raymarchPipeline = null;
11930
+ this.blurHPipeline = null;
11931
+ this.blurVPipeline = null;
11932
+ this.compositePipeline = null;
11933
+ }
11934
+ }
11935
+ var postproc_default = "struct VertexOutput {\n @builtin(position) position : vec4<f32>,\n @location(0) uv : vec2<f32>,\n}\n\nstruct Uniforms {\n canvasSize: vec2f,\n noiseParams: vec4f, \n ditherParams: vec4f, \n bloomParams: vec4f, \n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var inputTexture : texture_2d<f32>;\n@group(0) @binding(2) var inputSampler : sampler;\n@group(0) @binding(3) var noiseTexture : texture_2d<f32>;\n@group(0) @binding(4) var noiseSampler : sampler;\n@group(0) @binding(5) var bloomTexture : texture_2d<f32>;\n@group(0) @binding(6) var bloomSampler : sampler;\n@group(0) @binding(7) var guiTexture : texture_2d<f32>;\n@group(0) @binding(8) var guiSampler : sampler;\n\nconst FXAA_EDGE_THRESHOLD: f32 = 0.125; \nconst FXAA_EDGE_THRESHOLD_MIN: f32 = 0.0156; \nconst FXAA_SUBPIX_QUALITY: f32 = 1.0; \n\nfn sampleNoise(screenPos: vec2f) -> f32 {\n let noiseSize = i32(uniforms.noiseParams.x);\n let noiseOffsetX = i32(uniforms.noiseParams.y * f32(noiseSize));\n let noiseOffsetY = i32(uniforms.noiseParams.z * f32(noiseSize));\n\n let texCoord = vec2i(\n (i32(screenPos.x) + noiseOffsetX) % noiseSize,\n (i32(screenPos.y) + noiseOffsetY) % noiseSize\n );\n return textureLoad(noiseTexture, texCoord, 0).r;\n}\n\nfn rgb2luma(rgb: vec3f) -> f32 {\n return dot(rgb, vec3f(0.299, 0.587, 0.114));\n}\n\nfn loadPixel(coord: vec2i) -> vec3f {\n let size = vec2i(textureDimensions(inputTexture, 0));\n let clampedCoord = clamp(coord, vec2i(0), size - vec2i(1));\n return textureLoad(inputTexture, clampedCoord, 0).rgb;\n}\n\nfn sampleBilinear(uv: vec2f) -> vec3f {\n let texSize = vec2f(textureDimensions(inputTexture, 0));\n let texelPos = uv * texSize - 0.5;\n let baseCoord = vec2i(floor(texelPos));\n let frac = fract(texelPos);\n\n let c00 = loadPixel(baseCoord);\n let c10 = loadPixel(baseCoord + vec2i(1, 0));\n let c01 = loadPixel(baseCoord + vec2i(0, 1));\n let c11 = loadPixel(baseCoord + vec2i(1, 1));\n\n let c0 = mix(c00, c10, frac.x);\n let c1 = mix(c01, c11, frac.x);\n return mix(c0, c1, frac.y);\n}\n\nfn fxaa(uv: vec2f) -> vec3f {\n let texSize = vec2f(textureDimensions(inputTexture, 0));\n let pixelCoord = vec2i(uv * texSize);\n\n \n let rgbM = loadPixel(pixelCoord);\n let rgbN = loadPixel(pixelCoord + vec2i(0, -1));\n let rgbS = loadPixel(pixelCoord + vec2i(0, 1));\n let rgbW = loadPixel(pixelCoord + vec2i(-1, 0));\n let rgbE = loadPixel(pixelCoord + vec2i(1, 0));\n let rgbNW = loadPixel(pixelCoord + vec2i(-1, -1));\n let rgbNE = loadPixel(pixelCoord + vec2i(1, -1));\n let rgbSW = loadPixel(pixelCoord + vec2i(-1, 1));\n let rgbSE = loadPixel(pixelCoord + vec2i(1, 1));\n\n \n let lumaM = rgb2luma(rgbM);\n let lumaN = rgb2luma(rgbN);\n let lumaS = rgb2luma(rgbS);\n let lumaW = rgb2luma(rgbW);\n let lumaE = rgb2luma(rgbE);\n let lumaNW = rgb2luma(rgbNW);\n let lumaNE = rgb2luma(rgbNE);\n let lumaSW = rgb2luma(rgbSW);\n let lumaSE = rgb2luma(rgbSE);\n\n \n let lumaMin = min(lumaM, min(min(lumaN, lumaS), min(lumaW, lumaE)));\n let lumaMax = max(lumaM, max(max(lumaN, lumaS), max(lumaW, lumaE)));\n let lumaRange = lumaMax - lumaMin;\n\n \n let isEdge = lumaRange >= max(FXAA_EDGE_THRESHOLD_MIN, lumaMax * FXAA_EDGE_THRESHOLD);\n\n \n let lumaL = (lumaN + lumaS + lumaW + lumaE) * 0.25;\n let rangeL = abs(lumaL - lumaM);\n var blendL = max(0.0, (rangeL / max(lumaRange, 0.0001)) - 0.25) * (1.0 / 0.75);\n blendL = min(1.0, blendL) * blendL * FXAA_SUBPIX_QUALITY;\n\n \n let edgeHorz = abs((lumaNW + lumaNE) - 2.0 * lumaN) +\n 2.0 * abs((lumaW + lumaE) - 2.0 * lumaM) +\n abs((lumaSW + lumaSE) - 2.0 * lumaS);\n let edgeVert = abs((lumaNW + lumaSW) - 2.0 * lumaW) +\n 2.0 * abs((lumaN + lumaS) - 2.0 * lumaM) +\n abs((lumaNE + lumaSE) - 2.0 * lumaE);\n let isHorizontal = edgeHorz >= edgeVert;\n\n \n let luma1 = select(lumaE, lumaS, isHorizontal);\n let luma2 = select(lumaW, lumaN, isHorizontal);\n let gradient1 = abs(luma1 - lumaM);\n let gradient2 = abs(luma2 - lumaM);\n let is1Steepest = gradient1 >= gradient2;\n let gradientScaled = 0.25 * max(gradient1, gradient2);\n\n \n let stepSign = select(-1.0, 1.0, is1Steepest);\n let lumaLocalAverage = 0.5 * (select(luma2, luma1, is1Steepest) + lumaM);\n\n \n let searchDir = select(vec2i(0, 1), vec2i(1, 0), isHorizontal);\n\n let luma1_1 = rgb2luma(loadPixel(pixelCoord - searchDir)) - lumaLocalAverage;\n let luma2_1 = rgb2luma(loadPixel(pixelCoord + searchDir)) - lumaLocalAverage;\n let luma1_2 = rgb2luma(loadPixel(pixelCoord - searchDir * 2)) - lumaLocalAverage;\n let luma2_2 = rgb2luma(loadPixel(pixelCoord + searchDir * 2)) - lumaLocalAverage;\n let luma1_3 = rgb2luma(loadPixel(pixelCoord - searchDir * 3)) - lumaLocalAverage;\n let luma2_3 = rgb2luma(loadPixel(pixelCoord + searchDir * 3)) - lumaLocalAverage;\n let luma1_4 = rgb2luma(loadPixel(pixelCoord - searchDir * 4)) - lumaLocalAverage;\n let luma2_4 = rgb2luma(loadPixel(pixelCoord + searchDir * 4)) - lumaLocalAverage;\n\n \n let reached1_1 = abs(luma1_1) >= gradientScaled;\n let reached1_2 = abs(luma1_2) >= gradientScaled;\n let reached1_3 = abs(luma1_3) >= gradientScaled;\n let reached2_1 = abs(luma2_1) >= gradientScaled;\n let reached2_2 = abs(luma2_2) >= gradientScaled;\n let reached2_3 = abs(luma2_3) >= gradientScaled;\n\n \n let dist1 = select(select(select(4.0, 3.0, reached1_3), 2.0, reached1_2), 1.0, reached1_1);\n let dist2 = select(select(select(4.0, 3.0, reached2_3), 2.0, reached2_2), 1.0, reached2_1);\n\n \n let lumaEnd1 = select(select(select(luma1_4, luma1_3, reached1_3), luma1_2, reached1_2), luma1_1, reached1_1);\n let lumaEnd2 = select(select(select(luma2_4, luma2_3, reached2_3), luma2_2, reached2_2), luma2_1, reached2_1);\n\n \n let distFinal = min(dist1, dist2);\n let edgeThickness = dist1 + dist2;\n let lumaEndCloser = select(lumaEnd2, lumaEnd1, dist1 < dist2);\n let correctVariation = (lumaEndCloser < 0.0) != (lumaM < lumaLocalAverage);\n var pixelOffset = select(0.0, -distFinal / max(edgeThickness, 0.0001) + 0.5, correctVariation);\n\n \n let finalOffset = max(max(pixelOffset, blendL), 0.5);\n\n \n let inverseVP = 1.0 / texSize;\n var finalUv = uv;\n let offsetAmount = finalOffset * stepSign;\n finalUv.x += select(offsetAmount * inverseVP.x, 0.0, isHorizontal);\n finalUv.y += select(0.0, offsetAmount * inverseVP.y, isHorizontal);\n\n \n let offsetColor = sampleBilinear(finalUv);\n\n \n \n let perpDir = select(vec2i(0, 1), vec2i(1, 0), isHorizontal);\n let perpColor1 = loadPixel(pixelCoord + perpDir);\n let perpColor2 = loadPixel(pixelCoord - perpDir);\n let neighborAvg = (perpColor1 + perpColor2) * 0.5;\n\n \n let fxaaColor = mix(mix(offsetColor, neighborAvg, 0.7), rgbM, 0.1);\n\n \n return select(rgbM, fxaaColor, isEdge);\n}\n\nfn sampleBloom(uv: vec2f, sceneBrightness: f32) -> vec3f {\n let bloom = textureSample(bloomTexture, bloomSampler, uv).rgb;\n\n \n \n let threshold = 0.5; \n let mask = saturate(1.0 - (sceneBrightness - threshold) / (1.0 - threshold));\n\n return bloom * mask * mask; \n}\n\nfn aces_tone_map(hdr: vec3<f32>) -> vec3<f32> {\n let m1 = mat3x3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777,\n );\n let m2 = mat3x3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602,\n );\n let v = m1 * hdr;\n let a = v * (v + 0.0245786) - 0.000090537;\n let b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return clamp(m2 * (a / b), vec3(0.0), vec3(1.0));\n}\n\nfn reinhard_tone_map(hdr: vec3<f32>) -> vec3<f32> {\n return hdr / (hdr + vec3(1.0));\n}\n\nfn linear_tone_map(hdr: vec3<f32>) -> vec3<f32> {\n return clamp(hdr, vec3(0.0), vec3(1.0));\n}\n\nfn apply_tone_map(hdr: vec3<f32>, mode: i32) -> vec3<f32> {\n if (mode == 1) {\n return reinhard_tone_map(hdr);\n } else if (mode == 2) {\n return linear_tone_map(hdr);\n }\n return aces_tone_map(hdr); \n}\n\n@vertex\nfn vertexMain(@builtin(vertex_index) vertexIndex : u32) -> VertexOutput {\n var output : VertexOutput;\n let x = f32(vertexIndex & 1u) * 4.0 - 1.0;\n let y = f32(vertexIndex >> 1u) * 4.0 - 1.0;\n output.position = vec4<f32>(x, y, 0.0, 1.0);\n output.uv = vec2<f32>((x + 1.0) * 0.5, (1.0 - y) * 0.5);\n return output;\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {\n \n let fxaaEnabled = uniforms.noiseParams.w > 0.5;\n let fxaaColor = fxaa(input.uv);\n let directColor = textureSample(inputTexture, inputSampler, input.uv).rgb;\n var color = select(directColor, fxaaColor, fxaaEnabled);\n\n \n \n if (uniforms.bloomParams.x > 0.5) {\n let sceneBrightness = rgb2luma(color);\n let bloom = sampleBloom(input.uv, sceneBrightness);\n color += bloom * uniforms.bloomParams.y; \n }\n\n let tonemapMode = i32(uniforms.ditherParams.z);\n var sdr = apply_tone_map(color, tonemapMode);\n\n \n \n let gui = textureSample(guiTexture, guiSampler, input.uv);\n sdr = sdr * (1.0 - gui.a) + gui.rgb;\n\n \n \n if (uniforms.ditherParams.x > 0.5 && uniforms.noiseParams.x > 0.0) {\n let levels = uniforms.ditherParams.y;\n let noise = sampleNoise(input.position.xy);\n\n \n \n let dither = noise - 0.5;\n sdr = floor(sdr * (levels - 1.0) + dither + 0.5) / (levels - 1.0);\n sdr = clamp(sdr, vec3f(0.0), vec3f(1.0));\n }\n\n return vec4<f32>(sdr, 1.0);\n}";
11936
+ class PostProcessPass extends BasePass {
11937
+ constructor(engine = null) {
11938
+ super("PostProcess", engine);
11939
+ this.pipeline = null;
11940
+ this.inputTexture = null;
11941
+ this.bloomTexture = null;
11942
+ this.dummyBloomTexture = null;
11943
+ this.noiseTexture = null;
11944
+ this.noiseSize = 64;
11945
+ this.noiseAnimated = true;
11946
+ this.guiCanvas = null;
11947
+ this.guiTexture = null;
11948
+ this.guiSampler = null;
11949
+ this.intermediateTexture = null;
11950
+ this._outputWidth = 0;
11951
+ this._outputHeight = 0;
11952
+ this._lastOutputToTexture = false;
11953
+ this._resizeWidth = 0;
11954
+ this._resizeHeight = 0;
11955
+ }
11956
+ // Convenience getter for exposure setting
11957
+ get exposure() {
11958
+ return this.settings?.environment?.exposure ?? 1.6;
11959
+ }
11960
+ // Convenience getter for fxaa setting
11961
+ get fxaa() {
11962
+ return this.settings?.rendering?.fxaa ?? true;
11963
+ }
11964
+ // Convenience getter for dithering settings
11965
+ get ditheringEnabled() {
11966
+ return this.settings?.dithering?.enabled ?? true;
11967
+ }
11968
+ get colorLevels() {
11969
+ return this.settings?.dithering?.colorLevels ?? 32;
11970
+ }
11971
+ // Tonemap mode: 0=ACES, 1=Reinhard, 2=None/Linear
11972
+ get tonemapMode() {
11973
+ return this.settings?.rendering?.tonemapMode ?? 0;
11974
+ }
11975
+ // Convenience getters for bloom settings
11976
+ get bloomEnabled() {
11977
+ return this.settings?.bloom?.enabled ?? true;
11978
+ }
11979
+ get bloomIntensity() {
11980
+ return this.settings?.bloom?.intensity ?? 1;
11981
+ }
11982
+ get bloomRadius() {
11983
+ return this.settings?.bloom?.radius ?? 5;
11984
+ }
11985
+ // CRT settings (determines if we output to intermediate texture)
11986
+ get crtEnabled() {
11987
+ return this.settings?.crt?.enabled ?? false;
11988
+ }
11989
+ get crtUpscaleEnabled() {
11990
+ return this.settings?.crt?.upscaleEnabled ?? false;
11991
+ }
11992
+ get shouldOutputToTexture() {
11993
+ return this.crtEnabled || this.crtUpscaleEnabled;
11994
+ }
11995
+ /**
11996
+ * Set the input texture (HDR image from LightingPass)
11997
+ * @param {Texture} texture - Input HDR texture
11998
+ */
11999
+ setInputTexture(texture) {
12000
+ if (this.inputTexture !== texture) {
12001
+ this.inputTexture = texture;
12002
+ this._needsRebuild = true;
12003
+ }
12004
+ }
12005
+ /**
12006
+ * Set the bloom texture (from BloomPass)
12007
+ * @param {Object} bloomTexture - Bloom texture with mip levels
12008
+ */
12009
+ setBloomTexture(bloomTexture) {
12010
+ if (this.bloomTexture !== bloomTexture) {
12011
+ this.bloomTexture = bloomTexture;
12012
+ this._needsRebuild = true;
12013
+ }
12014
+ }
12015
+ /**
12016
+ * Set the noise texture for dithering
12017
+ * @param {Texture} texture - Noise texture (blue noise or bayer dither)
12018
+ * @param {number} size - Texture size
12019
+ * @param {boolean} animated - Whether to animate noise offset each frame
12020
+ */
12021
+ setNoise(texture, size = 64, animated = true) {
12022
+ this.noiseTexture = texture;
12023
+ this.noiseSize = size;
12024
+ this.noiseAnimated = animated;
12025
+ this._needsRebuild = true;
12026
+ }
12027
+ /**
12028
+ * Set the GUI canvas for overlay rendering
12029
+ * @param {HTMLCanvasElement} canvas - 2D canvas with GUI content
12030
+ */
12031
+ setGuiCanvas(canvas) {
12032
+ this.guiCanvas = canvas;
12033
+ }
12034
+ /**
12035
+ * Get the output texture (for CRT pass to use)
12036
+ * Returns null if outputting directly to canvas
12037
+ */
12038
+ getOutputTexture() {
12039
+ return this.shouldOutputToTexture ? this.intermediateTexture : null;
12040
+ }
12041
+ /**
12042
+ * Create or resize the intermediate texture for CRT
12043
+ */
12044
+ async _createIntermediateTexture(width, height) {
12045
+ if (!this.shouldOutputToTexture) {
12046
+ if (this.intermediateTexture?.texture) {
12047
+ this.intermediateTexture.texture.destroy();
12048
+ this.intermediateTexture = null;
12049
+ }
12050
+ return;
12051
+ }
12052
+ if (this._outputWidth === width && this._outputHeight === height && this.intermediateTexture) {
12053
+ return;
12054
+ }
12055
+ const { device } = this.engine;
12056
+ if (this.intermediateTexture?.texture) {
12057
+ this.intermediateTexture.texture.destroy();
12058
+ }
12059
+ const texture = device.createTexture({
12060
+ label: "PostProcess Intermediate",
12061
+ size: [width, height, 1],
12062
+ format: "rgba8unorm",
12063
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
12064
+ });
12065
+ const sampler = device.createSampler({
12066
+ label: "PostProcess Intermediate Sampler",
12067
+ minFilter: "nearest",
12068
+ magFilter: "nearest"
12069
+ });
12070
+ this.intermediateTexture = {
12071
+ texture,
12072
+ view: texture.createView(),
12073
+ sampler,
12074
+ width,
12075
+ height,
12076
+ format: "rgba8unorm"
12077
+ // Required by Pipeline.create()
12078
+ };
12079
+ this._outputWidth = width;
12080
+ this._outputHeight = height;
12081
+ this._needsRebuild = true;
12082
+ console.log(`PostProcessPass: Created intermediate texture ${width}x${height} for CRT`);
12083
+ }
12084
+ async _init() {
12085
+ const { device } = this.engine;
12086
+ const dummyTexture = device.createTexture({
12087
+ label: "Dummy Bloom Texture",
12088
+ size: [1, 1, 1],
12089
+ format: "rgba16float",
12090
+ mipLevelCount: 1,
12091
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
12092
+ });
12093
+ device.queue.writeTexture(
12094
+ { texture: dummyTexture },
12095
+ new Float32Array([0, 0, 0, 0]).buffer,
12096
+ { bytesPerRow: 8 },
12097
+ { width: 1, height: 1 }
12098
+ );
12099
+ const dummySampler = device.createSampler({
12100
+ label: "Dummy Bloom Sampler",
12101
+ minFilter: "linear",
12102
+ magFilter: "linear"
12103
+ });
12104
+ this.dummyBloomTexture = {
12105
+ texture: dummyTexture,
12106
+ view: dummyTexture.createView(),
12107
+ sampler: dummySampler,
12108
+ mipCount: 1
12109
+ };
12110
+ this.guiSampler = device.createSampler({
12111
+ label: "GUI Sampler",
12112
+ minFilter: "linear",
12113
+ magFilter: "linear"
12114
+ });
12115
+ const dummyGuiTexture = device.createTexture({
12116
+ label: "Dummy GUI Texture",
12117
+ size: [1, 1, 1],
12118
+ format: "rgba8unorm",
12119
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
12120
+ });
12121
+ device.queue.writeTexture(
12122
+ { texture: dummyGuiTexture },
12123
+ new Uint8Array([0, 0, 0, 0]),
12124
+ { bytesPerRow: 4 },
12125
+ { width: 1, height: 1 }
12126
+ );
12127
+ this.dummyGuiTexture = {
12128
+ texture: dummyGuiTexture,
12129
+ view: dummyGuiTexture.createView(),
12130
+ sampler: this.guiSampler
12131
+ };
12132
+ }
12133
+ /**
12134
+ * Build or rebuild the pipeline
12135
+ */
12136
+ async _buildPipeline() {
12137
+ if (!this.inputTexture) {
12138
+ return;
12139
+ }
12140
+ const textures = [this.inputTexture];
12141
+ if (this.noiseTexture) {
12142
+ textures.push(this.noiseTexture);
12143
+ }
12144
+ const effectiveBloomTexture = this.bloomTexture || this.dummyBloomTexture;
12145
+ textures.push(effectiveBloomTexture);
12146
+ const effectiveGuiTexture = this.guiTexture || this.dummyGuiTexture;
12147
+ textures.push(effectiveGuiTexture);
12148
+ const hasBloom = this.bloomTexture && this.bloomEnabled;
12149
+ const renderTarget = this.shouldOutputToTexture && this.intermediateTexture ? this.intermediateTexture : null;
11158
12150
  this.pipeline = await Pipeline.create(this.engine, {
11159
12151
  label: "postProcess",
11160
12152
  wgslSource: postproc_default,
@@ -11162,75 +12154,662 @@ class PostProcessPass extends BasePass {
11162
12154
  textures,
11163
12155
  uniforms: () => ({
11164
12156
  noiseParams: [this.noiseSize, this.noiseAnimated ? Math.random() : 0, this.noiseAnimated ? Math.random() : 0, this.fxaa ? 1 : 0],
11165
- ditherParams: [this.ditheringEnabled ? 1 : 0, this.colorLevels, 0, 0],
12157
+ ditherParams: [this.ditheringEnabled ? 1 : 0, this.colorLevels, this.tonemapMode, 0],
11166
12158
  bloomParams: [hasBloom ? 1 : 0, this.bloomIntensity, this.bloomRadius, effectiveBloomTexture?.mipCount ?? 1]
11167
- })
11168
- // No renderTarget = output to canvas
12159
+ }),
12160
+ renderTarget
12161
+ });
12162
+ this._needsRebuild = false;
12163
+ }
12164
+ async _execute(context) {
12165
+ const { device, canvas } = this.engine;
12166
+ const needsOutputToTexture = this.shouldOutputToTexture;
12167
+ const hasIntermediateTexture = !!this.intermediateTexture;
12168
+ if (needsOutputToTexture !== this._lastOutputToTexture) {
12169
+ this._lastOutputToTexture = needsOutputToTexture;
12170
+ this._needsRebuild = true;
12171
+ if (needsOutputToTexture && !hasIntermediateTexture) {
12172
+ const w = this._resizeWidth || canvas.width;
12173
+ const h = this._resizeHeight || canvas.height;
12174
+ await this._createIntermediateTexture(w, h);
12175
+ } else if (!needsOutputToTexture && hasIntermediateTexture) {
12176
+ if (this.intermediateTexture?.texture) {
12177
+ this.intermediateTexture.texture.destroy();
12178
+ this.intermediateTexture = null;
12179
+ }
12180
+ }
12181
+ }
12182
+ if (this.guiCanvas && this.guiCanvas.width > 0 && this.guiCanvas.height > 0) {
12183
+ const needsNewTexture = !this.guiTexture || this.guiTexture.width !== this.guiCanvas.width || this.guiTexture.height !== this.guiCanvas.height;
12184
+ if (needsNewTexture) {
12185
+ if (this.guiTexture?.texture) {
12186
+ this.guiTexture.texture.destroy();
12187
+ }
12188
+ const texture = device.createTexture({
12189
+ label: "GUI Texture",
12190
+ size: [this.guiCanvas.width, this.guiCanvas.height, 1],
12191
+ format: "rgba8unorm",
12192
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
12193
+ });
12194
+ this.guiTexture = {
12195
+ texture,
12196
+ view: texture.createView(),
12197
+ sampler: this.guiSampler,
12198
+ width: this.guiCanvas.width,
12199
+ height: this.guiCanvas.height
12200
+ };
12201
+ this._needsRebuild = true;
12202
+ }
12203
+ device.queue.copyExternalImageToTexture(
12204
+ { source: this.guiCanvas },
12205
+ { texture: this.guiTexture.texture },
12206
+ [this.guiCanvas.width, this.guiCanvas.height]
12207
+ );
12208
+ }
12209
+ if (this._needsRebuild) {
12210
+ await this._buildPipeline();
12211
+ }
12212
+ if (!this.pipeline || this._needsRebuild) {
12213
+ console.warn("PostProcessPass: Pipeline not ready");
12214
+ return;
12215
+ }
12216
+ const hasBloom = this.bloomTexture && this.bloomEnabled;
12217
+ const effectiveBloomTexture = this.bloomTexture || this.dummyBloomTexture;
12218
+ this.pipeline.uniformValues.set({
12219
+ noiseParams: [this.noiseSize, this.noiseAnimated ? Math.random() : 0, this.noiseAnimated ? Math.random() : 0, this.fxaa ? 1 : 0],
12220
+ ditherParams: [this.ditheringEnabled ? 1 : 0, this.colorLevels, this.tonemapMode, 0],
12221
+ bloomParams: [hasBloom ? 1 : 0, this.bloomIntensity, this.bloomRadius, effectiveBloomTexture?.mipCount ?? 1]
12222
+ });
12223
+ this.pipeline.render();
12224
+ }
12225
+ async _resize(width, height) {
12226
+ this._resizeWidth = width;
12227
+ this._resizeHeight = height;
12228
+ await this._createIntermediateTexture(width, height);
12229
+ this._needsRebuild = true;
12230
+ }
12231
+ _destroy() {
12232
+ this.pipeline = null;
12233
+ if (this.dummyBloomTexture?.texture) {
12234
+ this.dummyBloomTexture.texture.destroy();
12235
+ this.dummyBloomTexture = null;
12236
+ }
12237
+ if (this.guiTexture?.texture) {
12238
+ this.guiTexture.texture.destroy();
12239
+ this.guiTexture = null;
12240
+ }
12241
+ if (this.dummyGuiTexture?.texture) {
12242
+ this.dummyGuiTexture.texture.destroy();
12243
+ this.dummyGuiTexture = null;
12244
+ }
12245
+ if (this.intermediateTexture?.texture) {
12246
+ this.intermediateTexture.texture.destroy();
12247
+ this.intermediateTexture = null;
12248
+ }
12249
+ }
12250
+ }
12251
+ var crt_default = "struct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n}\n\nstruct Uniforms {\n canvasSize: vec2f, \n inputSize: vec2f, \n renderSize: vec2f, \n\n \n curvature: f32, \n cornerRadius: f32, \n zoom: f32, \n _padGeom: f32,\n\n \n scanlineIntensity: f32, \n scanlineWidth: f32, \n scanlineBrightBoost: f32, \n scanlineHeight: f32, \n\n \n convergence: vec3f,\n _pad2: f32,\n\n \n maskType: f32, \n maskIntensity: f32, \n maskScale: f32, \n maskCompensation: f32, \n\n \n vignetteIntensity: f32,\n vignetteSize: f32,\n\n \n blurSize: f32, \n\n \n crtEnabled: f32, \n upscaleEnabled: f32, \n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var inputTexture: texture_2d<f32>;\n@group(0) @binding(2) var inputSampler: sampler; \n@group(0) @binding(3) var nearestSampler: sampler; \n@group(0) @binding(4) var phosphorMask: texture_2d<f32>;\n@group(0) @binding(5) var phosphorSampler: sampler;\n\nconst PI: f32 = 3.14159265359;\n\nfn applyBarrelDistortion(uv: vec2f, curvature: f32) -> vec2f {\n \n let centered = uv - 0.5;\n\n \n let r2 = dot(centered, centered);\n\n \n let distorted = centered * (1.0 + curvature * r2);\n\n return distorted + 0.5;\n}\n\nfn getCornerMask(uv: vec2f, radius: f32) -> f32 {\n \n let d = abs(uv - 0.5) * 2.0;\n\n \n let corner = max(d.x, d.y);\n let roundedCorner = length(max(d - (1.0 - radius), vec2f(0.0)));\n\n \n let mask = 1.0 - smoothstep(1.0 - radius * 0.5, 1.0, max(corner, roundedCorner));\n\n return mask;\n}\n\nfn applyScanlines(color: vec3f, scanlinePos: f32, intensity: f32, width: f32, brightBoost: f32) -> vec3f {\n if (intensity <= 0.0) {\n return color;\n }\n\n \n \n let pos = fract(scanlinePos);\n\n \n \n let distFromCenter = abs(pos - 0.5);\n\n \n let brightness = dot(color, vec3f(0.299, 0.587, 0.114));\n\n \n \n \n let baseWidth = width;\n let brightWidening = brightBoost * brightness * (1.0 - width);\n let effectiveWidth = clamp(baseWidth + brightWidening, 0.0, 1.0);\n\n \n \n \n let sigma = mix(0.08, 0.8, effectiveWidth);\n let gaussian = exp(-0.5 * pow(distFromCenter / sigma, 2.0));\n\n \n let scanline = clamp(gaussian, 0.0, 1.0);\n let darkening = mix(1.0, scanline, intensity);\n\n return color * darkening;\n}\n\nfn sampleWithConvergence(uv: vec2f, convergence: vec3f, texelSize: vec2f) -> vec3f {\n \n \n let offsetR = vec2f(convergence.r * texelSize.x, 0.0);\n let offsetG = vec2f(convergence.g * texelSize.x, 0.0);\n let offsetB = vec2f(convergence.b * texelSize.x, 0.0);\n\n let r = textureSampleLevel(inputTexture, inputSampler, uv + offsetR, 0.0).r;\n let g = textureSampleLevel(inputTexture, inputSampler, uv + offsetG, 0.0).g;\n let b = textureSampleLevel(inputTexture, inputSampler, uv + offsetB, 0.0).b;\n\n return vec3f(r, g, b);\n}\n\nfn applyPhosphorMask(color: vec3f, screenPos: vec2f, maskType: f32, intensity: f32, scale: f32) -> vec3f {\n if (intensity <= 0.0 || maskType < 0.5) {\n return color;\n }\n\n \n \n \n let maskUV = screenPos / (3.0 * scale);\n let mask = textureSampleLevel(phosphorMask, phosphorSampler, maskUV, 0.0).rgb;\n\n \n \n return mix(color, color * mask, intensity);\n}\n\nfn proceduralApertureGrille(screenX: f32, scale: f32) -> vec3f {\n let phase = fract(screenX / (3.0 * scale));\n\n \n var mask = vec3f(0.0);\n if (phase < 0.333) {\n mask = vec3f(1.0, 0.2, 0.2); \n } else if (phase < 0.666) {\n mask = vec3f(0.2, 1.0, 0.2); \n } else {\n mask = vec3f(0.2, 0.2, 1.0); \n }\n\n return mask;\n}\n\nfn proceduralSlotMask(screenPos: vec2f, scale: f32, scanlineHeight: f32) -> vec3f {\n \n let cellWidth = 3.0 * scale;\n let cellHeight = max(scanlineHeight, 1.0);\n let cellPos = fract(vec2f(screenPos.x / cellWidth, screenPos.y / cellHeight));\n\n \n var adjusted = cellPos;\n if (fract(screenPos.y / (cellHeight * 2.0)) > 0.5) {\n adjusted.x = fract(adjusted.x + 0.5);\n }\n\n \n var mask = vec3f(0.0);\n if (adjusted.x < 0.333) {\n mask = vec3f(1.0, 0.1, 0.1);\n } else if (adjusted.x < 0.666) {\n mask = vec3f(0.1, 1.0, 0.1);\n } else {\n mask = vec3f(0.1, 0.1, 1.0);\n }\n\n \n mask *= smoothstep(0.0, 0.15, cellPos.y) * smoothstep(1.0, 0.85, cellPos.y);\n\n return mask;\n}\n\nfn proceduralShadowMask(screenPos: vec2f, scale: f32) -> vec3f {\n let cellSize = 2.0 * scale;\n let cell = floor(screenPos / cellSize);\n let cellPos = fract(screenPos / cellSize);\n\n \n let rowOffset = select(0.0, 0.5, fract(cell.y * 0.5) > 0.25);\n let adjustedX = fract(cellPos.x + rowOffset);\n\n \n let dist = length(cellPos - 0.5) * 2.0;\n let circle = 1.0 - smoothstep(0.6, 0.8, dist);\n\n \n let phase = fract((cell.x + rowOffset) / 3.0);\n var mask = vec3f(0.1);\n if (phase < 0.333) {\n mask = vec3f(1.0, 0.1, 0.1) * circle;\n } else if (phase < 0.666) {\n mask = vec3f(0.1, 1.0, 0.1) * circle;\n } else {\n mask = vec3f(0.1, 0.1, 1.0) * circle;\n }\n\n return max(mask, vec3f(0.1));\n}\n\nfn applyVignette(color: vec3f, uv: vec2f, intensity: f32, size: f32) -> vec3f {\n if (intensity <= 0.0) {\n return color;\n }\n\n \n let centered = uv - 0.5;\n let dist = length(centered);\n\n \n let vignette = 1.0 - smoothstep(size * 0.5, size, dist);\n let darkening = mix(1.0 - intensity, 1.0, vignette);\n\n return color * darkening;\n}\n\nfn sampleWithHorizontalBlur(uv: vec2f, blurSize: f32, texelSize: vec2f) -> vec3f {\n if (blurSize <= 0.0) {\n return textureSampleLevel(inputTexture, inputSampler, uv, 0.0).rgb;\n }\n\n \n \n let w0 = 0.0625;\n let w1 = 0.25;\n let w2 = 0.375;\n\n let offset = blurSize * texelSize.x;\n\n let c0 = textureSampleLevel(inputTexture, inputSampler, uv + vec2f(-2.0 * offset, 0.0), 0.0).rgb;\n let c1 = textureSampleLevel(inputTexture, inputSampler, uv + vec2f(-1.0 * offset, 0.0), 0.0).rgb;\n let c2 = textureSampleLevel(inputTexture, inputSampler, uv, 0.0).rgb;\n let c3 = textureSampleLevel(inputTexture, inputSampler, uv + vec2f(1.0 * offset, 0.0), 0.0).rgb;\n let c4 = textureSampleLevel(inputTexture, inputSampler, uv + vec2f(2.0 * offset, 0.0), 0.0).rgb;\n\n return c0 * w0 + c1 * w1 + c2 * w2 + c3 * w1 + c4 * w0;\n}\n\nfn sampleWithBlurAndConvergence(uv: vec2f, convergence: vec3f, blurSize: f32, texelSize: vec2f) -> vec3f {\n if (blurSize <= 0.0) {\n \n let offsetR = vec2f(convergence.r * texelSize.x, 0.0);\n let offsetG = vec2f(convergence.g * texelSize.x, 0.0);\n let offsetB = vec2f(convergence.b * texelSize.x, 0.0);\n let r = textureSampleLevel(inputTexture, inputSampler, uv + offsetR, 0.0).r;\n let g = textureSampleLevel(inputTexture, inputSampler, uv + offsetG, 0.0).g;\n let b = textureSampleLevel(inputTexture, inputSampler, uv + offsetB, 0.0).b;\n return vec3f(r, g, b);\n }\n\n \n let w0 = 0.0625;\n let w1 = 0.25;\n let w2 = 0.375;\n let offset = blurSize * texelSize.x;\n\n \n var r = 0.0;\n var g = 0.0;\n var b = 0.0;\n\n for (var i = -2; i <= 2; i++) {\n let weight = select(select(w1, w2, i == 0), w0, abs(i) == 2);\n let blurOffset = f32(i) * offset;\n\n r += textureSampleLevel(inputTexture, inputSampler, uv + vec2f(convergence.r * texelSize.x + blurOffset, 0.0), 0.0).r * weight;\n g += textureSampleLevel(inputTexture, inputSampler, uv + vec2f(convergence.g * texelSize.x + blurOffset, 0.0), 0.0).g * weight;\n b += textureSampleLevel(inputTexture, inputSampler, uv + vec2f(convergence.b * texelSize.x + blurOffset, 0.0), 0.0).b * weight;\n }\n\n return vec3f(r, g, b);\n}\n\n@vertex\nfn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {\n var output: VertexOutput;\n\n \n let x = f32(vertexIndex & 1u) * 4.0 - 1.0;\n let y = f32(vertexIndex >> 1u) * 4.0 - 1.0;\n output.position = vec4f(x, y, 0.0, 1.0);\n output.uv = vec2f((x + 1.0) * 0.5, (1.0 - y) * 0.5);\n\n return output;\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput) -> @location(0) vec4f {\n \n let pixelPos = input.position.xy;\n var uv = input.uv;\n\n \n let crtEnabled = uniforms.crtEnabled > 0.5;\n\n \n \n if (!crtEnabled && uniforms.upscaleEnabled < 0.5) {\n return textureSampleLevel(inputTexture, nearestSampler, uv, 0.0);\n }\n\n \n var distortedUV = uv;\n var cornerMask = 1.0;\n\n if (crtEnabled && uniforms.curvature > 0.0) {\n \n var zoomedUV = uv;\n if (uniforms.zoom > 1.0) {\n let centered = uv - 0.5;\n zoomedUV = centered / uniforms.zoom + 0.5;\n }\n distortedUV = applyBarrelDistortion(zoomedUV, uniforms.curvature);\n cornerMask = getCornerMask(distortedUV, uniforms.cornerRadius);\n\n \n \n if (distortedUV.x < 0.0 || distortedUV.x > 1.0 ||\n distortedUV.y < 0.0 || distortedUV.y > 1.0) {\n return vec4f(0.0, 0.0, 0.0, 1.0);\n }\n }\n\n \n let texelSize = 1.0 / uniforms.inputSize;\n\n \n var color: vec3f;\n if (crtEnabled) {\n let hasConvergence = any(uniforms.convergence != vec3f(0.0));\n if (hasConvergence) {\n \n color = sampleWithBlurAndConvergence(distortedUV, uniforms.convergence, uniforms.blurSize, texelSize);\n } else {\n \n color = sampleWithHorizontalBlur(distortedUV, uniforms.blurSize, texelSize);\n }\n } else {\n \n \n let hasUpscaling = uniforms.inputSize.x > uniforms.canvasSize.x + 0.5 ||\n uniforms.inputSize.y > uniforms.canvasSize.y + 0.5;\n\n if (hasUpscaling) {\n \n color = textureSampleLevel(inputTexture, inputSampler, distortedUV, 0.0).rgb;\n } else {\n \n \n color = textureSampleLevel(inputTexture, nearestSampler, distortedUV, 0.0).rgb;\n }\n }\n\n \n if (crtEnabled) {\n \n \n let scanlinePos = pixelPos.y / max(uniforms.scanlineHeight, 1.0);\n\n \n color = applyScanlines(\n color,\n scanlinePos,\n uniforms.scanlineIntensity,\n uniforms.scanlineWidth,\n uniforms.scanlineBrightBoost\n );\n\n \n if (uniforms.maskType > 0.5 && uniforms.maskIntensity > 0.0) {\n var mask: vec3f;\n let maskPos = pixelPos / uniforms.maskScale;\n if (uniforms.maskType < 1.5) {\n \n mask = proceduralApertureGrille(maskPos.x, 1.0);\n } else if (uniforms.maskType < 2.5) {\n \n mask = proceduralSlotMask(maskPos, 1.0, uniforms.scanlineHeight / uniforms.maskScale);\n } else {\n \n mask = proceduralShadowMask(maskPos, 1.0);\n }\n\n \n let maskedColor = color * mask;\n color = mix(color, maskedColor, uniforms.maskIntensity);\n color *= uniforms.maskCompensation;\n }\n\n \n color = applyVignette(color, uv, uniforms.vignetteIntensity, uniforms.vignetteSize);\n\n \n color *= cornerMask;\n }\n\n return vec4f(color, 1.0);\n}";
12252
+ class CRTPass extends BasePass {
12253
+ constructor(engine = null) {
12254
+ super("CRT", engine);
12255
+ this.pipeline = null;
12256
+ this.inputTexture = null;
12257
+ this.upscaledTexture = null;
12258
+ this.upscaledWidth = 0;
12259
+ this.upscaledHeight = 0;
12260
+ this.linearSampler = null;
12261
+ this.nearestSampler = null;
12262
+ this.phosphorMaskTexture = null;
12263
+ this.canvasWidth = 0;
12264
+ this.canvasHeight = 0;
12265
+ this.renderWidth = 0;
12266
+ this.renderHeight = 0;
12267
+ }
12268
+ // Settings getters
12269
+ get crtSettings() {
12270
+ return this.engine?.settings?.crt ?? {};
12271
+ }
12272
+ get crtEnabled() {
12273
+ return this.crtSettings.enabled ?? false;
12274
+ }
12275
+ get upscaleEnabled() {
12276
+ return this.crtSettings.upscaleEnabled ?? false;
12277
+ }
12278
+ get upscaleTarget() {
12279
+ return this.crtSettings.upscaleTarget ?? 4;
12280
+ }
12281
+ get maxTextureSize() {
12282
+ return this.crtSettings.maxTextureSize ?? 4096;
12283
+ }
12284
+ // Geometry
12285
+ get curvature() {
12286
+ return this.crtSettings.curvature ?? 0.03;
12287
+ }
12288
+ get cornerRadius() {
12289
+ return this.crtSettings.cornerRadius ?? 0.03;
12290
+ }
12291
+ get zoom() {
12292
+ return this.crtSettings.zoom ?? 1;
12293
+ }
12294
+ // Scanlines
12295
+ get scanlineIntensity() {
12296
+ return this.crtSettings.scanlineIntensity ?? 0.25;
12297
+ }
12298
+ get scanlineWidth() {
12299
+ return this.crtSettings.scanlineWidth ?? 0.5;
12300
+ }
12301
+ get scanlineBrightBoost() {
12302
+ return this.crtSettings.scanlineBrightBoost ?? 1;
12303
+ }
12304
+ get scanlineHeight() {
12305
+ return this.crtSettings.scanlineHeight ?? 3;
12306
+ }
12307
+ // pixels per scanline
12308
+ // Convergence
12309
+ get convergence() {
12310
+ return this.crtSettings.convergence ?? [0.5, 0, -0.5];
12311
+ }
12312
+ // Phosphor mask
12313
+ get maskType() {
12314
+ const type = this.crtSettings.maskType ?? "aperture";
12315
+ switch (type) {
12316
+ case "none":
12317
+ return 0;
12318
+ case "aperture":
12319
+ return 1;
12320
+ case "slot":
12321
+ return 2;
12322
+ case "shadow":
12323
+ return 3;
12324
+ default:
12325
+ return 1;
12326
+ }
12327
+ }
12328
+ get maskIntensity() {
12329
+ return this.crtSettings.maskIntensity ?? 0.15;
12330
+ }
12331
+ get maskScale() {
12332
+ return this.crtSettings.maskScale ?? 1;
12333
+ }
12334
+ // Vignette
12335
+ get vignetteIntensity() {
12336
+ return this.crtSettings.vignetteIntensity ?? 0.15;
12337
+ }
12338
+ get vignetteSize() {
12339
+ return this.crtSettings.vignetteSize ?? 0.4;
12340
+ }
12341
+ // Blur
12342
+ get blurSize() {
12343
+ return this.crtSettings.blurSize ?? 0.5;
12344
+ }
12345
+ /**
12346
+ * Calculate brightness compensation for phosphor mask
12347
+ * Pre-computed on CPU to avoid per-pixel calculation in shader
12348
+ * @param {number} maskType - 0=none, 1=aperture, 2=slot, 3=shadow
12349
+ * @param {number} intensity - mask intensity 0-1
12350
+ * @returns {number} compensation multiplier
12351
+ */
12352
+ _calculateMaskCompensation(maskType, intensity) {
12353
+ if (maskType < 0.5 || intensity <= 0) {
12354
+ return 1;
12355
+ }
12356
+ let darkening;
12357
+ let useLinearOnly = false;
12358
+ if (maskType < 1.5) {
12359
+ darkening = 0.25;
12360
+ } else if (maskType < 2.5) {
12361
+ darkening = 0.27;
12362
+ } else {
12363
+ darkening = 0.82;
12364
+ useLinearOnly = true;
12365
+ }
12366
+ const linearComp = 1 / Math.max(1 - intensity * darkening, 0.1);
12367
+ if (useLinearOnly) {
12368
+ return linearComp;
12369
+ }
12370
+ const expComp = Math.exp(intensity * darkening);
12371
+ const t = Math.max(0, Math.min(1, (intensity - 0.4) / 0.2));
12372
+ const blendFactor = t * t * (3 - 2 * t);
12373
+ return linearComp * (1 - blendFactor) + expComp * blendFactor;
12374
+ }
12375
+ /**
12376
+ * Set the input texture (from PostProcessPass intermediate output)
12377
+ * @param {Object} texture - Input texture object
12378
+ */
12379
+ setInputTexture(texture) {
12380
+ if (this.inputTexture !== texture) {
12381
+ this.inputTexture = texture;
12382
+ this._needsRebuild = true;
12383
+ this._blitPipeline = null;
12384
+ }
12385
+ }
12386
+ /**
12387
+ * Set the render size (before upscaling)
12388
+ * @param {number} width - Render width
12389
+ * @param {number} height - Render height
12390
+ */
12391
+ setRenderSize(width, height) {
12392
+ if (this.renderWidth !== width || this.renderHeight !== height) {
12393
+ this.renderWidth = width;
12394
+ this.renderHeight = height;
12395
+ this._needsUpscaleRebuild = true;
12396
+ }
12397
+ }
12398
+ /**
12399
+ * Calculate the upscaled texture size
12400
+ * @returns {{width: number, height: number, scale: number}}
12401
+ */
12402
+ _calculateUpscaledSize() {
12403
+ const renderW = this.renderWidth || this.canvasWidth;
12404
+ const renderH = this.renderHeight || this.canvasHeight;
12405
+ const maxSize = this.maxTextureSize;
12406
+ let scale = this.upscaleTarget;
12407
+ while (scale > 1 && (renderW * scale > maxSize || renderH * scale > maxSize)) {
12408
+ scale--;
12409
+ }
12410
+ const maxCanvasScale = 2;
12411
+ while (scale > 1 && (renderW * scale > this.canvasWidth * maxCanvasScale || renderH * scale > this.canvasHeight * maxCanvasScale)) {
12412
+ scale--;
12413
+ }
12414
+ scale = Math.max(scale, 1);
12415
+ const targetW = renderW * scale;
12416
+ const targetH = renderH * scale;
12417
+ return { width: targetW, height: targetH, scale };
12418
+ }
12419
+ /**
12420
+ * Check if actual upscaling is needed
12421
+ * Returns true only if the upscaled texture would be larger than the input
12422
+ */
12423
+ _needsUpscaling() {
12424
+ if (!this.upscaleEnabled) return false;
12425
+ const { scale } = this._calculateUpscaledSize();
12426
+ return scale > 1;
12427
+ }
12428
+ async _init() {
12429
+ const { device } = this.engine;
12430
+ this.linearSampler = device.createSampler({
12431
+ label: "CRT Linear Sampler",
12432
+ minFilter: "linear",
12433
+ magFilter: "linear",
12434
+ addressModeU: "mirror-repeat",
12435
+ addressModeV: "mirror-repeat"
11169
12436
  });
11170
- this._needsRebuild = false;
12437
+ this.nearestSampler = device.createSampler({
12438
+ label: "CRT Nearest Sampler",
12439
+ minFilter: "nearest",
12440
+ magFilter: "nearest",
12441
+ addressModeU: "mirror-repeat",
12442
+ addressModeV: "mirror-repeat"
12443
+ });
12444
+ await this._createPhosphorMaskTexture();
11171
12445
  }
11172
- async _execute(context) {
12446
+ /**
12447
+ * Create phosphor mask texture
12448
+ * This is a simple procedural texture for the aperture grille pattern
12449
+ */
12450
+ async _createPhosphorMaskTexture() {
11173
12451
  const { device } = this.engine;
11174
- if (this.guiCanvas && this.guiCanvas.width > 0 && this.guiCanvas.height > 0) {
11175
- const needsNewTexture = !this.guiTexture || this.guiTexture.width !== this.guiCanvas.width || this.guiTexture.height !== this.guiCanvas.height;
11176
- if (needsNewTexture) {
11177
- if (this.guiTexture?.texture) {
11178
- this.guiTexture.texture.destroy();
12452
+ const size = 6;
12453
+ const data = new Uint8Array(size * 2 * 4);
12454
+ for (let y = 0; y < 2; y++) {
12455
+ for (let x = 0; x < size; x++) {
12456
+ const idx = (y * size + x) * 4;
12457
+ const phase = x % 3;
12458
+ if (phase === 0) {
12459
+ data[idx] = 255;
12460
+ data[idx + 1] = 50;
12461
+ data[idx + 2] = 50;
12462
+ } else if (phase === 1) {
12463
+ data[idx] = 50;
12464
+ data[idx + 1] = 255;
12465
+ data[idx + 2] = 50;
12466
+ } else {
12467
+ data[idx] = 50;
12468
+ data[idx + 1] = 50;
12469
+ data[idx + 2] = 255;
11179
12470
  }
11180
- const texture = device.createTexture({
11181
- label: "GUI Texture",
11182
- size: [this.guiCanvas.width, this.guiCanvas.height, 1],
11183
- format: "rgba8unorm",
11184
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
11185
- });
11186
- this.guiTexture = {
11187
- texture,
11188
- view: texture.createView(),
11189
- sampler: this.guiSampler,
11190
- width: this.guiCanvas.width,
11191
- height: this.guiCanvas.height
11192
- };
11193
- this._needsRebuild = true;
12471
+ data[idx + 3] = 255;
11194
12472
  }
11195
- device.queue.copyExternalImageToTexture(
11196
- { source: this.guiCanvas },
11197
- { texture: this.guiTexture.texture },
11198
- [this.guiCanvas.width, this.guiCanvas.height]
11199
- );
11200
12473
  }
11201
- if (this._needsRebuild) {
11202
- await this._buildPipeline();
12474
+ const texture = device.createTexture({
12475
+ label: "Phosphor Mask",
12476
+ size: [size, 2, 1],
12477
+ format: "rgba8unorm",
12478
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
12479
+ });
12480
+ device.queue.writeTexture(
12481
+ { texture },
12482
+ data,
12483
+ { bytesPerRow: size * 4 },
12484
+ { width: size, height: 2 }
12485
+ );
12486
+ const sampler = device.createSampler({
12487
+ label: "Phosphor Mask Sampler",
12488
+ minFilter: "nearest",
12489
+ magFilter: "nearest",
12490
+ addressModeU: "repeat",
12491
+ addressModeV: "repeat"
12492
+ });
12493
+ this.phosphorMaskTexture = {
12494
+ texture,
12495
+ view: texture.createView(),
12496
+ sampler
12497
+ };
12498
+ }
12499
+ /**
12500
+ * Create or resize the upscaled texture
12501
+ */
12502
+ async _createUpscaledTexture() {
12503
+ const { device } = this.engine;
12504
+ const { width, height, scale } = this._calculateUpscaledSize();
12505
+ if (this.upscaledWidth === width && this.upscaledHeight === height) {
12506
+ return;
11203
12507
  }
11204
- if (!this.pipeline || this._needsRebuild) {
11205
- console.warn("PostProcessPass: Pipeline not ready");
12508
+ if (this.upscaledTexture?.texture) {
12509
+ this.upscaledTexture.texture.destroy();
12510
+ }
12511
+ const texture = device.createTexture({
12512
+ label: "CRT Upscaled Texture",
12513
+ size: [width, height, 1],
12514
+ format: "rgba8unorm",
12515
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_DST
12516
+ });
12517
+ this.upscaledTexture = {
12518
+ texture,
12519
+ view: texture.createView(),
12520
+ width,
12521
+ height,
12522
+ scale,
12523
+ format: "rgba8unorm"
12524
+ };
12525
+ this.upscaledWidth = width;
12526
+ this.upscaledHeight = height;
12527
+ console.log(`CRTPass: Created upscaled texture ${width}x${height} (${scale.toFixed(1)}x)`);
12528
+ this._needsRebuild = true;
12529
+ this._blitPipeline = null;
12530
+ }
12531
+ /**
12532
+ * Build or rebuild the CRT pipeline
12533
+ */
12534
+ async _buildPipeline() {
12535
+ if (!this.inputTexture && !this.upscaledTexture) {
11206
12536
  return;
11207
12537
  }
11208
- const hasBloom = this.bloomTexture && this.bloomEnabled;
11209
- const effectiveBloomTexture = this.bloomTexture || this.dummyBloomTexture;
11210
- this.pipeline.uniformValues.set({
11211
- noiseParams: [this.noiseSize, this.noiseAnimated ? Math.random() : 0, this.noiseAnimated ? Math.random() : 0, this.fxaa ? 1 : 0],
11212
- ditherParams: [this.ditheringEnabled ? 1 : 0, this.colorLevels, 0, 0],
11213
- bloomParams: [hasBloom ? 1 : 0, this.bloomIntensity, this.bloomRadius, effectiveBloomTexture?.mipCount ?? 1]
12538
+ const { device } = this.engine;
12539
+ const needsUpscaling = this._needsUpscaling();
12540
+ const effectiveInput = (this.crtEnabled || this.upscaleEnabled) && needsUpscaling && this.upscaledTexture ? this.upscaledTexture : this.inputTexture;
12541
+ if (!effectiveInput) return;
12542
+ const bindGroupLayout = device.createBindGroupLayout({
12543
+ label: "CRT Bind Group Layout",
12544
+ entries: [
12545
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
12546
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
12547
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
12548
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
12549
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
12550
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } }
12551
+ ]
11214
12552
  });
11215
- this.pipeline.render();
12553
+ const uniformBuffer = device.createBuffer({
12554
+ label: "CRT Uniforms",
12555
+ size: 128,
12556
+ // Padded for alignment
12557
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
12558
+ });
12559
+ const bindGroup = device.createBindGroup({
12560
+ label: "CRT Bind Group",
12561
+ layout: bindGroupLayout,
12562
+ entries: [
12563
+ { binding: 0, resource: { buffer: uniformBuffer } },
12564
+ { binding: 1, resource: effectiveInput.view },
12565
+ { binding: 2, resource: this.linearSampler },
12566
+ { binding: 3, resource: this.nearestSampler },
12567
+ { binding: 4, resource: this.phosphorMaskTexture.view },
12568
+ { binding: 5, resource: this.phosphorMaskTexture.sampler }
12569
+ ]
12570
+ });
12571
+ const pipelineLayout = device.createPipelineLayout({
12572
+ label: "CRT Pipeline Layout",
12573
+ bindGroupLayouts: [bindGroupLayout]
12574
+ });
12575
+ const shaderModule = device.createShaderModule({
12576
+ label: "CRT Shader",
12577
+ code: crt_default
12578
+ });
12579
+ const pipeline = device.createRenderPipeline({
12580
+ label: "CRT Pipeline",
12581
+ layout: pipelineLayout,
12582
+ vertex: {
12583
+ module: shaderModule,
12584
+ entryPoint: "vertexMain"
12585
+ },
12586
+ fragment: {
12587
+ module: shaderModule,
12588
+ entryPoint: "fragmentMain",
12589
+ targets: [{
12590
+ format: navigator.gpu.getPreferredCanvasFormat()
12591
+ }]
12592
+ },
12593
+ primitive: {
12594
+ topology: "triangle-list"
12595
+ }
12596
+ });
12597
+ this.pipeline = {
12598
+ pipeline,
12599
+ bindGroup,
12600
+ uniformBuffer
12601
+ };
12602
+ this._pipelineInputTexture = this.inputTexture;
12603
+ this._pipelineUpscaledTexture = needsUpscaling ? this.upscaledTexture : null;
12604
+ this._needsRebuild = false;
12605
+ }
12606
+ /**
12607
+ * Upscale the input texture using nearest-neighbor filtering
12608
+ */
12609
+ _upscaleInput() {
12610
+ if (!this.inputTexture || !this.upscaledTexture) return;
12611
+ const { device } = this.engine;
12612
+ const commandEncoder = device.createCommandEncoder({ label: "CRT Upscale" });
12613
+ if (this.inputTexture.width === this.upscaledTexture.width && this.inputTexture.height === this.upscaledTexture.height) {
12614
+ commandEncoder.copyTextureToTexture(
12615
+ { texture: this.inputTexture.texture },
12616
+ { texture: this.upscaledTexture.texture },
12617
+ [this.inputTexture.width, this.inputTexture.height]
12618
+ );
12619
+ } else {
12620
+ if (!this._blitPipeline || this._blitInputTexture !== this.inputTexture) {
12621
+ this._createBlitPipeline();
12622
+ }
12623
+ if (this._blitPipeline) {
12624
+ const renderPass = commandEncoder.beginRenderPass({
12625
+ colorAttachments: [{
12626
+ view: this.upscaledTexture.view,
12627
+ loadOp: "clear",
12628
+ storeOp: "store",
12629
+ clearValue: { r: 0, g: 0, b: 0, a: 1 }
12630
+ }]
12631
+ });
12632
+ renderPass.setPipeline(this._blitPipeline.pipeline);
12633
+ renderPass.setBindGroup(0, this._blitPipeline.bindGroup);
12634
+ renderPass.draw(3, 1, 0, 0);
12635
+ renderPass.end();
12636
+ }
12637
+ }
12638
+ device.queue.submit([commandEncoder.finish()]);
12639
+ }
12640
+ /**
12641
+ * Create a simple nearest-neighbor blit pipeline
12642
+ */
12643
+ _createBlitPipeline() {
12644
+ if (!this.inputTexture) return;
12645
+ const { device } = this.engine;
12646
+ this._blitInputTexture = this.inputTexture;
12647
+ const blitShader = `
12648
+ struct VertexOutput {
12649
+ @builtin(position) position: vec4f,
12650
+ @location(0) uv: vec2f,
12651
+ }
12652
+
12653
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
12654
+ @group(0) @binding(1) var inputSampler: sampler;
12655
+
12656
+ @vertex
12657
+ fn vertexMain(@builtin(vertex_index) idx: u32) -> VertexOutput {
12658
+ var output: VertexOutput;
12659
+ let x = f32(idx & 1u) * 4.0 - 1.0;
12660
+ let y = f32(idx >> 1u) * 4.0 - 1.0;
12661
+ output.position = vec4f(x, y, 0.0, 1.0);
12662
+ output.uv = vec2f((x + 1.0) * 0.5, (1.0 - y) * 0.5);
12663
+ return output;
12664
+ }
12665
+
12666
+ @fragment
12667
+ fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
12668
+ return textureSample(inputTexture, inputSampler, input.uv);
12669
+ }
12670
+ `;
12671
+ const shaderModule = device.createShaderModule({
12672
+ label: "Blit Shader",
12673
+ code: blitShader
12674
+ });
12675
+ const bindGroupLayout = device.createBindGroupLayout({
12676
+ label: "Blit Bind Group Layout",
12677
+ entries: [
12678
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
12679
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } }
12680
+ ]
12681
+ });
12682
+ const bindGroup = device.createBindGroup({
12683
+ label: "Blit Bind Group",
12684
+ layout: bindGroupLayout,
12685
+ entries: [
12686
+ { binding: 0, resource: this.inputTexture.view },
12687
+ { binding: 1, resource: this.nearestSampler }
12688
+ ]
12689
+ });
12690
+ const pipeline = device.createRenderPipeline({
12691
+ label: "Blit Pipeline",
12692
+ layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
12693
+ vertex: {
12694
+ module: shaderModule,
12695
+ entryPoint: "vertexMain"
12696
+ },
12697
+ fragment: {
12698
+ module: shaderModule,
12699
+ entryPoint: "fragmentMain",
12700
+ targets: [{ format: "rgba8unorm" }]
12701
+ },
12702
+ primitive: { topology: "triangle-list" }
12703
+ });
12704
+ this._blitPipeline = { pipeline, bindGroup };
12705
+ }
12706
+ async _execute(context) {
12707
+ const { device, canvas } = this.engine;
12708
+ const needsUpscaling = this._needsUpscaling();
12709
+ const effectiveUpscaledTexture = needsUpscaling ? this.upscaledTexture : null;
12710
+ if (this.pipeline && (this._pipelineInputTexture !== this.inputTexture || this._pipelineUpscaledTexture !== effectiveUpscaledTexture)) {
12711
+ this._needsRebuild = true;
12712
+ this._blitPipeline = null;
12713
+ }
12714
+ if (!this.crtEnabled && !this.upscaleEnabled) {
12715
+ if (!this.inputTexture) return;
12716
+ if (this._needsRebuild || !this.pipeline) {
12717
+ await this._buildPipeline();
12718
+ }
12719
+ } else {
12720
+ if (needsUpscaling) {
12721
+ if (this._needsUpscaleRebuild) {
12722
+ await this._createUpscaledTexture();
12723
+ this._needsUpscaleRebuild = false;
12724
+ }
12725
+ if (this.inputTexture && this.upscaledTexture) {
12726
+ this._upscaleInput();
12727
+ }
12728
+ }
12729
+ if (this._needsRebuild || !this.pipeline) {
12730
+ await this._buildPipeline();
12731
+ }
12732
+ }
12733
+ if (!this.pipeline) return;
12734
+ const uniformData = new Float32Array(32);
12735
+ uniformData[0] = this.canvasWidth;
12736
+ uniformData[1] = this.canvasHeight;
12737
+ const inputW = needsUpscaling && this.upscaledTexture ? this.upscaledTexture.width : this.inputTexture?.width || this.canvasWidth;
12738
+ const inputH = needsUpscaling && this.upscaledTexture ? this.upscaledTexture.height : this.inputTexture?.height || this.canvasHeight;
12739
+ uniformData[2] = inputW;
12740
+ uniformData[3] = inputH;
12741
+ const renderW = this.renderWidth || this.canvasWidth;
12742
+ const renderH = this.renderHeight || this.canvasHeight;
12743
+ uniformData[4] = renderW;
12744
+ uniformData[5] = renderH;
12745
+ if (!this._loggedDimensions) {
12746
+ const renderScale = renderW > 0 ? (renderW / this.canvasWidth).toFixed(2) : "?";
12747
+ const upscaleInfo = needsUpscaling ? `upscale=${this.upscaledTexture?.scale || "?"}x` : "no-upscale (direct)";
12748
+ console.log(`CRT: canvas=${this.canvasWidth}x${this.canvasHeight}, render=${renderW}x${renderH} (scale ${renderScale}), input=${inputW}x${inputH}, ${upscaleInfo}`);
12749
+ console.log(`CRT: Scanlines use fragment coords (${this.canvasHeight}px), should repeat every ${this.scanlineHeight}px = ${Math.floor(this.canvasHeight / this.scanlineHeight)} scanlines`);
12750
+ this._loggedDimensions = true;
12751
+ }
12752
+ uniformData[6] = this.curvature;
12753
+ uniformData[7] = this.cornerRadius;
12754
+ uniformData[8] = this.zoom;
12755
+ uniformData[9] = 0;
12756
+ uniformData[10] = this.scanlineIntensity;
12757
+ uniformData[11] = this.scanlineWidth;
12758
+ uniformData[12] = this.scanlineBrightBoost;
12759
+ uniformData[13] = this.scanlineHeight;
12760
+ uniformData[14] = 0;
12761
+ uniformData[15] = 0;
12762
+ const conv = this.convergence;
12763
+ uniformData[16] = conv[0];
12764
+ uniformData[17] = conv[1];
12765
+ uniformData[18] = conv[2];
12766
+ uniformData[19] = 0;
12767
+ uniformData[20] = this.maskType;
12768
+ uniformData[21] = this.maskIntensity;
12769
+ uniformData[22] = this.maskScale;
12770
+ const maskCompensation = this._calculateMaskCompensation(this.maskType, this.maskIntensity);
12771
+ uniformData[23] = maskCompensation;
12772
+ uniformData[24] = this.vignetteIntensity;
12773
+ uniformData[25] = this.vignetteSize;
12774
+ uniformData[26] = this.blurSize;
12775
+ uniformData[27] = this.crtEnabled ? 1 : 0;
12776
+ uniformData[28] = this.upscaleEnabled ? 1 : 0;
12777
+ device.queue.writeBuffer(this.pipeline.uniformBuffer, 0, uniformData);
12778
+ const commandEncoder = device.createCommandEncoder({ label: "CRT Render" });
12779
+ const canvasTexture = this.engine.context.getCurrentTexture();
12780
+ const renderPass = commandEncoder.beginRenderPass({
12781
+ colorAttachments: [{
12782
+ view: canvasTexture.createView(),
12783
+ loadOp: "clear",
12784
+ storeOp: "store",
12785
+ clearValue: { r: 0, g: 0, b: 0, a: 1 }
12786
+ }]
12787
+ });
12788
+ renderPass.setPipeline(this.pipeline.pipeline);
12789
+ renderPass.setBindGroup(0, this.pipeline.bindGroup);
12790
+ renderPass.draw(3, 1, 0, 0);
12791
+ renderPass.end();
12792
+ device.queue.submit([commandEncoder.finish()]);
11216
12793
  }
11217
12794
  async _resize(width, height) {
12795
+ this.canvasWidth = width;
12796
+ this.canvasHeight = height;
12797
+ this._needsUpscaleRebuild = true;
11218
12798
  this._needsRebuild = true;
12799
+ this._loggedDimensions = false;
11219
12800
  }
11220
12801
  _destroy() {
11221
- this.pipeline = null;
11222
- if (this.dummyBloomTexture?.texture) {
11223
- this.dummyBloomTexture.texture.destroy();
11224
- this.dummyBloomTexture = null;
12802
+ if (this.pipeline?.uniformBuffer) {
12803
+ this.pipeline.uniformBuffer.destroy();
11225
12804
  }
11226
- if (this.guiTexture?.texture) {
11227
- this.guiTexture.texture.destroy();
11228
- this.guiTexture = null;
12805
+ if (this.upscaledTexture?.texture) {
12806
+ this.upscaledTexture.texture.destroy();
11229
12807
  }
11230
- if (this.dummyGuiTexture?.texture) {
11231
- this.dummyGuiTexture.texture.destroy();
11232
- this.dummyGuiTexture = null;
12808
+ if (this.phosphorMaskTexture?.texture) {
12809
+ this.phosphorMaskTexture.texture.destroy();
11233
12810
  }
12811
+ this.pipeline = null;
12812
+ this._blitPipeline = null;
11234
12813
  }
11235
12814
  }
11236
12815
  class AmbientCapturePass extends BasePass {
@@ -13394,8 +14973,10 @@ class RenderGraph {
13394
14973
  // Pass 11: Forward transparent objects
13395
14974
  particles: null,
13396
14975
  // Pass 12: GPU particle rendering
13397
- postProcess: null
14976
+ postProcess: null,
13398
14977
  // Pass 13: Tone mapping + bloom composite
14978
+ crt: null
14979
+ // Pass 14: CRT effect (optional)
13399
14980
  };
13400
14981
  this.historyManager = null;
13401
14982
  this.cullingSystem = new CullingSystem(engine);
@@ -13468,7 +15049,9 @@ class RenderGraph {
13468
15049
  this.passes.transparent = new TransparentPass(this.engine);
13469
15050
  this.passes.particles = new ParticlePass(this.engine);
13470
15051
  this.passes.fog = new FogPass(this.engine);
15052
+ this.passes.volumetricFog = new VolumetricFogPass(this.engine);
13471
15053
  this.passes.postProcess = new PostProcessPass(this.engine);
15054
+ this.passes.crt = new CRTPass(this.engine);
13472
15055
  const { canvas } = this.engine;
13473
15056
  this.historyManager = new HistoryBufferManager(this.engine);
13474
15057
  start = performance.now();
@@ -13520,9 +15103,15 @@ class RenderGraph {
13520
15103
  await this.passes.fog.initialize();
13521
15104
  timings.push({ name: "init:fog", time: performance.now() - start });
13522
15105
  start = performance.now();
15106
+ await this.passes.volumetricFog.initialize();
15107
+ timings.push({ name: "init:volumetricFog", time: performance.now() - start });
15108
+ start = performance.now();
13523
15109
  await this.passes.postProcess.initialize();
13524
15110
  timings.push({ name: "init:postProcess", time: performance.now() - start });
13525
15111
  start = performance.now();
15112
+ await this.passes.crt.initialize();
15113
+ timings.push({ name: "init:crt", time: performance.now() - start });
15114
+ start = performance.now();
13526
15115
  this.passes.reflection.setFallbackEnvironment(environmentMap, this.environmentEncoding);
13527
15116
  this.passes.lighting.setEnvironmentMap(environmentMap, this.environmentEncoding);
13528
15117
  await this.passes.lighting.setGBuffer(this.passes.gbuffer.getGBuffer());
@@ -13586,11 +15175,16 @@ class RenderGraph {
13586
15175
  this.passes.particles.setShadowPass(this.passes.shadow);
13587
15176
  this.passes.particles.setEnvironmentMap(environmentMap, this.environmentEncoding);
13588
15177
  this.passes.particles.setLightingPass(this.passes.lighting);
15178
+ this.passes.volumetricFog.setGBuffer(this.passes.gbuffer.getGBuffer());
15179
+ this.passes.volumetricFog.setShadowPass(this.passes.shadow);
15180
+ this.passes.volumetricFog.setLightingPass(this.passes.lighting);
15181
+ this.passes.volumetricFog.setHiZPass(this.passes.hiz);
13589
15182
  this.passes.postProcess.setInputTexture(this.passes.lighting.getOutputTexture());
13590
15183
  this.passes.postProcess.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
13591
15184
  if (this.engine?.guiCanvas) {
13592
15185
  this.passes.postProcess.setGuiCanvas(this.engine.guiCanvas);
13593
15186
  }
15187
+ this.invalidateOcclusionCulling();
13594
15188
  }
13595
15189
  /**
13596
15190
  * Render a frame using the new entity/asset system
@@ -13843,10 +15437,6 @@ class RenderGraph {
13843
15437
  this.passes.transparent.distanceFadeStart = transparentMaxDist * transparentFadeStart;
13844
15438
  await this.passes.transparent.execute(passContext);
13845
15439
  }
13846
- if (this.passes.particles && this.particleSystem.getActiveEmitters().length > 0) {
13847
- this.passes.particles.setOutputTexture(hdrSource);
13848
- await this.passes.particles.execute(passContext);
13849
- }
13850
15440
  const fogEnabled = this.engine?.settings?.environment?.fog?.enabled;
13851
15441
  if (this.passes.fog && fogEnabled) {
13852
15442
  this.passes.fog.setInputTexture(hdrSource);
@@ -13857,6 +15447,20 @@ class RenderGraph {
13857
15447
  hdrSource = fogOutput;
13858
15448
  }
13859
15449
  }
15450
+ if (this.passes.particles && this.particleSystem.getActiveEmitters().length > 0) {
15451
+ this.passes.particles.setOutputTexture(hdrSource);
15452
+ await this.passes.particles.execute(passContext);
15453
+ }
15454
+ const volumetricFogEnabled = this.engine?.settings?.volumetricFog?.enabled;
15455
+ if (this.passes.volumetricFog && volumetricFogEnabled) {
15456
+ this.passes.volumetricFog.setInputTexture(hdrSource);
15457
+ this.passes.volumetricFog.setGBuffer(gbuffer);
15458
+ await this.passes.volumetricFog.execute(passContext);
15459
+ const volFogOutput = this.passes.volumetricFog.getOutputTexture();
15460
+ if (volFogOutput && volFogOutput !== hdrSource) {
15461
+ hdrSource = volFogOutput;
15462
+ }
15463
+ }
13860
15464
  const bloomEnabled = this.engine?.settings?.bloom?.enabled;
13861
15465
  if (this.passes.bloom && bloomEnabled) {
13862
15466
  this.passes.bloom.setInputTexture(hdrSource);
@@ -13867,6 +15471,19 @@ class RenderGraph {
13867
15471
  }
13868
15472
  this.passes.postProcess.setInputTexture(hdrSource);
13869
15473
  await this.passes.postProcess.execute(passContext);
15474
+ const crtEnabled = this.engine?.settings?.crt?.enabled;
15475
+ const crtUpscaleEnabled = this.engine?.settings?.crt?.upscaleEnabled;
15476
+ if (crtEnabled || crtUpscaleEnabled) {
15477
+ const postProcessOutput = this.passes.postProcess.getOutputTexture();
15478
+ if (postProcessOutput) {
15479
+ this.passes.crt.setInputTexture(postProcessOutput);
15480
+ this.passes.crt.setRenderSize(
15481
+ this.passes.gbuffer.getGBuffer()?.depth?.width || canvas.width,
15482
+ this.passes.gbuffer.getGBuffer()?.depth?.height || canvas.height
15483
+ );
15484
+ await this.passes.crt.execute(passContext);
15485
+ }
15486
+ }
13870
15487
  this.historyManager.swap(camera);
13871
15488
  this._lastRenderContext = {
13872
15489
  meshes,
@@ -13931,6 +15548,40 @@ class RenderGraph {
13931
15548
  }
13932
15549
  }
13933
15550
  const asset = assetManager.get(modelId);
15551
+ if (asset?.meshNames && !asset.mesh) {
15552
+ for (const meshName of asset.meshNames) {
15553
+ const submeshId = assetManager.createModelId(modelId, meshName);
15554
+ const submeshAsset = assetManager.get(submeshId);
15555
+ if (!submeshAsset?.mesh) continue;
15556
+ if (submeshAsset.hasSkin && submeshAsset.skin) {
15557
+ for (const item of entities) {
15558
+ const entity = item.entity;
15559
+ const animation = entity.animation || "default";
15560
+ const phase = entity.phase || 0;
15561
+ const quantizedPhase = Math.floor(phase / 0.05) * 0.05;
15562
+ const key = `${submeshId}|${animation}|${quantizedPhase.toFixed(2)}`;
15563
+ if (!skinnedInstancedGroups.has(key)) {
15564
+ skinnedInstancedGroups.set(key, {
15565
+ modelId: submeshId,
15566
+ animation,
15567
+ phase: quantizedPhase,
15568
+ asset: submeshAsset,
15569
+ entities: []
15570
+ });
15571
+ }
15572
+ skinnedInstancedGroups.get(key).entities.push(item);
15573
+ }
15574
+ } else {
15575
+ if (!nonSkinnedGroups.has(submeshId)) {
15576
+ nonSkinnedGroups.set(submeshId, { asset: submeshAsset, entities: [] });
15577
+ }
15578
+ for (const item of entities) {
15579
+ nonSkinnedGroups.get(submeshId).entities.push(item);
15580
+ }
15581
+ }
15582
+ }
15583
+ continue;
15584
+ }
13934
15585
  if (!asset?.mesh) continue;
13935
15586
  if (asset.hasSkin && asset.skin) {
13936
15587
  for (const item of entities) {
@@ -14028,7 +15679,8 @@ class RenderGraph {
14028
15679
  geometry._instanceDataDirty = true;
14029
15680
  updatedMeshes.add(meshName);
14030
15681
  }
14031
- const globalTime = performance.now() / 1e3;
15682
+ const animationSpeed = this.engine?.settings?.animation?.speed ?? 1;
15683
+ const globalTime = performance.now() / 1e3 * animationSpeed;
14032
15684
  for (const { id: entityId, entity, asset, modelId } of skinnedIndividualEntities) {
14033
15685
  const entityAnimation = entity.animation || "default";
14034
15686
  const entityPhase = entity.phase || 0;
@@ -14071,7 +15723,9 @@ class RenderGraph {
14071
15723
  material: asset.mesh.material,
14072
15724
  skin: individualSkin2,
14073
15725
  hasSkin: true,
14074
- uid: `individual_${entityId}`
15726
+ uid: `individual_${entityId}`,
15727
+ // Use asset's combined bsphere for culling (all skinned submeshes share one sphere)
15728
+ combinedBsphere: asset.bsphere || null
14075
15729
  };
14076
15730
  cached = {
14077
15731
  skin: individualSkin2,
@@ -14200,7 +15854,9 @@ class RenderGraph {
14200
15854
  material: asset.mesh.material,
14201
15855
  skin: clonedSkin2,
14202
15856
  hasSkin: true,
14203
- uid: asset.mesh.uid + "_phase_" + key.replace(/[^a-zA-Z0-9]/g, "_")
15857
+ uid: asset.mesh.uid + "_phase_" + key.replace(/[^a-zA-Z0-9]/g, "_"),
15858
+ // Use asset's combined bsphere for culling (all skinned submeshes share one sphere)
15859
+ combinedBsphere: asset.bsphere || null
14204
15860
  };
14205
15861
  cached = { skin: clonedSkin2, mesh: phaseMesh2, geometry: phaseGeometry2 };
14206
15862
  this._skinnedPhaseCache.set(key, cached);
@@ -14335,45 +15991,62 @@ class RenderGraph {
14335
15991
  }
14336
15992
  /**
14337
15993
  * Handle window resize
14338
- * @param {number} width - New width
14339
- * @param {number} height - New height
15994
+ * @param {number} width - Canvas width (full device pixels)
15995
+ * @param {number} height - Canvas height (full device pixels)
15996
+ * @param {number} renderScale - Scale for internal rendering (1.0 = full resolution)
14340
15997
  */
14341
- async resize(width, height) {
15998
+ async resize(width, height, renderScale = 1) {
14342
15999
  const timings = [];
14343
16000
  performance.now();
16001
+ this.canvasWidth = width;
16002
+ this.canvasHeight = height;
16003
+ const renderWidth = Math.max(1, Math.round(width * renderScale));
16004
+ const renderHeight = Math.max(1, Math.round(height * renderScale));
16005
+ this.renderWidth = renderWidth;
16006
+ this.renderHeight = renderHeight;
16007
+ this.renderScale = renderScale;
14344
16008
  const autoScale = this.engine?.settings?.rendering?.autoScale;
14345
16009
  let effectScale = 1;
14346
16010
  if (autoScale && !autoScale.enabled && autoScale.enabledForEffects) {
14347
- if (height > (autoScale.maxHeight ?? 1536)) {
16011
+ if (renderHeight > (autoScale.maxHeight ?? 1536)) {
14348
16012
  effectScale = autoScale.scaleFactor ?? 0.5;
14349
16013
  if (!this._effectScaleWarned) {
14350
- console.log(`Effect auto-scale: Reducing effect resolution by ${effectScale} (height: ${height}px > ${autoScale.maxHeight}px)`);
16014
+ console.log(`Effect auto-scale: Reducing effect resolution by ${effectScale} (height: ${renderHeight}px > ${autoScale.maxHeight}px)`);
14351
16015
  this._effectScaleWarned = true;
14352
16016
  }
14353
16017
  } else if (this._effectScaleWarned) {
14354
- console.log(`Effect auto-scale: Restoring full effect resolution (height: ${height}px <= ${autoScale.maxHeight}px)`);
16018
+ console.log(`Effect auto-scale: Restoring full effect resolution (height: ${renderHeight}px <= ${autoScale.maxHeight}px)`);
14355
16019
  this._effectScaleWarned = false;
14356
16020
  }
14357
16021
  }
14358
- const scaledPasses = /* @__PURE__ */ new Set(["bloom", "ao", "ssgi", "planarReflection"]);
14359
- const effectWidth = Math.max(1, Math.floor(width * effectScale));
14360
- const effectHeight = Math.max(1, Math.floor(height * effectScale));
16022
+ const fullResPasses = /* @__PURE__ */ new Set(["crt"]);
16023
+ const effectScaledPasses = /* @__PURE__ */ new Set(["bloom", "ao", "planarReflection"]);
16024
+ const effectWidth = Math.max(1, Math.floor(renderWidth * effectScale));
16025
+ const effectHeight = Math.max(1, Math.floor(renderHeight * effectScale));
14361
16026
  this.effectWidth = effectWidth;
14362
16027
  this.effectHeight = effectHeight;
14363
16028
  this.effectScale = effectScale;
14364
16029
  for (const passName in this.passes) {
14365
16030
  if (this.passes[passName]) {
14366
16031
  const start2 = performance.now();
14367
- const useScaled = scaledPasses.has(passName) && effectScale < 1;
14368
- const w = useScaled ? effectWidth : width;
14369
- const h = useScaled ? effectHeight : height;
16032
+ let w, h;
16033
+ if (fullResPasses.has(passName)) {
16034
+ w = width;
16035
+ h = height;
16036
+ } else if (effectScaledPasses.has(passName) && effectScale < 1) {
16037
+ w = effectWidth;
16038
+ h = effectHeight;
16039
+ } else {
16040
+ w = renderWidth;
16041
+ h = renderHeight;
16042
+ }
14370
16043
  await this.passes[passName].resize(w, h);
14371
16044
  timings.push({ name: `pass:${passName}`, time: performance.now() - start2 });
14372
16045
  }
14373
16046
  }
14374
16047
  if (this.historyManager) {
14375
16048
  const start2 = performance.now();
14376
- await this.historyManager.resize(width, height);
16049
+ await this.historyManager.resize(renderWidth, renderHeight);
14377
16050
  timings.push({ name: "historyManager", time: performance.now() - start2 });
14378
16051
  }
14379
16052
  let start = performance.now();
@@ -14392,8 +16065,18 @@ class RenderGraph {
14392
16065
  this.passes.lighting.setHiZPass(this.passes.hiz);
14393
16066
  this.passes.transparent.setHiZPass(this.passes.hiz);
14394
16067
  this.passes.shadow.setHiZPass(this.passes.hiz);
16068
+ if (this.passes.volumetricFog) {
16069
+ this.passes.volumetricFog.setHiZPass(this.passes.hiz);
16070
+ }
14395
16071
  }
14396
16072
  timings.push({ name: "rewire:hiz", time: performance.now() - start });
16073
+ if (this.passes.volumetricFog) {
16074
+ start = performance.now();
16075
+ this.passes.volumetricFog.setGBuffer(this.passes.gbuffer.getGBuffer());
16076
+ this.passes.volumetricFog.setShadowPass(this.passes.shadow);
16077
+ this.passes.volumetricFog.setLightingPass(this.passes.lighting);
16078
+ timings.push({ name: "rewire:volumetricFog", time: performance.now() - start });
16079
+ }
14397
16080
  start = performance.now();
14398
16081
  this.passes.shadow.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
14399
16082
  timings.push({ name: "rewire:shadow.setNoise", time: performance.now() - start });
@@ -14771,8 +16454,18 @@ class RenderGraph {
14771
16454
  * @param {string} passName - Name of pass
14772
16455
  * @returns {BasePass} The pass instance
14773
16456
  */
14774
- getPass(passName) {
14775
- return this.passes[passName];
16457
+ getPass(passName) {
16458
+ return this.passes[passName];
16459
+ }
16460
+ /**
16461
+ * Invalidate occlusion culling data and reset warmup period.
16462
+ * Call this after scene loading or major camera changes to prevent
16463
+ * incorrect occlusion culling with stale depth buffer data.
16464
+ */
16465
+ invalidateOcclusionCulling() {
16466
+ if (this.passes.hiz) {
16467
+ this.passes.hiz.invalidate();
16468
+ }
14776
16469
  }
14777
16470
  /**
14778
16471
  * Load noise texture based on settings
@@ -14910,6 +16603,38 @@ class RenderGraph {
14910
16603
  height: 8
14911
16604
  };
14912
16605
  }
16606
+ /**
16607
+ * Reload noise texture and update all passes that use it
16608
+ * Called when noise settings change at runtime
16609
+ */
16610
+ async reloadNoiseTexture() {
16611
+ if (this.noiseTexture?.texture) {
16612
+ this.noiseTexture.texture.destroy();
16613
+ }
16614
+ await this._loadNoiseTexture();
16615
+ if (this.passes.gbuffer) {
16616
+ this.passes.gbuffer.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
16617
+ }
16618
+ if (this.passes.shadow) {
16619
+ this.passes.shadow.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
16620
+ }
16621
+ if (this.passes.lighting) {
16622
+ this.passes.lighting.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
16623
+ }
16624
+ if (this.passes.ao) {
16625
+ this.passes.ao.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
16626
+ }
16627
+ if (this.passes.transparent) {
16628
+ this.passes.transparent.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
16629
+ }
16630
+ if (this.passes.postProcess) {
16631
+ this.passes.postProcess.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
16632
+ }
16633
+ if (this.passes.renderPost) {
16634
+ this.passes.renderPost.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
16635
+ }
16636
+ console.log(`RenderGraph: Reloaded noise texture (${this.engine?.settings?.noise?.type || "bluenoise"})`);
16637
+ }
14913
16638
  /**
14914
16639
  * Destroy all resources
14915
16640
  */
@@ -15537,6 +17262,7 @@ class Joint {
15537
17262
  function expandTriangles(attributes, expansion) {
15538
17263
  const positions = attributes.position;
15539
17264
  const normals = attributes.normal;
17265
+ const tangents = attributes.tangent;
15540
17266
  const uvs = attributes.uv;
15541
17267
  const indices = attributes.indices;
15542
17268
  const weights = attributes.weights;
@@ -15545,6 +17271,7 @@ function expandTriangles(attributes, expansion) {
15545
17271
  const triCount = indices.length / 3;
15546
17272
  const newPositions = new Float32Array(triCount * 3 * 3);
15547
17273
  const newNormals = normals ? new Float32Array(triCount * 3 * 3) : null;
17274
+ const newTangents = tangents ? new Float32Array(triCount * 3 * 4) : null;
15548
17275
  const newUvs = uvs ? new Float32Array(triCount * 3 * 2) : null;
15549
17276
  const newWeights = weights ? new Float32Array(triCount * 3 * 4) : null;
15550
17277
  const newJoints = joints ? new Uint16Array(triCount * 3 * 4) : null;
@@ -15580,6 +17307,12 @@ function expandTriangles(attributes, expansion) {
15580
17307
  newNormals[newIdx * 3 + 1] = normals[origIdx * 3 + 1];
15581
17308
  newNormals[newIdx * 3 + 2] = normals[origIdx * 3 + 2];
15582
17309
  }
17310
+ if (newTangents) {
17311
+ newTangents[newIdx * 4 + 0] = tangents[origIdx * 4 + 0];
17312
+ newTangents[newIdx * 4 + 1] = tangents[origIdx * 4 + 1];
17313
+ newTangents[newIdx * 4 + 2] = tangents[origIdx * 4 + 2];
17314
+ newTangents[newIdx * 4 + 3] = tangents[origIdx * 4 + 3];
17315
+ }
15583
17316
  if (newUvs) {
15584
17317
  newUvs[newIdx * 2 + 0] = uvs[origIdx * 2 + 0];
15585
17318
  newUvs[newIdx * 2 + 1] = uvs[origIdx * 2 + 1];
@@ -15602,6 +17335,7 @@ function expandTriangles(attributes, expansion) {
15602
17335
  return {
15603
17336
  position: newPositions,
15604
17337
  normal: newNormals,
17338
+ tangent: newTangents,
15605
17339
  uv: newUvs,
15606
17340
  indices: newIndices,
15607
17341
  weights: newWeights,
@@ -15817,6 +17551,7 @@ async function loadGltfData(engine, url, options = {}) {
15817
17551
  let attrs = {
15818
17552
  position: getAccessor(attributes.POSITION),
15819
17553
  normal: getAccessor(attributes.NORMAL),
17554
+ tangent: getAccessor(attributes.TANGENT),
15820
17555
  uv: getAccessor(attributes.TEXCOORD_0),
15821
17556
  indices: getAccessor(primitive.indices),
15822
17557
  weights: getAccessor(attributes.WEIGHTS_0),
@@ -15935,6 +17670,9 @@ async function loadGltf(engine, url, options = {}) {
15935
17670
  const transmission = mesh.material.extensions.KHR_materials_transmission;
15936
17671
  material.opacity = 1 - (transmission.transmissionFactor ?? 0);
15937
17672
  }
17673
+ if (mesh.material.doubleSided) {
17674
+ material.doubleSided = true;
17675
+ }
15938
17676
  const nmesh = new Mesh(geometry, material, name);
15939
17677
  if (mesh.skinIndex !== void 0 && mesh.skinIndex !== null && skins[mesh.skinIndex]) {
15940
17678
  nmesh.skin = skins[mesh.skinIndex];
@@ -16444,6 +18182,22 @@ class AssetManager {
16444
18182
  try {
16445
18183
  const result = await loadGltf(this.engine, path, options);
16446
18184
  const meshNames = Object.keys(result.meshes);
18185
+ let combinedBsphere = null;
18186
+ const hasAnySkin = Object.values(result.meshes).some((m) => m.hasSkin);
18187
+ if (hasAnySkin) {
18188
+ const allPositions = [];
18189
+ for (const mesh of Object.values(result.meshes)) {
18190
+ if (mesh.geometry?.attributes?.position) {
18191
+ const positions = mesh.geometry.attributes.position;
18192
+ for (let i = 0; i < positions.length; i += 3) {
18193
+ allPositions.push(positions[i], positions[i + 1], positions[i + 2]);
18194
+ }
18195
+ }
18196
+ }
18197
+ if (allPositions.length > 0) {
18198
+ combinedBsphere = calculateBoundingSphere(new Float32Array(allPositions));
18199
+ }
18200
+ }
16447
18201
  this.assets[path] = {
16448
18202
  gltf: result,
16449
18203
  meshes: result.meshes,
@@ -16451,12 +18205,17 @@ class AssetManager {
16451
18205
  animations: result.animations,
16452
18206
  nodes: result.nodes,
16453
18207
  meshNames,
18208
+ bsphere: combinedBsphere,
18209
+ // Combined bsphere for parent path entities
18210
+ hasSkin: hasAnySkin,
18211
+ // Flag for skinned model detection
16454
18212
  ready: true,
16455
18213
  loading: false
16456
18214
  };
16457
18215
  for (const meshName of meshNames) {
16458
- const modelId = this.createModelId(path, meshName);
16459
- await this._registerMesh(path, meshName, result.meshes[meshName]);
18216
+ const mesh = result.meshes[meshName];
18217
+ const bsphere = hasAnySkin && combinedBsphere ? combinedBsphere : null;
18218
+ await this._registerMesh(path, meshName, mesh, bsphere);
16460
18219
  }
16461
18220
  this._triggerReady(path);
16462
18221
  return this.assets[path];
@@ -16472,10 +18231,14 @@ class AssetManager {
16472
18231
  }
16473
18232
  /**
16474
18233
  * Register a mesh asset (internal)
18234
+ * @param {string} path - GLTF file path
18235
+ * @param {string} meshName - Mesh name
18236
+ * @param {Object} mesh - Mesh object
18237
+ * @param {Object|null} overrideBsphere - Optional bounding sphere (for skinned mesh combined sphere)
16475
18238
  */
16476
- async _registerMesh(path, meshName, mesh) {
18239
+ async _registerMesh(path, meshName, mesh, overrideBsphere = null) {
16477
18240
  const modelId = this.createModelId(path, meshName);
16478
- const bsphere = calculateBoundingSphere(mesh.geometry.attributes.position);
18241
+ const bsphere = overrideBsphere || calculateBoundingSphere(mesh.geometry.attributes.position);
16479
18242
  this.assets[modelId] = {
16480
18243
  mesh,
16481
18244
  geometry: mesh.geometry,
@@ -16672,19 +18435,20 @@ class DebugUI {
16672
18435
  this._addStyles();
16673
18436
  this._createStatsFolder();
16674
18437
  this._createRenderingFolder();
16675
- this._createAOFolder();
16676
- this._createShadowFolder();
16677
- this._createMainLightFolder();
18438
+ this._createCameraFolder();
16678
18439
  this._createEnvironmentFolder();
16679
18440
  this._createLightingFolder();
16680
- this._createCullingFolder();
16681
- this._createSSGIFolder();
16682
- this._createBloomFolder();
18441
+ this._createMainLightFolder();
18442
+ this._createShadowFolder();
16683
18443
  this._createPlanarReflectionFolder();
18444
+ this._createAOFolder();
16684
18445
  this._createAmbientCaptureFolder();
16685
- this._createNoiseFolder();
18446
+ this._createSSGIFolder();
18447
+ this._createVolumetricFogFolder();
18448
+ this._createBloomFolder();
18449
+ this._createTonemapFolder();
16686
18450
  this._createDitheringFolder();
16687
- this._createCameraFolder();
18451
+ this._createCRTFolder();
16688
18452
  this._createDebugFolder();
16689
18453
  for (const folder of Object.values(this.folders)) {
16690
18454
  folder.close();
@@ -16828,6 +18592,33 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
16828
18592
  this.gui.domElement.style.display = "none";
16829
18593
  }
16830
18594
  });
18595
+ if (s.culling) {
18596
+ const cullFolder = folder.addFolder("Culling");
18597
+ cullFolder.add(s.culling, "frustumEnabled").name("Frustum Culling");
18598
+ if (s.occlusionCulling) {
18599
+ cullFolder.add(s.occlusionCulling, "enabled").name("Occlusion Culling");
18600
+ cullFolder.add(s.occlusionCulling, "threshold", 0.1, 2, 0.1).name("Occlusion Threshold");
18601
+ }
18602
+ if (s.culling.planarReflection) {
18603
+ const prFolder = cullFolder.addFolder("Planar Reflection");
18604
+ prFolder.add(s.culling.planarReflection, "frustum").name("Frustum Culling");
18605
+ prFolder.add(s.culling.planarReflection, "maxDistance", 10, 200, 10).name("Max Distance");
18606
+ prFolder.add(s.culling.planarReflection, "maxSkinned", 0, 100, 1).name("Max Skinned");
18607
+ prFolder.add(s.culling.planarReflection, "minPixelSize", 0, 16, 1).name("Min Pixel Size");
18608
+ prFolder.close();
18609
+ }
18610
+ cullFolder.close();
18611
+ }
18612
+ if (s.noise) {
18613
+ const noiseFolder = folder.addFolder("Noise");
18614
+ noiseFolder.add(s.noise, "type", ["bluenoise", "bayer8"]).name("Type").onChange(() => {
18615
+ if (this.engine.renderer?.renderGraph) {
18616
+ this.engine.renderer.renderGraph.reloadNoiseTexture();
18617
+ }
18618
+ });
18619
+ noiseFolder.add(s.noise, "animated").name("Animated");
18620
+ noiseFolder.close();
18621
+ }
16831
18622
  }
16832
18623
  _createAOFolder() {
16833
18624
  const s = this.engine.settings;
@@ -16906,6 +18697,8 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
16906
18697
  fogFolder.add(s.environment.fog.heightFade, "0", -100, 100, 1).name("Bottom Y");
16907
18698
  fogFolder.add(s.environment.fog.heightFade, "1", -50, 200, 5).name("Top Y");
16908
18699
  fogFolder.add(s.environment.fog, "brightResist", 0, 1, 0.05).name("Bright Resist");
18700
+ if (s.environment.fog.debug === void 0) s.environment.fog.debug = 0;
18701
+ fogFolder.add(s.environment.fog, "debug", 0, 10, 1).name("Debug Mode");
16909
18702
  }
16910
18703
  }
16911
18704
  _createLightingFolder() {
@@ -16922,25 +18715,6 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
16922
18715
  folder.add(s.lighting, "specularBoostRoughnessCutoff", 0.1, 1, 0.05).name("Boost Roughness Cutoff");
16923
18716
  }
16924
18717
  }
16925
- _createCullingFolder() {
16926
- const s = this.engine.settings;
16927
- if (!s.culling) return;
16928
- const folder = this.gui.addFolder("Culling");
16929
- this.folders.culling = folder;
16930
- folder.add(s.culling, "frustumEnabled").name("Frustum Culling");
16931
- if (s.occlusionCulling) {
16932
- folder.add(s.occlusionCulling, "enabled").name("Occlusion Culling");
16933
- folder.add(s.occlusionCulling, "threshold", 0.1, 2, 0.1).name("Occlusion Threshold");
16934
- }
16935
- if (s.culling.planarReflection) {
16936
- const prFolder = folder.addFolder("Planar Reflection");
16937
- prFolder.add(s.culling.planarReflection, "frustum").name("Frustum Culling");
16938
- prFolder.add(s.culling.planarReflection, "maxDistance", 10, 200, 10).name("Max Distance");
16939
- prFolder.add(s.culling.planarReflection, "maxSkinned", 0, 100, 1).name("Max Skinned");
16940
- prFolder.add(s.culling.planarReflection, "minPixelSize", 0, 16, 1).name("Min Pixel Size");
16941
- prFolder.close();
16942
- }
16943
- }
16944
18718
  _createSSGIFolder() {
16945
18719
  const s = this.engine.settings;
16946
18720
  if (!s.ssgi) return;
@@ -16953,6 +18727,57 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
16953
18727
  folder.add(s.ssgi, "sampleRadius", 0.5, 4, 0.5).name("Sample Radius");
16954
18728
  folder.add(s.ssgi, "saturateLevel", 0.1, 2, 0.1).name("Saturate Level");
16955
18729
  }
18730
+ _createVolumetricFogFolder() {
18731
+ const s = this.engine.settings;
18732
+ if (!s.volumetricFog) return;
18733
+ const vf = s.volumetricFog;
18734
+ if (vf.density === void 0 && vf.densityMultiplier === void 0) vf.density = 0.5;
18735
+ if (vf.scatterStrength === void 0) vf.scatterStrength = 1;
18736
+ if (!vf.heightRange) vf.heightRange = [-5, 20];
18737
+ if (vf.resolution === void 0) vf.resolution = 0.25;
18738
+ if (vf.maxSamples === void 0) vf.maxSamples = 32;
18739
+ if (vf.blurRadius === void 0) vf.blurRadius = 4;
18740
+ if (vf.noiseStrength === void 0) vf.noiseStrength = 1;
18741
+ if (vf.noiseScale === void 0) vf.noiseScale = 0.25;
18742
+ if (vf.noiseAnimated === void 0) vf.noiseAnimated = true;
18743
+ if (vf.shadowsEnabled === void 0) vf.shadowsEnabled = true;
18744
+ if (vf.mainLightScatter === void 0) vf.mainLightScatter = 1;
18745
+ if (vf.mainLightScatterDark === void 0) vf.mainLightScatterDark = 3;
18746
+ if (vf.mainLightSaturation === void 0) vf.mainLightSaturation = 1;
18747
+ if (vf.brightnessThreshold === void 0) vf.brightnessThreshold = 1;
18748
+ if (vf.minVisibility === void 0) vf.minVisibility = 0.15;
18749
+ if (vf.skyBrightness === void 0) vf.skyBrightness = 5;
18750
+ if (vf.debug === void 0) vf.debug = 0;
18751
+ const folder = this.gui.addFolder("Volumetric Fog");
18752
+ this.folders.volumetricFog = folder;
18753
+ folder.add(vf, "enabled").name("Enabled");
18754
+ if (vf.density !== void 0) {
18755
+ folder.add(vf, "density", 0, 2, 0.05).name("Density");
18756
+ } else if (vf.densityMultiplier !== void 0) {
18757
+ folder.add(vf, "densityMultiplier", 0, 2, 0.05).name("Density");
18758
+ }
18759
+ folder.add(vf, "scatterStrength", 0, 10, 0.1).name("Scatter (Lights)");
18760
+ folder.add(vf, "mainLightScatter", 0, 5, 0.1).name("Sun Scatter (Light)");
18761
+ folder.add(vf, "mainLightScatterDark", 0, 10, 0.1).name("Sun Scatter (Dark)");
18762
+ folder.add(vf, "mainLightSaturation", 0, 1, 0.01).name("Sun Saturation");
18763
+ folder.add(vf, "brightnessThreshold", 0.1, 5, 0.1).name("Bright Threshold");
18764
+ folder.add(vf, "minVisibility", 0, 1, 0.05).name("Min Visibility");
18765
+ folder.add(vf, "skyBrightness", 0, 10, 0.5).name("Sky Brightness");
18766
+ folder.add(vf.heightRange, "0", -50, 50, 1).name("Height Bottom");
18767
+ folder.add(vf.heightRange, "1", -10, 100, 1).name("Height Top");
18768
+ const qualityFolder = folder.addFolder("Quality");
18769
+ qualityFolder.add(vf, "resolution", 0.125, 0.5, 0.125).name("Resolution");
18770
+ qualityFolder.add(vf, "maxSamples", 16, 128, 8).name("Max Samples");
18771
+ qualityFolder.add(vf, "blurRadius", 0, 8, 1).name("Blur Radius");
18772
+ qualityFolder.close();
18773
+ const noiseFolder = folder.addFolder("Noise");
18774
+ noiseFolder.add(vf, "noiseStrength", 0, 1, 0.1).name("Strength");
18775
+ noiseFolder.add(vf, "noiseScale", 0.05, 1, 0.05).name("Scale (Detail)");
18776
+ noiseFolder.add(vf, "noiseAnimated").name("Animated");
18777
+ noiseFolder.close();
18778
+ folder.add(vf, "shadowsEnabled").name("Shadows");
18779
+ folder.add(vf, "debug", 0, 12, 1).name("Debug Mode");
18780
+ }
16956
18781
  _createBloomFolder() {
16957
18782
  const s = this.engine.settings;
16958
18783
  if (!s.bloom) return;
@@ -16969,6 +18794,14 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
16969
18794
  folder.add(s.bloom, "scale", 0.25, 1, 0.25).name("Resolution Scale");
16970
18795
  }
16971
18796
  }
18797
+ _createTonemapFolder() {
18798
+ const s = this.engine.settings;
18799
+ if (!s.rendering) return;
18800
+ if (s.rendering.tonemapMode === void 0) s.rendering.tonemapMode = 0;
18801
+ const folder = this.gui.addFolder("Tone Mapping");
18802
+ this.folders.tonemap = folder;
18803
+ folder.add(s.rendering, "tonemapMode", { "ACES": 0, "Reinhard": 1, "None (Linear)": 2 }).name("Mode");
18804
+ }
16972
18805
  _createPlanarReflectionFolder() {
16973
18806
  const s = this.engine.settings;
16974
18807
  if (!s.planarReflection) return;
@@ -16984,7 +18817,7 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
16984
18817
  _createAmbientCaptureFolder() {
16985
18818
  const s = this.engine.settings;
16986
18819
  if (!s.ambientCapture) return;
16987
- const folder = this.gui.addFolder("Ambient Capture");
18820
+ const folder = this.gui.addFolder("Probe GI (Ambient Capture)");
16988
18821
  this.folders.ambientCapture = folder;
16989
18822
  folder.add(s.ambientCapture, "enabled").name("Enabled");
16990
18823
  folder.add(s.ambientCapture, "intensity", 0, 2, 0.05).name("Intensity");
@@ -16992,14 +18825,6 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
16992
18825
  folder.add(s.ambientCapture, "emissiveBoost", 0, 10, 0.1).name("Emissive Boost");
16993
18826
  folder.add(s.ambientCapture, "saturateLevel", 0, 2, 0.05).name("Saturate Level");
16994
18827
  }
16995
- _createNoiseFolder() {
16996
- const s = this.engine.settings;
16997
- if (!s.noise) return;
16998
- const folder = this.gui.addFolder("Noise");
16999
- this.folders.noise = folder;
17000
- folder.add(s.noise, "type", ["bluenoise", "bayer8"]).name("Type");
17001
- folder.add(s.noise, "animated").name("Animated");
17002
- }
17003
18828
  _createDitheringFolder() {
17004
18829
  const s = this.engine.settings;
17005
18830
  if (!s.dithering) return;
@@ -17008,6 +18833,40 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
17008
18833
  folder.add(s.dithering, "enabled").name("Enabled");
17009
18834
  folder.add(s.dithering, "colorLevels", 4, 256, 1).name("Color Levels");
17010
18835
  }
18836
+ _createCRTFolder() {
18837
+ const s = this.engine.settings;
18838
+ if (!s.crt) return;
18839
+ const folder = this.gui.addFolder("CRT Effect");
18840
+ this.folders.crt = folder;
18841
+ folder.add(s.crt, "enabled").name("CRT Enabled");
18842
+ folder.add(s.crt, "upscaleEnabled").name("Upscale Only");
18843
+ folder.add(s.crt, "upscaleTarget", 1, 8, 1).name("Upscale Target");
18844
+ const geomFolder = folder.addFolder("Geometry");
18845
+ geomFolder.add(s.crt, "curvature", 0, 0.25, 5e-3).name("Curvature");
18846
+ geomFolder.add(s.crt, "cornerRadius", 0, 0.2, 5e-3).name("Corner Radius");
18847
+ geomFolder.add(s.crt, "zoom", 1, 1.25, 5e-3).name("Zoom");
18848
+ geomFolder.close();
18849
+ const scanFolder = folder.addFolder("Scanlines");
18850
+ scanFolder.add(s.crt, "scanlineIntensity", 0, 1, 0.05).name("Intensity");
18851
+ scanFolder.add(s.crt, "scanlineWidth", 0, 1, 0.05).name("Width");
18852
+ scanFolder.add(s.crt, "scanlineBrightBoost", 0, 2, 0.05).name("Bright Boost");
18853
+ scanFolder.add(s.crt, "scanlineHeight", 1, 10, 1).name("Height (px)");
18854
+ scanFolder.close();
18855
+ const convFolder = folder.addFolder("RGB Convergence");
18856
+ convFolder.add(s.crt.convergence, "0", -3, 3, 0.1).name("Red X Offset");
18857
+ convFolder.add(s.crt.convergence, "1", -3, 3, 0.1).name("Green X Offset");
18858
+ convFolder.add(s.crt.convergence, "2", -3, 3, 0.1).name("Blue X Offset");
18859
+ convFolder.close();
18860
+ const maskFolder = folder.addFolder("Phosphor Mask");
18861
+ maskFolder.add(s.crt, "maskType", ["none", "aperture", "slot", "shadow"]).name("Type");
18862
+ maskFolder.add(s.crt, "maskIntensity", 0, 1, 0.05).name("Intensity");
18863
+ maskFolder.close();
18864
+ const vigFolder = folder.addFolder("Vignette");
18865
+ vigFolder.add(s.crt, "vignetteIntensity", 0, 1, 0.05).name("Intensity");
18866
+ vigFolder.add(s.crt, "vignetteSize", 0.1, 1, 0.05).name("Size");
18867
+ vigFolder.close();
18868
+ folder.add(s.crt, "blurSize", 0, 8, 0.1).name("H-Blur (px)");
18869
+ }
17011
18870
  _createCameraFolder() {
17012
18871
  const s = this.engine.settings;
17013
18872
  if (!s.camera) return;
@@ -17087,6 +18946,619 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
17087
18946
  } : { r: 1, g: 1, b: 1 };
17088
18947
  }
17089
18948
  }
18949
+ class Raycaster {
18950
+ constructor(engine) {
18951
+ this.engine = engine;
18952
+ this.worker = null;
18953
+ this._pendingCallbacks = /* @__PURE__ */ new Map();
18954
+ this._nextRequestId = 0;
18955
+ this._initialized = false;
18956
+ }
18957
+ async initialize() {
18958
+ const workerCode = this._getWorkerCode();
18959
+ const blob = new Blob([workerCode], { type: "application/javascript" });
18960
+ const workerUrl = URL.createObjectURL(blob);
18961
+ this.worker = new Worker(workerUrl);
18962
+ this.worker.onmessage = this._handleWorkerMessage.bind(this);
18963
+ this.worker.onerror = (e) => console.error("Raycaster worker error:", e);
18964
+ this._initialized = true;
18965
+ URL.revokeObjectURL(workerUrl);
18966
+ }
18967
+ /**
18968
+ * Cast a ray and get the closest intersection
18969
+ * @param {Array|vec3} origin - Ray start point [x, y, z]
18970
+ * @param {Array|vec3} direction - Ray direction (will be normalized)
18971
+ * @param {number} maxDistance - Maximum ray length
18972
+ * @param {Function} callback - Called with result: { hit, distance, point, normal, entity, mesh, triangleIndex }
18973
+ * @param {Object} options - Optional settings
18974
+ * @param {Array} options.entities - Specific entities to test (default: all scene entities)
18975
+ * @param {Array} options.meshes - Specific meshes to test (default: all scene meshes)
18976
+ * @param {boolean} options.backfaces - Test backfaces (default: false)
18977
+ * @param {Array} options.exclude - Entities/meshes to exclude
18978
+ */
18979
+ cast(origin, direction, maxDistance, callback, options = {}) {
18980
+ if (!this._initialized) {
18981
+ console.warn("Raycaster not initialized");
18982
+ callback({ hit: false, error: "not initialized" });
18983
+ return;
18984
+ }
18985
+ const ray = {
18986
+ origin: Array.from(origin),
18987
+ direction: this._normalize(Array.from(direction)),
18988
+ maxDistance
18989
+ };
18990
+ const candidates = this._collectCandidates(ray, options);
18991
+ if (candidates.length === 0) {
18992
+ callback({ hit: false });
18993
+ return;
18994
+ }
18995
+ const requestId = this._nextRequestId++;
18996
+ this._pendingCallbacks.set(requestId, { callback, candidates });
18997
+ this.worker.postMessage({
18998
+ type: "raycast",
18999
+ requestId,
19000
+ ray,
19001
+ debug: options.debug ?? false,
19002
+ candidates: candidates.map((c) => ({
19003
+ id: c.id,
19004
+ vertices: c.vertices,
19005
+ indices: c.indices,
19006
+ matrix: c.matrix,
19007
+ backfaces: options.backfaces ?? false
19008
+ }))
19009
+ });
19010
+ }
19011
+ /**
19012
+ * Cast a ray upward from a position to check for sky visibility
19013
+ * Useful for determining if camera is under cover
19014
+ * @param {Array|vec3} position - Position to test from
19015
+ * @param {number} maxDistance - How far to check (default: 100)
19016
+ * @param {Function} callback - Called with { hitSky: boolean, distance?: number, entity?: object }
19017
+ */
19018
+ castToSky(position, maxDistance, callback) {
19019
+ this.cast(
19020
+ position,
19021
+ [0, 1, 0],
19022
+ // Straight up
19023
+ maxDistance ?? 100,
19024
+ (result) => {
19025
+ callback({
19026
+ hitSky: !result.hit,
19027
+ distance: result.distance,
19028
+ entity: result.entity,
19029
+ mesh: result.mesh
19030
+ });
19031
+ }
19032
+ );
19033
+ }
19034
+ /**
19035
+ * Cast a ray from screen coordinates (mouse picking)
19036
+ * @param {number} screenX - Screen X coordinate
19037
+ * @param {number} screenY - Screen Y coordinate
19038
+ * @param {Object} camera - Camera with projection/view matrices
19039
+ * @param {Function} callback - Called with intersection result
19040
+ * @param {Object} options - Cast options
19041
+ */
19042
+ castFromScreen(screenX, screenY, camera, callback, options = {}) {
19043
+ const { width, height } = this.engine.canvas;
19044
+ const ndcX = screenX / width * 2 - 1;
19045
+ const ndcY = 1 - screenY / height * 2;
19046
+ const invViewProj = mat4$1.create();
19047
+ mat4$1.multiply(invViewProj, camera.proj, camera.view);
19048
+ mat4$1.invert(invViewProj, invViewProj);
19049
+ const nearPoint = this._unproject([ndcX, ndcY, 0], invViewProj);
19050
+ const farPoint = this._unproject([ndcX, ndcY, 1], invViewProj);
19051
+ const direction = [
19052
+ farPoint[0] - nearPoint[0],
19053
+ farPoint[1] - nearPoint[1],
19054
+ farPoint[2] - nearPoint[2]
19055
+ ];
19056
+ const maxDistance = options.maxDistance ?? camera.far ?? 1e3;
19057
+ this.cast(nearPoint, direction, maxDistance, callback, options);
19058
+ }
19059
+ /**
19060
+ * Collect candidate geometries that pass bounding sphere test
19061
+ */
19062
+ _collectCandidates(ray, options) {
19063
+ const candidates = [];
19064
+ const exclude = new Set(options.exclude ?? []);
19065
+ const debug = options.debug;
19066
+ const entities = options.entities ?? this._getAllEntities();
19067
+ const assetManager = this.engine.assetManager;
19068
+ for (const entity of entities) {
19069
+ if (exclude.has(entity)) continue;
19070
+ if (!entity.model) continue;
19071
+ const asset = assetManager?.get(entity.model);
19072
+ if (!asset?.geometry) continue;
19073
+ const bsphere = this._getEntityBoundingSphere(entity);
19074
+ if (!bsphere) continue;
19075
+ if (this._raySphereIntersect(ray, bsphere)) {
19076
+ const geometryData = this._extractGeometry(asset.geometry);
19077
+ if (geometryData) {
19078
+ const matrix = entity._matrix ?? mat4$1.create();
19079
+ candidates.push({
19080
+ id: entity.id ?? entity.name ?? `entity_${candidates.length}`,
19081
+ type: "entity",
19082
+ entity,
19083
+ asset,
19084
+ vertices: geometryData.vertices,
19085
+ indices: geometryData.indices,
19086
+ matrix: Array.from(matrix),
19087
+ bsphereDistance: this._raySphereDistance(ray, bsphere)
19088
+ });
19089
+ }
19090
+ }
19091
+ }
19092
+ const meshes = options.meshes ?? this._getAllMeshes();
19093
+ let debugStats = debug ? { total: 0, noGeom: 0, noBsphere: 0, noData: 0, sphereMiss: 0, candidates: 0 } : null;
19094
+ for (const [name, mesh] of Object.entries(meshes)) {
19095
+ if (exclude.has(mesh)) continue;
19096
+ if (!mesh.geometry) {
19097
+ if (debug) debugStats.noGeom++;
19098
+ continue;
19099
+ }
19100
+ const bsphere = this._getMeshBoundingSphere(mesh);
19101
+ if (!bsphere) {
19102
+ if (debug) debugStats.noBsphere++;
19103
+ continue;
19104
+ }
19105
+ const geometryData = this._extractGeometry(mesh.geometry);
19106
+ if (!geometryData) {
19107
+ if (debug) debugStats.noData++;
19108
+ continue;
19109
+ }
19110
+ if (debug) debugStats.total++;
19111
+ let instanceCount = mesh.geometry.instanceCount ?? 0;
19112
+ if (instanceCount === 0) {
19113
+ if (mesh.geometry.instanceData) {
19114
+ instanceCount = mesh.static ? mesh.geometry.maxInstances ?? 1 : 1;
19115
+ } else {
19116
+ instanceCount = 1;
19117
+ }
19118
+ }
19119
+ for (let i = 0; i < instanceCount; i++) {
19120
+ const matrix = this._getInstanceMatrix(mesh.geometry, i);
19121
+ const instanceBsphere = this._transformBoundingSphere(bsphere, matrix);
19122
+ if (this._raySphereIntersect(ray, instanceBsphere)) {
19123
+ if (debug) debugStats.candidates++;
19124
+ candidates.push({
19125
+ id: `${name}_${i}`,
19126
+ type: "mesh",
19127
+ mesh,
19128
+ meshName: name,
19129
+ instanceIndex: i,
19130
+ vertices: geometryData.vertices,
19131
+ indices: geometryData.indices,
19132
+ matrix: Array.from(matrix),
19133
+ bsphereDistance: this._raySphereDistance(ray, instanceBsphere)
19134
+ });
19135
+ } else {
19136
+ if (debug) debugStats.sphereMiss++;
19137
+ }
19138
+ }
19139
+ }
19140
+ if (debug && debugStats) {
19141
+ console.log(`Raycaster: meshes=${debugStats.total}, sphereHit=${debugStats.candidates}, sphereMiss=${debugStats.sphereMiss}`);
19142
+ if (candidates.length > 0 && candidates.length < 50) {
19143
+ const candInfo = candidates.map((c) => {
19144
+ const m = c.matrix;
19145
+ const pos = [m[12], m[13], m[14]];
19146
+ return `${c.id}@[${pos.map((v) => v.toFixed(1)).join(",")}]`;
19147
+ }).join(", ");
19148
+ console.log(`Candidates: ${candInfo}`);
19149
+ }
19150
+ }
19151
+ candidates.sort((a, b) => a.bsphereDistance - b.bsphereDistance);
19152
+ return candidates;
19153
+ }
19154
+ _getAllEntities() {
19155
+ const entities = this.engine.entities;
19156
+ if (!entities) return [];
19157
+ return Object.values(entities);
19158
+ }
19159
+ _getAllMeshes() {
19160
+ return this.engine.meshes ?? {};
19161
+ }
19162
+ _getEntityBoundingSphere(entity) {
19163
+ if (entity._bsphere && entity._bsphere.radius > 0) {
19164
+ return {
19165
+ center: Array.from(entity._bsphere.center),
19166
+ radius: entity._bsphere.radius
19167
+ };
19168
+ }
19169
+ const geometry = entity.mesh?.geometry;
19170
+ if (!geometry) return null;
19171
+ const localBsphere = geometry.getBoundingSphere?.();
19172
+ if (!localBsphere || localBsphere.radius <= 0) return null;
19173
+ const matrix = entity._matrix ?? entity.matrix ?? mat4$1.create();
19174
+ return this._transformBoundingSphere(localBsphere, matrix);
19175
+ }
19176
+ _getMeshBoundingSphere(mesh) {
19177
+ const geometry = mesh.geometry;
19178
+ if (!geometry) return null;
19179
+ return geometry.getBoundingSphere?.() ?? null;
19180
+ }
19181
+ _transformBoundingSphere(bsphere, matrix) {
19182
+ const center = vec3$1.create();
19183
+ vec3$1.transformMat4(center, bsphere.center, matrix);
19184
+ const scaleX = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1] + matrix[2] * matrix[2]);
19185
+ const scaleY = Math.sqrt(matrix[4] * matrix[4] + matrix[5] * matrix[5] + matrix[6] * matrix[6]);
19186
+ const scaleZ = Math.sqrt(matrix[8] * matrix[8] + matrix[9] * matrix[9] + matrix[10] * matrix[10]);
19187
+ const maxScale = Math.max(scaleX, scaleY, scaleZ);
19188
+ return {
19189
+ center: Array.from(center),
19190
+ radius: bsphere.radius * maxScale
19191
+ };
19192
+ }
19193
+ _getInstanceMatrix(geometry, instanceIndex) {
19194
+ if (!geometry.instanceData) {
19195
+ return mat4$1.create();
19196
+ }
19197
+ const stride = 28;
19198
+ const offset = instanceIndex * stride;
19199
+ if (offset + 16 > geometry.instanceData.length) {
19200
+ return mat4$1.create();
19201
+ }
19202
+ const matrix = mat4$1.create();
19203
+ for (let i = 0; i < 16; i++) {
19204
+ matrix[i] = geometry.instanceData[offset + i];
19205
+ }
19206
+ return matrix;
19207
+ }
19208
+ _extractGeometry(geometry) {
19209
+ if (!geometry.vertexArray || !geometry.indexArray) {
19210
+ return null;
19211
+ }
19212
+ const stride = 20;
19213
+ const vertexCount = geometry.vertexArray.length / stride;
19214
+ const vertices = new Float32Array(vertexCount * 3);
19215
+ for (let i = 0; i < vertexCount; i++) {
19216
+ vertices[i * 3] = geometry.vertexArray[i * stride];
19217
+ vertices[i * 3 + 1] = geometry.vertexArray[i * stride + 1];
19218
+ vertices[i * 3 + 2] = geometry.vertexArray[i * stride + 2];
19219
+ }
19220
+ return {
19221
+ vertices,
19222
+ indices: geometry.indexArray
19223
+ };
19224
+ }
19225
+ /**
19226
+ * Ray-sphere intersection test
19227
+ * Returns true if ray intersects sphere within maxDistance
19228
+ * Handles case where ray origin is inside the sphere
19229
+ */
19230
+ _raySphereIntersect(ray, sphere) {
19231
+ const oc = [
19232
+ ray.origin[0] - sphere.center[0],
19233
+ ray.origin[1] - sphere.center[1],
19234
+ ray.origin[2] - sphere.center[2]
19235
+ ];
19236
+ const distToCenter = Math.sqrt(oc[0] * oc[0] + oc[1] * oc[1] + oc[2] * oc[2]);
19237
+ if (distToCenter < sphere.radius) {
19238
+ return true;
19239
+ }
19240
+ const a = this._dot(ray.direction, ray.direction);
19241
+ const b = 2 * this._dot(oc, ray.direction);
19242
+ const c = this._dot(oc, oc) - sphere.radius * sphere.radius;
19243
+ const discriminant = b * b - 4 * a * c;
19244
+ if (discriminant < 0) return false;
19245
+ const sqrtDisc = Math.sqrt(discriminant);
19246
+ const t1 = (-b - sqrtDisc) / (2 * a);
19247
+ const t2 = (-b + sqrtDisc) / (2 * a);
19248
+ if (t1 >= 0 && t1 <= ray.maxDistance) return true;
19249
+ if (t2 >= 0 && t2 <= ray.maxDistance) return true;
19250
+ return false;
19251
+ }
19252
+ /**
19253
+ * Get distance to sphere along ray (for sorting)
19254
+ */
19255
+ _raySphereDistance(ray, sphere) {
19256
+ const oc = [
19257
+ ray.origin[0] - sphere.center[0],
19258
+ ray.origin[1] - sphere.center[1],
19259
+ ray.origin[2] - sphere.center[2]
19260
+ ];
19261
+ const a = this._dot(ray.direction, ray.direction);
19262
+ const b = 2 * this._dot(oc, ray.direction);
19263
+ const c = this._dot(oc, oc) - sphere.radius * sphere.radius;
19264
+ const discriminant = b * b - 4 * a * c;
19265
+ if (discriminant < 0) return Infinity;
19266
+ const t = (-b - Math.sqrt(discriminant)) / (2 * a);
19267
+ return Math.max(0, t);
19268
+ }
19269
+ _handleWorkerMessage(event) {
19270
+ const { type, requestId, result } = event.data;
19271
+ if (type === "raycastResult") {
19272
+ const pending = this._pendingCallbacks.get(requestId);
19273
+ if (pending) {
19274
+ this._pendingCallbacks.delete(requestId);
19275
+ if (result.hit && pending.candidates) {
19276
+ const candidate = pending.candidates.find((c) => c.id === result.candidateId);
19277
+ if (candidate) {
19278
+ result.entity = candidate.entity;
19279
+ result.mesh = candidate.mesh;
19280
+ result.meshName = candidate.meshName;
19281
+ result.instanceIndex = candidate.instanceIndex;
19282
+ }
19283
+ }
19284
+ pending.callback(result);
19285
+ }
19286
+ }
19287
+ }
19288
+ _normalize(v) {
19289
+ const len = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
19290
+ if (len === 0) return [0, 0, 1];
19291
+ return [v[0] / len, v[1] / len, v[2] / len];
19292
+ }
19293
+ _dot(a, b) {
19294
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
19295
+ }
19296
+ /**
19297
+ * Get perpendicular distance from a point to a ray
19298
+ */
19299
+ _pointToRayDistance(point, ray) {
19300
+ const op = [
19301
+ point[0] - ray.origin[0],
19302
+ point[1] - ray.origin[1],
19303
+ point[2] - ray.origin[2]
19304
+ ];
19305
+ const t = this._dot(op, ray.direction);
19306
+ const closest = [
19307
+ ray.origin[0] + ray.direction[0] * t,
19308
+ ray.origin[1] + ray.direction[1] * t,
19309
+ ray.origin[2] + ray.direction[2] * t
19310
+ ];
19311
+ const dx = point[0] - closest[0];
19312
+ const dy = point[1] - closest[1];
19313
+ const dz = point[2] - closest[2];
19314
+ return Math.sqrt(dx * dx + dy * dy + dz * dz);
19315
+ }
19316
+ _unproject(ndc, invViewProj) {
19317
+ const x = ndc[0];
19318
+ const y = ndc[1];
19319
+ const z = ndc[2];
19320
+ const w = invViewProj[3] * x + invViewProj[7] * y + invViewProj[11] * z + invViewProj[15];
19321
+ return [
19322
+ (invViewProj[0] * x + invViewProj[4] * y + invViewProj[8] * z + invViewProj[12]) / w,
19323
+ (invViewProj[1] * x + invViewProj[5] * y + invViewProj[9] * z + invViewProj[13]) / w,
19324
+ (invViewProj[2] * x + invViewProj[6] * y + invViewProj[10] * z + invViewProj[14]) / w
19325
+ ];
19326
+ }
19327
+ /**
19328
+ * Generate Web Worker code as string
19329
+ */
19330
+ _getWorkerCode() {
19331
+ return `
19332
+ // Raycaster Web Worker
19333
+ // Performs triangle intersection tests off the main thread
19334
+
19335
+ self.onmessage = function(event) {
19336
+ const { type, requestId, ray, candidates, debug } = event.data
19337
+
19338
+ if (type === 'raycast') {
19339
+ const result = raycastTriangles(ray, candidates, debug)
19340
+ self.postMessage({ type: 'raycastResult', requestId, result })
19341
+ }
19342
+ }
19343
+
19344
+ function raycastTriangles(ray, candidates, debug) {
19345
+ let closestHit = null
19346
+ let closestDistance = ray.maxDistance
19347
+ let debugInfo = debug ? { totalTris: 0, testedCandidates: 0, scales: [] } : null
19348
+
19349
+ for (const candidate of candidates) {
19350
+ if (debug) debugInfo.testedCandidates++
19351
+ const result = testCandidate(ray, candidate, closestDistance, debug ? debugInfo : null)
19352
+
19353
+ if (result && result.distance < closestDistance) {
19354
+ closestDistance = result.distance
19355
+ closestHit = {
19356
+ hit: true,
19357
+ distance: result.distance,
19358
+ point: result.point,
19359
+ normal: result.normal,
19360
+ triangleIndex: result.triangleIndex,
19361
+ candidateId: candidate.id,
19362
+ localT: result.localT,
19363
+ scale: result.scale
19364
+ }
19365
+ }
19366
+ }
19367
+
19368
+ if (debug) {
19369
+ let msg = 'Worker: candidates=' + debugInfo.testedCandidates + ', triangles=' + debugInfo.totalTris
19370
+ if (closestHit) {
19371
+ msg += ', hit=' + closestHit.distance.toFixed(2) + ' (localT=' + closestHit.localT.toFixed(2) + ', scale=' + closestHit.scale.toFixed(2) + ')'
19372
+ } else {
19373
+ msg += ', hit=none'
19374
+ }
19375
+ console.log(msg)
19376
+ }
19377
+
19378
+ return closestHit ?? { hit: false }
19379
+ }
19380
+
19381
+ function testCandidate(ray, candidate, maxDistance, debugInfo) {
19382
+ const { vertices, indices, matrix, backfaces } = candidate
19383
+
19384
+ // Compute inverse matrix for transforming ray to local space
19385
+ const invMatrix = invertMatrix4(matrix)
19386
+
19387
+ // Transform ray to local space
19388
+ const localOrigin = transformPoint(ray.origin, invMatrix)
19389
+ const localDir = transformDirection(ray.direction, invMatrix)
19390
+
19391
+ // Calculate the scale factor of the transformation (for correct distance)
19392
+ const dirScale = Math.sqrt(localDir[0]*localDir[0] + localDir[1]*localDir[1] + localDir[2]*localDir[2])
19393
+ const localDirNorm = [localDir[0]/dirScale, localDir[1]/dirScale, localDir[2]/dirScale]
19394
+
19395
+ let closestHit = null
19396
+ let closestT = maxDistance
19397
+
19398
+ // Test each triangle
19399
+ const triangleCount = indices.length / 3
19400
+ if (debugInfo) debugInfo.totalTris += triangleCount
19401
+ for (let i = 0; i < triangleCount; i++) {
19402
+ const i0 = indices[i * 3]
19403
+ const i1 = indices[i * 3 + 1]
19404
+ const i2 = indices[i * 3 + 2]
19405
+
19406
+ const v0 = [vertices[i0 * 3], vertices[i0 * 3 + 1], vertices[i0 * 3 + 2]]
19407
+ const v1 = [vertices[i1 * 3], vertices[i1 * 3 + 1], vertices[i1 * 3 + 2]]
19408
+ const v2 = [vertices[i2 * 3], vertices[i2 * 3 + 1], vertices[i2 * 3 + 2]]
19409
+
19410
+ const hit = rayTriangleIntersect(localOrigin, localDirNorm, v0, v1, v2, backfaces)
19411
+
19412
+ if (hit && hit.t > 0) {
19413
+ // Transform hit point and normal back to world space
19414
+ const worldPoint = transformPoint(hit.point, matrix)
19415
+
19416
+ // Calculate world-space distance (local t may be wrong due to matrix scale)
19417
+ const worldDist = Math.sqrt(
19418
+ (worldPoint[0] - ray.origin[0]) ** 2 +
19419
+ (worldPoint[1] - ray.origin[1]) ** 2 +
19420
+ (worldPoint[2] - ray.origin[2]) ** 2
19421
+ )
19422
+
19423
+ if (worldDist < closestT) {
19424
+ closestT = worldDist
19425
+ const worldNormal = transformDirection(hit.normal, matrix)
19426
+
19427
+ closestHit = {
19428
+ distance: worldDist,
19429
+ point: worldPoint,
19430
+ normal: normalize(worldNormal),
19431
+ triangleIndex: i,
19432
+ localT: hit.t,
19433
+ scale: dirScale
19434
+ }
19435
+ }
19436
+ }
19437
+ }
19438
+
19439
+ return closestHit
19440
+ }
19441
+
19442
+ // Möller–Trumbore intersection algorithm
19443
+ function rayTriangleIntersect(origin, dir, v0, v1, v2, backfaces) {
19444
+ const EPSILON = 0.0000001
19445
+
19446
+ const edge1 = sub(v1, v0)
19447
+ const edge2 = sub(v2, v0)
19448
+ const h = cross(dir, edge2)
19449
+ const a = dot(edge1, h)
19450
+
19451
+ // Check if ray is parallel to triangle
19452
+ if (a > -EPSILON && a < EPSILON) return null
19453
+
19454
+ // Check backface
19455
+ if (!backfaces && a < 0) return null
19456
+
19457
+ const f = 1.0 / a
19458
+ const s = sub(origin, v0)
19459
+ const u = f * dot(s, h)
19460
+
19461
+ if (u < 0.0 || u > 1.0) return null
19462
+
19463
+ const q = cross(s, edge1)
19464
+ const v = f * dot(dir, q)
19465
+
19466
+ if (v < 0.0 || u + v > 1.0) return null
19467
+
19468
+ const t = f * dot(edge2, q)
19469
+
19470
+ if (t > EPSILON) {
19471
+ const point = [
19472
+ origin[0] + dir[0] * t,
19473
+ origin[1] + dir[1] * t,
19474
+ origin[2] + dir[2] * t
19475
+ ]
19476
+ const normal = normalize(cross(edge1, edge2))
19477
+ return { t, point, normal, u, v }
19478
+ }
19479
+
19480
+ return null
19481
+ }
19482
+
19483
+ // Matrix and vector utilities
19484
+ function invertMatrix4(m) {
19485
+ const inv = new Array(16)
19486
+
19487
+ inv[0] = m[5]*m[10]*m[15] - m[5]*m[11]*m[14] - m[9]*m[6]*m[15] + m[9]*m[7]*m[14] + m[13]*m[6]*m[11] - m[13]*m[7]*m[10]
19488
+ inv[4] = -m[4]*m[10]*m[15] + m[4]*m[11]*m[14] + m[8]*m[6]*m[15] - m[8]*m[7]*m[14] - m[12]*m[6]*m[11] + m[12]*m[7]*m[10]
19489
+ inv[8] = m[4]*m[9]*m[15] - m[4]*m[11]*m[13] - m[8]*m[5]*m[15] + m[8]*m[7]*m[13] + m[12]*m[5]*m[11] - m[12]*m[7]*m[9]
19490
+ inv[12] = -m[4]*m[9]*m[14] + m[4]*m[10]*m[13] + m[8]*m[5]*m[14] - m[8]*m[6]*m[13] - m[12]*m[5]*m[10] + m[12]*m[6]*m[9]
19491
+ inv[1] = -m[1]*m[10]*m[15] + m[1]*m[11]*m[14] + m[9]*m[2]*m[15] - m[9]*m[3]*m[14] - m[13]*m[2]*m[11] + m[13]*m[3]*m[10]
19492
+ inv[5] = m[0]*m[10]*m[15] - m[0]*m[11]*m[14] - m[8]*m[2]*m[15] + m[8]*m[3]*m[14] + m[12]*m[2]*m[11] - m[12]*m[3]*m[10]
19493
+ inv[9] = -m[0]*m[9]*m[15] + m[0]*m[11]*m[13] + m[8]*m[1]*m[15] - m[8]*m[3]*m[13] - m[12]*m[1]*m[11] + m[12]*m[3]*m[9]
19494
+ inv[13] = m[0]*m[9]*m[14] - m[0]*m[10]*m[13] - m[8]*m[1]*m[14] + m[8]*m[2]*m[13] + m[12]*m[1]*m[10] - m[12]*m[2]*m[9]
19495
+ inv[2] = m[1]*m[6]*m[15] - m[1]*m[7]*m[14] - m[5]*m[2]*m[15] + m[5]*m[3]*m[14] + m[13]*m[2]*m[7] - m[13]*m[3]*m[6]
19496
+ inv[6] = -m[0]*m[6]*m[15] + m[0]*m[7]*m[14] + m[4]*m[2]*m[15] - m[4]*m[3]*m[14] - m[12]*m[2]*m[7] + m[12]*m[3]*m[6]
19497
+ inv[10] = m[0]*m[5]*m[15] - m[0]*m[7]*m[13] - m[4]*m[1]*m[15] + m[4]*m[3]*m[13] + m[12]*m[1]*m[7] - m[12]*m[3]*m[5]
19498
+ inv[14] = -m[0]*m[5]*m[14] + m[0]*m[6]*m[13] + m[4]*m[1]*m[14] - m[4]*m[2]*m[13] - m[12]*m[1]*m[6] + m[12]*m[2]*m[5]
19499
+ inv[3] = -m[1]*m[6]*m[11] + m[1]*m[7]*m[10] + m[5]*m[2]*m[11] - m[5]*m[3]*m[10] - m[9]*m[2]*m[7] + m[9]*m[3]*m[6]
19500
+ inv[7] = m[0]*m[6]*m[11] - m[0]*m[7]*m[10] - m[4]*m[2]*m[11] + m[4]*m[3]*m[10] + m[8]*m[2]*m[7] - m[8]*m[3]*m[6]
19501
+ inv[11] = -m[0]*m[5]*m[11] + m[0]*m[7]*m[9] + m[4]*m[1]*m[11] - m[4]*m[3]*m[9] - m[8]*m[1]*m[7] + m[8]*m[3]*m[5]
19502
+ inv[15] = m[0]*m[5]*m[10] - m[0]*m[6]*m[9] - m[4]*m[1]*m[10] + m[4]*m[2]*m[9] + m[8]*m[1]*m[6] - m[8]*m[2]*m[5]
19503
+
19504
+ let det = m[0]*inv[0] + m[1]*inv[4] + m[2]*inv[8] + m[3]*inv[12]
19505
+ if (det === 0) return m // Return original if singular
19506
+
19507
+ det = 1.0 / det
19508
+ for (let i = 0; i < 16; i++) inv[i] *= det
19509
+
19510
+ return inv
19511
+ }
19512
+
19513
+ function transformPoint(p, m) {
19514
+ const w = m[3]*p[0] + m[7]*p[1] + m[11]*p[2] + m[15]
19515
+ return [
19516
+ (m[0]*p[0] + m[4]*p[1] + m[8]*p[2] + m[12]) / w,
19517
+ (m[1]*p[0] + m[5]*p[1] + m[9]*p[2] + m[13]) / w,
19518
+ (m[2]*p[0] + m[6]*p[1] + m[10]*p[2] + m[14]) / w
19519
+ ]
19520
+ }
19521
+
19522
+ function transformDirection(d, m) {
19523
+ return [
19524
+ m[0]*d[0] + m[4]*d[1] + m[8]*d[2],
19525
+ m[1]*d[0] + m[5]*d[1] + m[9]*d[2],
19526
+ m[2]*d[0] + m[6]*d[1] + m[10]*d[2]
19527
+ ]
19528
+ }
19529
+
19530
+ function normalize(v) {
19531
+ const len = Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2])
19532
+ if (len === 0) return [0, 0, 1]
19533
+ return [v[0]/len, v[1]/len, v[2]/len]
19534
+ }
19535
+
19536
+ function dot(a, b) {
19537
+ return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
19538
+ }
19539
+
19540
+ function cross(a, b) {
19541
+ return [
19542
+ a[1]*b[2] - a[2]*b[1],
19543
+ a[2]*b[0] - a[0]*b[2],
19544
+ a[0]*b[1] - a[1]*b[0]
19545
+ ]
19546
+ }
19547
+
19548
+ function sub(a, b) {
19549
+ return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]
19550
+ }
19551
+ `;
19552
+ }
19553
+ destroy() {
19554
+ if (this.worker) {
19555
+ this.worker.terminate();
19556
+ this.worker = null;
19557
+ }
19558
+ this._pendingCallbacks.clear();
19559
+ this._initialized = false;
19560
+ }
19561
+ }
17090
19562
  function fail(engine, msg, data) {
17091
19563
  if (engine?.canvas) {
17092
19564
  engine.canvas.style.display = "none";
@@ -17152,7 +19624,7 @@ const DEFAULT_SETTINGS = {
17152
19624
  renderScale: 1,
17153
19625
  // Render resolution multiplier (1.5-2.0 for supersampling AA)
17154
19626
  autoScale: {
17155
- enabled: false,
19627
+ enabled: true,
17156
19628
  // Auto-reduce renderScale for high resolutions
17157
19629
  enabledForEffects: true,
17158
19630
  // Auto scale effects at high resolutions (when main autoScale disabled)
@@ -17177,8 +19649,10 @@ const DEFAULT_SETTINGS = {
17177
19649
  // Enable alpha hashing/dithering for cutout transparency (global default)
17178
19650
  alphaHashScale: 1,
17179
19651
  // Scale factor for alpha hash threshold (higher = more opaque)
17180
- luminanceToAlpha: false
19652
+ luminanceToAlpha: false,
17181
19653
  // Derive alpha from color luminance (for old game assets where black=transparent)
19654
+ tonemapMode: 0
19655
+ // 0=ACES, 1=Reinhard, 2=None (linear clamp)
17182
19656
  },
17183
19657
  // Noise settings for dithering, jittering, etc.
17184
19658
  noise: {
@@ -17225,13 +19699,14 @@ const DEFAULT_SETTINGS = {
17225
19699
  exposure: 1.6,
17226
19700
  fog: {
17227
19701
  enabled: true,
17228
- color: [0.8, 0.85, 0.9],
17229
- distances: [6, 15, 50],
19702
+ color: [100 / 255, 135 / 255, 170 / 255],
19703
+ distances: [0, 15, 50],
17230
19704
  alpha: [0, 0.5, 0.9],
17231
19705
  heightFade: [-2, 185],
17232
19706
  // [bottomY, topY] - full fog at bottomY, zero at topY
17233
- brightResist: 0.2
19707
+ brightResist: 0,
17234
19708
  // How much bright/emissive colors resist fog (0-1)
19709
+ debug: 0
17235
19710
  }
17236
19711
  },
17237
19712
  // Main directional light
@@ -17261,6 +19736,8 @@ const DEFAULT_SETTINGS = {
17261
19736
  surfaceBias: 0,
17262
19737
  // Scale shadow projection larger (0.01 = 1% larger)
17263
19738
  strength: 1
19739
+ //frustum: false,
19740
+ //hiZ: false,
17264
19741
  },
17265
19742
  // Ambient Occlusion settings
17266
19743
  ao: {
@@ -17368,6 +19845,54 @@ const DEFAULT_SETTINGS = {
17368
19845
  saturateLevel: 0.5
17369
19846
  // Logarithmic saturation level for indirect light
17370
19847
  },
19848
+ // Volumetric Fog (light scattering through particles)
19849
+ volumetricFog: {
19850
+ enabled: false,
19851
+ // Disabled by default (performance impact)
19852
+ resolution: 0.125,
19853
+ // 1/4 render resolution for ray marching
19854
+ maxSamples: 32,
19855
+ // Ray march samples (8-32)
19856
+ blurRadius: 8,
19857
+ // Gaussian blur radius
19858
+ densityMultiplier: 1,
19859
+ // Multiplies base fog density
19860
+ scatterStrength: 0.35,
19861
+ // Light scattering intensity
19862
+ mainLightScatter: 1.4,
19863
+ // Main directional light scattering boost
19864
+ mainLightScatterDark: 5,
19865
+ // Main directional light scattering boost
19866
+ mainLightSaturation: 0.15,
19867
+ // Main light color saturation in fog
19868
+ maxFogOpacity: 0.3,
19869
+ // Maximum fog opacity (0-1)
19870
+ heightRange: [-2, 8],
19871
+ // [bottom, top] Y bounds for fog (low ground fog)
19872
+ windDirection: [1, 0, 0.2],
19873
+ // Wind direction for fog animation
19874
+ windSpeed: 0.5,
19875
+ // Wind speed multiplier
19876
+ noiseScale: 0.9,
19877
+ // 3D noise frequency (higher = finer detail)
19878
+ noiseStrength: 0.8,
19879
+ // Noise intensity (0 = uniform, 1 = full variation)
19880
+ noiseOctaves: 6,
19881
+ // Noise detail layers
19882
+ noiseEnabled: true,
19883
+ // Enable 3D noise (disable for debug)
19884
+ lightingEnabled: true,
19885
+ // Light fog from scene lights
19886
+ shadowsEnabled: true,
19887
+ // Apply shadows to fog
19888
+ brightnessThreshold: 0.8,
19889
+ // Scene luminance where fog starts fading (like bloom)
19890
+ minVisibility: 0.15,
19891
+ // Minimum fog visibility over bright surfaces (0-1)
19892
+ skyBrightness: 1.2
19893
+ // Virtual brightness for sky pixels (depth at far plane)
19894
+ //debugSkyCheck: true
19895
+ },
17371
19896
  // Planar Reflections (alternative to SSR for water/floor)
17372
19897
  planarReflection: {
17373
19898
  enabled: true,
@@ -17423,15 +19948,68 @@ const DEFAULT_SETTINGS = {
17423
19948
  // FPS threshold for auto-disable
17424
19949
  disableDelay: 3
17425
19950
  // Seconds below threshold before disabling
19951
+ },
19952
+ // CRT effect (retro monitor simulation)
19953
+ crt: {
19954
+ enabled: false,
19955
+ // Enable CRT effect (geometry, scanlines, etc.)
19956
+ upscaleEnabled: false,
19957
+ // Enable upscaling (pixelated look) even when CRT disabled
19958
+ upscaleTarget: 4,
19959
+ // Target upscale multiplier (4x render resolution)
19960
+ maxTextureSize: 4096,
19961
+ // Max upscaled texture dimension
19962
+ // Geometry distortion
19963
+ curvature: 0.14,
19964
+ // Screen curvature amount (0-0.15)
19965
+ cornerRadius: 0.055,
19966
+ // Rounded corner radius (0-0.1)
19967
+ zoom: 1.06,
19968
+ // Zoom to compensate for curvature shrinkage
19969
+ // Scanlines (electron beam simulation - Gaussian profile)
19970
+ scanlineIntensity: 0.4,
19971
+ // Scanline effect strength (0-1)
19972
+ scanlineWidth: 0,
19973
+ // Beam width (0=thin/center only, 1=no gap)
19974
+ scanlineBrightBoost: 0.8,
19975
+ // Bright pixels widen beam to fill gaps (0-1)
19976
+ scanlineHeight: 5,
19977
+ // Scanline height in canvas pixels
19978
+ // RGB convergence error (color channel misalignment)
19979
+ convergence: [0.79, 0, -0.77],
19980
+ // RGB X offset in source pixels
19981
+ // Phosphor mask
19982
+ maskType: "aperture",
19983
+ // 'aperture', 'slot', 'shadow', 'none'
19984
+ maskIntensity: 0.25,
19985
+ // Mask strength (0-1)
19986
+ maskScale: 1,
19987
+ // Mask size multiplier
19988
+ // Vignette (edge darkening)
19989
+ vignetteIntensity: 0.54,
19990
+ // Edge darkening strength (0-1)
19991
+ vignetteSize: 0.85,
19992
+ // Vignette size (larger = more visible)
19993
+ // Horizontal blur (beam softness)
19994
+ blurSize: 0.79
19995
+ // Horizontal blur in pixels (0-2)
17426
19996
  }
17427
19997
  };
17428
19998
  async function createWebGPUContext(engine, canvasId) {
17429
19999
  try {
17430
20000
  let configureContext = function() {
17431
- const devicePixelRatio = window.devicePixelRatio || 1;
17432
- const renderScale = engine.renderScale || 1;
17433
- canvas.width = Math.floor(canvas.clientWidth * devicePixelRatio * renderScale) | 0;
17434
- canvas.height = Math.floor(canvas.clientHeight * devicePixelRatio * renderScale) | 0;
20001
+ let pixelWidth, pixelHeight;
20002
+ if (engine._devicePixelSize) {
20003
+ pixelWidth = engine._devicePixelSize.width;
20004
+ pixelHeight = engine._devicePixelSize.height;
20005
+ } else {
20006
+ const devicePixelRatio = window.devicePixelRatio || 1;
20007
+ pixelWidth = Math.round(canvas.clientWidth * devicePixelRatio);
20008
+ pixelHeight = Math.round(canvas.clientHeight * devicePixelRatio);
20009
+ }
20010
+ canvas.width = pixelWidth;
20011
+ canvas.height = pixelHeight;
20012
+ engine._canvasPixelSize = { width: pixelWidth, height: pixelHeight };
17435
20013
  context.configure({
17436
20014
  device,
17437
20015
  format: canvasFormat,
@@ -17604,9 +20182,34 @@ class Engine {
17604
20182
  this.stats.avg_fps = 60;
17605
20183
  this.stats.avg_dt_render = 0.1;
17606
20184
  requestAnimationFrame(() => this._frame());
17607
- window.addEventListener("resize", () => {
17608
- this.needsResize = true;
17609
- });
20185
+ this._devicePixelSize = null;
20186
+ try {
20187
+ const resizeObserver = new ResizeObserver((entries) => {
20188
+ for (const entry of entries) {
20189
+ if (entry.devicePixelContentBoxSize) {
20190
+ const size = entry.devicePixelContentBoxSize[0];
20191
+ this._devicePixelSize = {
20192
+ width: size.inlineSize,
20193
+ height: size.blockSize
20194
+ };
20195
+ } else if (entry.contentBoxSize) {
20196
+ const size = entry.contentBoxSize[0];
20197
+ const dpr = window.devicePixelRatio || 1;
20198
+ this._devicePixelSize = {
20199
+ width: Math.round(size.inlineSize * dpr),
20200
+ height: Math.round(size.blockSize * dpr)
20201
+ };
20202
+ }
20203
+ this.needsResize = true;
20204
+ }
20205
+ });
20206
+ resizeObserver.observe(this.canvas, { box: "device-pixel-content-box" });
20207
+ } catch (e) {
20208
+ console.log("ResizeObserver device-pixel-content-box not supported, falling back to window resize");
20209
+ window.addEventListener("resize", () => {
20210
+ this.needsResize = true;
20211
+ });
20212
+ }
17610
20213
  setInterval(() => {
17611
20214
  if (this.needsResize && !this._resizing) {
17612
20215
  this.needsResize = false;
@@ -17726,11 +20329,19 @@ class Engine {
17726
20329
  * @param {Array} options.position - Optional position offset [x, y, z]
17727
20330
  * @param {Array} options.rotation - Optional rotation offset [x, y, z] in radians
17728
20331
  * @param {number} options.scale - Optional uniform scale multiplier
20332
+ * @param {boolean} options.doubleSided - Optional: force all materials to be double-sided
17729
20333
  * @returns {Promise<Object>} Object containing { meshes, nodes, skins, animations }
17730
20334
  */
17731
20335
  async loadScene(url, options = {}) {
17732
20336
  const result = await loadGltf(this, url, options);
17733
20337
  const { meshes, nodes } = result;
20338
+ if (options.doubleSided) {
20339
+ for (const mesh of Object.values(meshes)) {
20340
+ if (mesh.material) {
20341
+ mesh.material.doubleSided = true;
20342
+ }
20343
+ }
20344
+ }
17734
20345
  for (const node of nodes) {
17735
20346
  if (!node.parent) {
17736
20347
  node.updateMatrix(null);
@@ -17750,6 +20361,23 @@ class Engine {
17750
20361
  [scl, scl, scl]
17751
20362
  );
17752
20363
  }
20364
+ let combinedBsphere = null;
20365
+ const hasAnySkin = Object.values(meshes).some((m) => m.hasSkin);
20366
+ if (hasAnySkin) {
20367
+ const allPositions = [];
20368
+ for (const mesh of Object.values(meshes)) {
20369
+ const positions = mesh.geometry?.attributes?.position;
20370
+ if (positions) {
20371
+ for (let i = 0; i < positions.length; i += 3) {
20372
+ allPositions.push(positions[i], positions[i + 1], positions[i + 2]);
20373
+ }
20374
+ }
20375
+ }
20376
+ if (allPositions.length > 0) {
20377
+ const { calculateBoundingSphere: calculateBoundingSphere2 } = await Promise.resolve().then(() => BoundingSphere);
20378
+ combinedBsphere = calculateBoundingSphere2(new Float32Array(allPositions));
20379
+ }
20380
+ }
17753
20381
  for (const [name, mesh] of Object.entries(meshes)) {
17754
20382
  let meshNode = null;
17755
20383
  if (mesh.nodeIndex !== null && mesh.nodeIndex !== void 0) {
@@ -17762,7 +20390,7 @@ class Engine {
17762
20390
  if (options.position || options.rotation || options.scale) {
17763
20391
  mat4.multiply(worldMatrix, rootTransform, worldMatrix);
17764
20392
  }
17765
- const localBsphere = mesh.geometry.getBoundingSphere?.();
20393
+ const localBsphere = hasAnySkin && combinedBsphere ? combinedBsphere : mesh.geometry.getBoundingSphere?.();
17766
20394
  let worldCenter = [0, 0, 0];
17767
20395
  let worldRadius = 1;
17768
20396
  if (localBsphere && localBsphere.radius > 0) {
@@ -17777,6 +20405,9 @@ class Engine {
17777
20405
  const scaleZ = Math.sqrt(worldMatrix[8] ** 2 + worldMatrix[9] ** 2 + worldMatrix[10] ** 2);
17778
20406
  worldRadius = localBsphere.radius * Math.max(scaleX, scaleY, scaleZ);
17779
20407
  }
20408
+ if (hasAnySkin && combinedBsphere) {
20409
+ mesh.combinedBsphere = combinedBsphere;
20410
+ }
17780
20411
  mesh.addInstance(worldCenter, worldRadius);
17781
20412
  mesh.updateInstance(0, worldMatrix);
17782
20413
  mesh.static = true;
@@ -17845,6 +20476,16 @@ class Engine {
17845
20476
  getEntity(id) {
17846
20477
  return this.entityManager.get(id);
17847
20478
  }
20479
+ /**
20480
+ * Invalidate occlusion culling data and reset warmup period.
20481
+ * Call this after scene loading or major camera teleportation to prevent
20482
+ * incorrect occlusion culling with stale depth buffer data.
20483
+ */
20484
+ invalidateOcclusionCulling() {
20485
+ if (this.renderer) {
20486
+ this.renderer.invalidateOcclusionCulling();
20487
+ }
20488
+ }
17848
20489
  async _create() {
17849
20490
  let camera = new Camera(this);
17850
20491
  camera.updateMatrix();
@@ -17867,6 +20508,8 @@ class Engine {
17867
20508
  }
17868
20509
  async _after_create() {
17869
20510
  this.renderer = await RenderGraph.create(this, this.environment, this.environmentEncoding);
20511
+ this.raycaster = new Raycaster(this);
20512
+ await this.raycaster.initialize();
17870
20513
  }
17871
20514
  _update(dt) {
17872
20515
  this._updateInput();
@@ -17922,7 +20565,7 @@ class Engine {
17922
20565
  this.guiCanvas.height = canvas.height;
17923
20566
  this.guiCtx.clearRect(0, 0, canvas.width, canvas.height);
17924
20567
  }
17925
- await this.renderer.resize(canvas.width, canvas.height);
20568
+ await this.renderer.resize(canvas.width, canvas.height, this.renderScale);
17926
20569
  this.resize();
17927
20570
  await new Promise((resolve) => setTimeout(resolve, 16));
17928
20571
  this._resizing = false;
@@ -18176,6 +20819,7 @@ export {
18176
20819
  Mesh,
18177
20820
  ParticleEmitter,
18178
20821
  ParticleSystem,
20822
+ Raycaster,
18179
20823
  RenderGraph,
18180
20824
  Texture,
18181
20825
  fail