Hello everyone!
I am developing a game with babylon.js and recently I got a very non-intuitive and hard-to-find-out-why-that-thing-dont-work-omg bug.
To make long story short - I have a planet (non-transparent material) and clouds, that I draw using shader material on another sphere, which is a little bit bigger than planet itself. This clouds sphere material has alpha enabled, so it can be transparent. The mode is ALPHA_COMBINE, just in case.
Behind my planet, I have a sun. A sun is just nothing more but a sphere with custom shader material.So, the bug:In certain zones, where transparency of clouds sphere > ~20%, the sun silhouette becomes visible through the planet as pure-white circle.
Furthermore, my sun’s halo, which I rendered on a plane with the ALPHA_ADD mode, was always showing through the planet, everywhere, regardless of clouds. This started to happen after I decided to add custom postprocess into my game. The postprocess addition alters the rendering pipeline, as far as I understand, and for example, you have to control occlusion manually this case, using depth maps.
I spent a long time trying to figure this out. Initially, I thought I’d misconfigured the occlusion (because after adding postprocessing to a scene, you have to do it manually, transferring the depth texture to the materials of translucent objects and then cutting off the part obscured by other objects). But after two days of searching, I couldn’t fix it, even though I tried everything and burned through half my AI subscription in vain attempts to figure out what was going on. Then, completely unexpectedly, the bug completely disappeared when I added the Babylon Scene Inspector to the project.
After carefully examining what was happening to the project when adding the Scene Inspector, I concluded that there was some side effect, some package that fixed this bug, which was imported by the Inspector but, for some unknown reason, was missing from my working project without the Inspector. I should note, however, that I didn’t observe any errors anywhere. In other words, everything seemed to be working correctly for me.I created a list of all imports added when working with the Inspector and, using a binary search, found the package that, when added to my project, eliminated the bug. It turned out to be import “@babylonjs/core/Engines/AbstractEngine/abstractEngine.texture”
In my project, I don’t import the entire engine module, only the webgpu engine. Therefore, this module isn’t imported and, as a result, doesn’t call RegisterAbstractEngineTexture or implement createDepthStencilTexture. Because of this, all depth manipulation using this texture in the project itself fails. Again, I don’t get any error message; it simply doesn’t work.
Can you please fix this? Below I attach minimal example that reproduces this behavior.
import "@babylonjs/core/Culling/ray"
import "@babylonjs/core/Engines/Extensions/engine.computeShader"
import "@babylonjs/core/Engines/WebGPU/Extensions/engine.computeShader"
import "@babylonjs/core/Rendering/depthRendererSceneComponent"
import "@babylonjs/core/ShadersWGSL/ShadersInclude/meshUboDeclaration"
import "@babylonjs/core/ShadersWGSL/ShadersInclude/sceneUboDeclaration"
import {ArcRotateCamera} from "@babylonjs/core/Cameras/arcRotateCamera"
import {Camera} from "@babylonjs/core/Cameras/camera"
import {Constants} from "@babylonjs/core/Engines/constants"
import {WebGPUEngine} from "@babylonjs/core/Engines/webgpuEngine"
import {FeatureName} from "@babylonjs/core/Engines/WebGPU/webgpuConstants"
import {HemisphericLight} from "@babylonjs/core/Lights/hemisphericLight"
import {DirectionalLight} from "@babylonjs/core/Lights/directionalLight"
import {Effect} from "@babylonjs/core/Materials/effect"
import {Material} from "@babylonjs/core/Materials/material"
import {ShaderLanguage} from "@babylonjs/core/Materials/shaderLanguage"
import {ShaderMaterial} from "@babylonjs/core/Materials/shaderMaterial"
import {StandardMaterial} from "@babylonjs/core/Materials/standardMaterial"
import type {BaseTexture} from "@babylonjs/core/Materials/Textures/baseTexture"
import {RawTexture2DArray} from "@babylonjs/core/Materials/Textures/rawTexture2DArray"
import {Texture} from "@babylonjs/core/Materials/Textures/texture"
import {Color3, Color4} from "@babylonjs/core/Maths/math.color"
import {Clamp} from "@babylonjs/core/Maths/math.scalar.functions"
import {Vector3} from "@babylonjs/core/Maths/math.vector"
import {
type CreateSphere,
CreateSphereVertexData,
} from "@babylonjs/core/Meshes/Builders/sphereBuilder"
import {CreatePlane} from "@babylonjs/core/Meshes/Builders/planeBuilder"
import {Mesh} from "@babylonjs/core/Meshes/mesh"
import {_IsSideEffectImplemented} from "@babylonjs/core/Misc/devTools"
import {PostProcess} from "@babylonjs/core/PostProcesses/postProcess"
import type {DepthRenderer} from "@babylonjs/core/Rendering/depthRenderer"
import {Scene} from "@babylonjs/core/scene"
// For the fix validation, uncomment only this line.
// It should reproduce the same effect without importing the full Engine module.
// import "@babylonjs/core/Engines/AbstractEngine/abstractEngine.texture"
const DEBUG_SCENE_CONFIG = {
CameraAlpha: 0.9,
CameraBeta: 1.1,
CameraDefaultRadius: 3.2,
PlanetDiameter: 2,
PlanetSegments: 64,
PlanetCloudsSurfaceOffset: 0.01,
PlanetAtmospherePlaneRadiusMultiplier: 1.16,
SunDiameter: 4,
SunHaloRadiusMultiplier: 3,
NoiseTextureSize: 128,
NoiseTextureLayerCount: 8,
StatusUpdateIntervalMs: 250,
} as const
const DEBUG_ENTITY_NAMES = {
PlanetCamera: "mainCamera",
Planet: "Planet",
PlanetCloudsSphere: "planet-clouds-sphere",
PlanetAtmospherePlane: "planet-atmosphere-plane",
AmbientLight: "ambientLight",
SunLight: "sunLight",
Sun: "sun",
SunHaloPlane: "sphere-effect-plane",
} as const
const DEBUG_SHADER_DATA = {
Attributes: {
Position: "position",
},
UniformBuffers: {
Scene: "Scene",
Mesh: "Mesh",
},
Uniforms: {
TimeSeconds: "timeSeconds",
DiscRadiusOnPlane: "discRadiusOnPlane",
},
Textures: {
CloudNoise: "cloudNoiseTexture",
SunActivityNoise: "sunActivityNoiseTexture",
SunHaloNoise: "sunHaloNoiseTexture",
SceneDepth: "sceneDepthTexture",
},
Varyings: {
SpherePosition: "vSpherePosition",
PlaneUv: "planeUv",
PostProcessUv: "vUV",
},
} as const
const DEBUG_POSTPROCESS_SHADER_NAME = "sunVolumetricRaysPostprocess"
const DEBUG_POSTPROCESS_NAME = "sun-volumetric-rays-postprocess"
const DEBUG_POSTPROCESS_FRAGMENT_SOURCE = `
varying ${DEBUG_SHADER_DATA.Varyings.PostProcessUv}: vec2<f32>;
var textureSamplerSampler: sampler;
var textureSampler: texture_2d<f32>;
@fragment
// Pass-through color frame for debug post process.
fn main(input: FragmentInputs) -> FragmentOutputs {
fragmentOutputs.color = textureSample(
textureSampler,
textureSamplerSampler,
input.${DEBUG_SHADER_DATA.Varyings.PostProcessUv}
);
}`
Effect.RegisterShader(
DEBUG_POSTPROCESS_SHADER_NAME,
DEBUG_POSTPROCESS_FRAGMENT_SOURCE,
undefined,
ShaderLanguage.WGSL,
)
const DEPTH_OCCLUSION_WGSL = `
var ${DEBUG_SHADER_DATA.Textures.SceneDepth}Sampler: sampler;
var ${DEBUG_SHADER_DATA.Textures.SceneDepth}: texture_2d<f32>;
// Reads the opaque scene depth at the current fragment's screen position.
fn readSceneDepthAtFragment(fragmentPosition: vec4<f32>) -> f32 {
let depthTextureSize = vec2<f32>(textureDimensions(${DEBUG_SHADER_DATA.Textures.SceneDepth}));
let depthUv = fragmentPosition.xy / depthTextureSize;
if (
depthUv.x < 0.0 ||
depthUv.x > 1.0 ||
depthUv.y < 0.0 ||
depthUv.y > 1.0
) {
return 1.0;
}
return textureSampleLevel(
${DEBUG_SHADER_DATA.Textures.SceneDepth},
${DEBUG_SHADER_DATA.Textures.SceneDepth}Sampler,
depthUv,
0.0
).r;
}
// Returns true if a transparent fragment is behind the opaque scene depth.
fn isFragmentOccludedBySceneDepth(fragmentPosition: vec4<f32>) -> bool {
let sceneDepth = readSceneDepthAtFragment(fragmentPosition);
return fragmentPosition.z > sceneDepth;
}`
const SPHERE_VERTEX_SOURCE = `
#include<sceneUboDeclaration>
#include<meshUboDeclaration>
attribute ${DEBUG_SHADER_DATA.Attributes.Position}: vec3<f32>;
varying ${DEBUG_SHADER_DATA.Varyings.SpherePosition}: vec3<f32>;
@vertex
// Passes normalized sphere position to the fragment shader.
fn main(input: VertexInputs) -> FragmentInputs {
let localPosition = vertexInputs.${DEBUG_SHADER_DATA.Attributes.Position};
vertexOutputs.${DEBUG_SHADER_DATA.Varyings.SpherePosition} = normalize(localPosition);
vertexOutputs.position = scene.viewProjection * mesh.world * vec4<f32>(localPosition, 1.0);
}`
const PLANET_CLOUDS_FRAGMENT_SOURCE = `
varying ${DEBUG_SHADER_DATA.Varyings.SpherePosition}: vec3<f32>;
uniform ${DEBUG_SHADER_DATA.Uniforms.TimeSeconds}: f32;
var ${DEBUG_SHADER_DATA.Textures.CloudNoise}Sampler: sampler;
var ${DEBUG_SHADER_DATA.Textures.CloudNoise}: texture_2d_array<f32>;
${DEPTH_OCCLUSION_WGSL}
@fragment
// Simplified default planet cloud branch: alpha blend + manual depth discard.
fn main(input: FragmentInputs) -> FragmentOutputs {
if (isFragmentOccludedBySceneDepth(fragmentInputs.position)) {
discard;
}
let sphereCoords = normalize(fragmentInputs.${DEBUG_SHADER_DATA.Varyings.SpherePosition});
let noiseUv = sphereCoords.xy * 0.5 + vec2<f32>(0.5);
let noise = textureSample(
${DEBUG_SHADER_DATA.Textures.CloudNoise},
${DEBUG_SHADER_DATA.Textures.CloudNoise}Sampler,
noiseUv,
0
).r;
let cloudDensity = smoothstep(0.42, 0.62, noise) * 0.55;
if (cloudDensity <= 0.000001) {
discard;
}
fragmentOutputs.color = vec4<f32>(vec3<f32>(1.0), cloudDensity);
}`
const PLANET_ATMOSPHERE_PLANE_VERTEX_SOURCE = `
#include<sceneUboDeclaration>
#include<meshUboDeclaration>
attribute ${DEBUG_SHADER_DATA.Attributes.Position}: vec3<f32>;
varying ${DEBUG_SHADER_DATA.Varyings.PlaneUv}: vec2<f32>;
@vertex
// Mirrors the billboard plane pattern where UV is derived from local plane position.
fn main(input: VertexInputs) -> FragmentInputs {
let localPosition = vertexInputs.${DEBUG_SHADER_DATA.Attributes.Position};
let worldPosition = (mesh.world * vec4<f32>(localPosition, 1.0)).xyz;
vertexOutputs.${DEBUG_SHADER_DATA.Varyings.PlaneUv} = localPosition.xy * 0.5 + vec2<f32>(0.5);
vertexOutputs.position = scene.viewProjection * vec4<f32>(worldPosition, 1.0);
}`
const PLANET_ATMOSPHERE_PLANE_FRAGMENT_SOURCE = `
varying ${DEBUG_SHADER_DATA.Varyings.PlaneUv}: vec2<f32>;
${DEPTH_OCCLUSION_WGSL}
@fragment
// Simplified atmosphere billboard: additive transparent layer + manual depth discard.
fn main(input: FragmentInputs) -> FragmentOutputs {
if (isFragmentOccludedBySceneDepth(fragmentInputs.position)) {
discard;
}
let centeredUv = fragmentInputs.${DEBUG_SHADER_DATA.Varyings.PlaneUv} * 2.0 - vec2<f32>(1.0);
let radius = length(centeredUv);
if (radius > 1.0 || radius < 0.82) {
discard;
}
let alpha = (1.0 - smoothstep(0.82, 1.0, radius)) * 0.16;
fragmentOutputs.color = vec4<f32>(vec3<f32>(0.25, 0.58, 1.0), alpha);
}`
const SUN_SURFACE_FRAGMENT_SOURCE = `
varying ${DEBUG_SHADER_DATA.Varyings.SpherePosition}: vec3<f32>;
uniform ${DEBUG_SHADER_DATA.Uniforms.TimeSeconds}: f32;
var ${DEBUG_SHADER_DATA.Textures.SunActivityNoise}Sampler: sampler;
var ${DEBUG_SHADER_DATA.Textures.SunActivityNoise}: texture_2d_array<f32>;
var ${DEBUG_SHADER_DATA.Textures.SceneDepth}Sampler: sampler;
var ${DEBUG_SHADER_DATA.Textures.SceneDepth}: texture_2d<f32>;
@fragment
// Simplified default sun surface branch.
fn main(input: FragmentInputs) -> FragmentOutputs {
let sphereCoords = normalize(fragmentInputs.${DEBUG_SHADER_DATA.Varyings.SpherePosition});
let noiseUv = sphereCoords.xy * 0.5 + vec2<f32>(0.5);
let noise = textureSample(
${DEBUG_SHADER_DATA.Textures.SunActivityNoise},
${DEBUG_SHADER_DATA.Textures.SunActivityNoise}Sampler,
noiseUv,
0
).r;
let activity = smoothstep(0.14, 0.86, noise);
let color = mix(
vec3<f32>(1.0, 0.22, 0.02),
vec3<f32>(1.0, 0.92, 0.28),
activity
);
fragmentOutputs.color = vec4<f32>(color, 1.0);
}`
const SUN_HALO_PLANE_VERTEX_SOURCE = `
#include<sceneUboDeclaration>
#include<meshUboDeclaration>
attribute ${DEBUG_SHADER_DATA.Attributes.Position}: vec3<f32>;
varying ${DEBUG_SHADER_DATA.Varyings.PlaneUv}: vec2<f32>;
@vertex
// Mirrors the sun halo billboard plane where UV is derived from local plane position.
fn main(input: VertexInputs) -> FragmentInputs {
let localPosition = vertexInputs.${DEBUG_SHADER_DATA.Attributes.Position};
let worldPosition = (mesh.world * vec4<f32>(localPosition, 1.0)).xyz;
vertexOutputs.${DEBUG_SHADER_DATA.Varyings.PlaneUv} = localPosition.xy * 0.5 + vec2<f32>(0.5);
vertexOutputs.position = scene.viewProjection * vec4<f32>(worldPosition, 1.0);
}`
const SUN_HALO_PLANE_FRAGMENT_SOURCE = `
varying ${DEBUG_SHADER_DATA.Varyings.PlaneUv}: vec2<f32>;
uniform ${DEBUG_SHADER_DATA.Uniforms.DiscRadiusOnPlane}: f32;
uniform ${DEBUG_SHADER_DATA.Uniforms.TimeSeconds}: f32;
var ${DEBUG_SHADER_DATA.Textures.SunHaloNoise}Sampler: sampler;
var ${DEBUG_SHADER_DATA.Textures.SunHaloNoise}: texture_2d_array<f32>;
${DEPTH_OCCLUSION_WGSL}
@fragment
// Simplified default sun halo branch.
fn main(input: FragmentInputs) -> FragmentOutputs {
if (isFragmentOccludedBySceneDepth(fragmentInputs.position)) {
discard;
}
let planeUv = fragmentInputs.${DEBUG_SHADER_DATA.Varyings.PlaneUv};
let centeredUv = planeUv * 2.0 - vec2<f32>(1.0);
let radius = length(centeredUv);
if (radius > 1.0) {
discard;
}
let noise = textureSample(
${DEBUG_SHADER_DATA.Textures.SunHaloNoise},
${DEBUG_SHADER_DATA.Textures.SunHaloNoise}Sampler,
planeUv,
0
).r;
let sunDistance = radius / max(uniforms.${DEBUG_SHADER_DATA.Uniforms.DiscRadiusOnPlane}, 0.000001) - 1.0;
var brightness = 0.0;
if (sunDistance <= 0.0) {
brightness = (1.0 - radius / max(uniforms.${DEBUG_SHADER_DATA.Uniforms.DiscRadiusOnPlane}, 0.000001)) * 0.28;
} else if (sunDistance <= 1.0) {
brightness = 0.20;
} else {
brightness = 0.11;
}
brightness = brightness * (0.76 + noise * 0.24);
if (brightness <= 0.000001) {
fragmentOutputs.color = vec4<f32>(0.0);
return fragmentOutputs;
}
let color = mix(
vec3<f32>(1.0, 0.24, 0.02),
vec3<f32>(1.0, 0.78, 0.22),
clamp(1.0 - sunDistance / 2.0, 0.0, 1.0)
);
fragmentOutputs.color = vec4<f32>(color * brightness, 1.0);
}`
class DebugGameObjects {
public static engine: WebGPUEngine = null as unknown as WebGPUEngine
public static scene: Scene = null as unknown as Scene
public static camera: ArcRotateCamera = null as unknown as ArcRotateCamera
public static ambientLight: HemisphericLight = null as unknown as HemisphericLight
public static depthRenderer: DepthRenderer = null as unknown as DepthRenderer
public static sun: DebugSun = null as unknown as DebugSun
}
/** Babylon sphere mesh that preserves the original diameter for world-size calculations. */
class DebugSphere extends Mesh {
private readonly initialDiameter: number
public constructor(...args: Parameters<typeof CreateSphere>) {
const [name, options = {}, scene = null] = args
super(name, scene)
const sideOrientation = Mesh._GetDefaultSideOrientation(options.sideOrientation)
const sphereOptions = {
...options,
sideOrientation,
}
this.initialDiameter = sphereOptions.diameter || 1
this._originalBuilderSideOrientation = sideOrientation
CreateSphereVertexData(sphereOptions).applyToMesh(this, sphereOptions.updatable)
}
public get diameter(): number {
return this.initialDiameter * Math.max(
this.scaling.x,
this.scaling.y,
this.scaling.z,
)
}
}
class DebugSphereEffect {
public readonly mesh: DebugSphere
private readonly scene: Scene
private readonly targetMesh: DebugSphere
private currentSphereSurfaceOffset: number
public constructor(
targetMesh: DebugSphere,
sphereSurfaceOffset: number,
meshName: string,
) {
this.targetMesh = targetMesh
this.scene = targetMesh.getScene()
this.currentSphereSurfaceOffset = sphereSurfaceOffset
this.mesh = new DebugSphere(
meshName,
{
diameter: 2,
segments: DEBUG_SCENE_CONFIG.PlanetSegments,
sideOrientation: Mesh.FRONTSIDE,
},
this.scene,
)
this.mesh.isPickable = false
this.mesh.receiveShadows = false
this.mesh.checkCollisions = false
this.mesh.renderingGroupId = this.targetMesh.renderingGroupId
this.mesh.parent = this.targetMesh
this.mesh.position.set(0, 0, 0)
this.scene.onBeforeRenderObservable.add(() => {
this.syncWithTargetMesh()
})
this.syncWithTargetMesh()
}
private syncWithTargetMesh(): void {
const targetRadius = this.targetMesh.diameter / 2
const sphereRadius = targetRadius * (1 + this.currentSphereSurfaceOffset)
this.mesh.scaling.setAll(sphereRadius)
}
}