Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/dev/core/src/Lights/light.ts
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,15 @@ export abstract class Light extends Node implements ISortableLight {
this.getScene().sortLightsByPriority();
}

/**
* Returns true when all texture resources used by this light are ready (e.g. projection textures).
* Override in subclasses that use texture resources.
* @returns true if all light textures are ready
*/
public areLightTexturesReady(): boolean {
return true;
}

/**
* Prepares the list of defines specific to the light type.
* @param defines the list of defines
Expand Down
11 changes: 11 additions & 0 deletions packages/dev/core/src/Lights/spotLight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,17 @@ export class SpotLight extends ShadowLight {
return engine.useReverseDepthBuffer && engine.isNDCHalfZRange ? 0 : maxZ;
}

/** @override */
public override areLightTexturesReady(): boolean {
if (this._projectionTexture && !this._projectionTexture.isReadyOrNotBlocking()) {
return false;
}
if (this._iesProfileTexture && !this._iesProfileTexture.isReadyOrNotBlocking()) {
return false;
}
return true;
}

/**
* Prepares the list of defines specific to the light type.
* @param defines the list of defines
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
PrepareUniformsAndSamplersList,
PrepareUniformsAndSamplersForIBL,
PrepareUniformLayoutForIBL,
AreLightsTexturesReady,
} from "../materialHelper.functions";
import { SerializationHelper } from "../../Misc/decorators.serialization";
import { ShaderLanguage } from "../shaderLanguage";
Expand Down Expand Up @@ -606,6 +607,10 @@ export class BackgroundMaterial extends BackgroundMaterialBase {
PrepareDefinesForLights(scene, mesh, defines, false, this._maxSimultaneousLights);
defines._needNormals = true;

if (!AreLightsTexturesReady(scene, mesh, this._maxSimultaneousLights)) {
return false;
}

// Multiview
PrepareDefinesForMultiview(scene, defines);

Expand Down
17 changes: 17 additions & 0 deletions packages/dev/core/src/Materials/Node/Blocks/Dual/lightBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,20 @@ export class LightBlock extends NodeMaterialBlock {
}
}

/**
* Checks if the block is ready
* @param mesh - the mesh to check
* @param nodeMaterial - the node material
* @param defines - the list of defines
* @returns true if ready
*/
public override isReady(mesh: AbstractMesh, nodeMaterial: NodeMaterial, defines: NodeMaterialDefines) {
if (this.light && !this.light.areLightTexturesReady()) {
return false;
}
return true;
}

/**
* Update the uniforms and samples
* @param state - the build state
Expand Down Expand Up @@ -401,6 +415,9 @@ export class LightBlock extends NodeMaterialBlock {
const accessor = isWGSL ? "fragmentInputs." : "";
state.sharedData.forcedBindableBlocks.push(this);
state.sharedData.blocksWithDefines.push(this);
if (this.light) {
state.sharedData.blockingBlocks.push(this);
}
const worldPos = this.worldPosition;

let worldPosVariableName = worldPos.associatedVariableName;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,10 @@ export class PBRMetallicRoughnessBlock extends NodeMaterialBlock {
}
}

if (this.light && !this.light.areLightTexturesReady()) {
return false;
}

return true;
}

Expand Down
6 changes: 5 additions & 1 deletion packages/dev/core/src/Materials/Node/nodeMaterial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import { type PrePassOutputBlock } from "./Blocks/Fragment/prePassOutputBlock";
import { type NodeMaterialTeleportOutBlock } from "./Blocks/Teleport/teleportOutBlock";
import { type NodeMaterialTeleportInBlock } from "./Blocks/Teleport/teleportInBlock";
import { Logger } from "core/Misc/logger";
import { PrepareDefinesForCamera, PrepareDefinesForPrePass } from "../materialHelper.functions";
import { PrepareDefinesForCamera, PrepareDefinesForPrePass, AreLightsTexturesReady } from "../materialHelper.functions";
import { ImageProcessingDefinesMixin } from "../imageProcessingConfiguration.defines";
import { ShaderLanguage } from "../shaderLanguage";
import { AbstractEngine } from "../../Engines/abstractEngine";
Expand Down Expand Up @@ -1663,6 +1663,10 @@ export class NodeMaterial extends NodeMaterialBase {
return false;
}

if (!AreLightsTexturesReady(scene, mesh, this.maxSimultaneousLights)) {
return false;
}

const result = this._processDefines(defines, mesh, useInstances, subMesh);

if (result) {
Expand Down
5 changes: 5 additions & 0 deletions packages/dev/core/src/Materials/PBR/openpbrMaterial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
PrepareUniformsAndSamplersList,
PrepareUniformsAndSamplersForIBL,
PrepareUniformLayoutForIBL,
AreLightsTexturesReady,
} from "../materialHelper.functions";
import { Constants } from "../../Engines/constants";
import { VertexBuffer } from "../../Buffers/buffer";
Expand Down Expand Up @@ -2237,6 +2238,10 @@ export class OpenPBRMaterial extends OpenPBRMaterialBase {
Logger.Warn("OpenPBRMaterial: Normals have been created for the mesh: " + mesh.name);
}

if (!AreLightsTexturesReady(scene, mesh, this._maxSimultaneousLights, this._disableLighting)) {
return false;
}

const previousEffect = subMesh.effect;
const lightDisposed = defines._areLightsDisposed;
const effect = this._prepareEffect(mesh, subMesh.getRenderingMesh(), defines, this.onCompiled, this.onError, useInstances, null);
Expand Down
5 changes: 5 additions & 0 deletions packages/dev/core/src/Materials/PBR/pbrBaseMaterial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
PrepareUniformsAndSamplersList,
PrepareUniformsAndSamplersForIBL,
PrepareUniformLayoutForIBL,
AreLightsTexturesReady,
} from "../materialHelper.functions";
import { ShaderLanguage } from "../shaderLanguage";
import { MaterialHelperGeometryRendering } from "../materialHelper.geometryrendering";
Expand Down Expand Up @@ -1190,6 +1191,10 @@ export abstract class PBRBaseMaterial extends PBRBaseMaterialBase {
Logger.Warn("PBRMaterial: Normals have been created for the mesh: " + mesh.name);
}

if (!AreLightsTexturesReady(scene, mesh, this._maxSimultaneousLights, this._disableLighting)) {
return false;
}

const previousEffect = subMesh.effect;
const lightDisposed = defines._areLightsDisposed;
const effect = this._prepareEffect(mesh, subMesh.getRenderingMesh(), defines, this.onCompiled, this.onError, useInstances, null);
Expand Down
25 changes: 25 additions & 0 deletions packages/dev/core/src/Materials/materialHelper.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,31 @@ export function PrepareDefinesForMisc(
}
}

/**
* Checks whether the texture resources used by the lights that will affect the given mesh are ready.
* This mirrors the light iteration performed by {@link PrepareDefinesForLights} and {@link BindLights}:
* it only considers lights that can affect the mesh (already filtered into `mesh.lightSources`)
* and respects the material's `maxSimultaneousLights` cap and the `disableLighting` flag.
* @param scene The scene the mesh belongs to
* @param mesh The mesh to check
* @param maxSimultaneousLights The material's max simultaneous lights cap
* @param disableLighting Whether lighting is disabled for the material
* @returns true if all affecting lights report their textures are ready
*/
export function AreLightsTexturesReady(scene: Scene, mesh: AbstractMesh, maxSimultaneousLights: number, disableLighting = false): boolean {
if (!scene.lightsEnabled || disableLighting) {
return true;
}
const lights = mesh.lightSources;
const count = Math.min(lights.length, maxSimultaneousLights);
for (let i = 0; i < count; i++) {
if (!lights[i].areLightTexturesReady()) {
return false;
}
}
return true;
}

/**
* Prepares the defines related to the light information passed in parameter
* @param scene The scene we are intending to draw
Expand Down
5 changes: 5 additions & 0 deletions packages/dev/core/src/Materials/standardMaterial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
PrepareUniformsAndSamplersForIBL,
PrepareUniformsAndSamplersList,
PrepareUniformLayoutForIBL,
AreLightsTexturesReady,
} from "./materialHelper.functions";
import { SerializationHelper } from "../Misc/decorators.serialization";
import { ShaderLanguage } from "./shaderLanguage";
Expand Down Expand Up @@ -745,6 +746,10 @@ export class StandardMaterial extends StandardMaterialBase {
// Lights
defines._needNormals = PrepareDefinesForLights(scene, mesh, defines, true, this._maxSimultaneousLights, this._disableLighting);

if (!AreLightsTexturesReady(scene, mesh, this._maxSimultaneousLights, this._disableLighting)) {
return false;
}

// Multiview
PrepareDefinesForMultiview(scene, defines);

Expand Down
99 changes: 99 additions & 0 deletions packages/dev/core/test/unit/Lights/babylon.spotLight.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { type Engine } from "core/Engines/engine";
import { NullEngine } from "core/Engines/nullEngine";
import { SpotLight } from "core/Lights/spotLight";
import { StandardMaterial } from "core/Materials/standardMaterial";
import { Texture } from "core/Materials/Textures/texture";
import { Vector3 } from "core/Maths/math.vector";
import { CreateBox } from "core/Meshes/Builders/boxBuilder";
import { Scene } from "core/scene";

// Pre-load shader modules that StandardMaterial dynamically imports during
// effect creation. Without these, the fire-and-forget import() may still be
// resolving when the test environment tears down, causing EnvironmentTeardownError.
import "core/Shaders/default.fragment";
import "core/Shaders/default.vertex";

describe("SpotLight", () => {
let engine: Engine;
let scene: Scene;

beforeEach(() => {
engine = new NullEngine({
renderHeight: 256,
renderWidth: 256,
textureSize: 256,
deterministicLockstep: false,
lockstepMaxSteps: 1,
});
scene = new Scene(engine);
});

afterEach(() => {
scene.dispose();
engine.dispose();
});

describe("light texture readiness", () => {
// When a light owns texture resources that are not yet ready (e.g. a SpotLight's
// projectionTexture or iesProfileTexture), material readiness must reflect that
// so scene.isReady() returns false and scene.executeWhenReady() waits for those
// textures before firing. Otherwise, callers that render on executeWhenReady —
// such as visual-test harnesses — can produce frames before the texture-dependent
// effect is applied.
//
// Each test sets up a lit mesh with a StandardMaterial, pins a light-texture
// readiness signal to "not ready", and then polls scene.isReady(). Polling with
// a microtask yield lets the NullEngine's async shader compile settle before the
// assertion, so the test guards against a material flipping to ready prematurely.
async function pollSceneIsReady(scene: Scene): Promise<boolean> {
for (let i = 0; i < 20; i++) {
if (scene.isReady()) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
return false;
}

function createLitBoxScene(): SpotLight {
const mesh = CreateBox("mesh", {}, scene);
mesh.material = new StandardMaterial("mat", scene);
return new SpotLight("spot", new Vector3(0, 5, 0), new Vector3(0, -1, 0), Math.PI / 4, 2, scene);
}

it("scene.isReady() must not return true while a light reports its textures are not ready", async () => {
const light = createLitBoxScene();

// Plumbing-level contract: material readiness must consult the Light's
// areLightTexturesReady() contract regardless of which texture(s) caused
// it to report not-ready. This ensures future light types that add their
// own textures (and override areLightTexturesReady) are gated automatically.
(light as any).areLightTexturesReady = () => false;

expect(await pollSceneIsReady(scene)).toBe(false);
});

it("scene.isReady() must not return true while a SpotLight's projectionTexture is not ready", async () => {
const light = createLitBoxScene();

const tex = new Texture(null, scene);
vi.spyOn(tex, "isReady").mockReturnValue(false);
light.projectionTexture = tex;

expect(await pollSceneIsReady(scene)).toBe(false);
});

it("scene.isReady() must not return true while a SpotLight's iesProfileTexture is not ready", async () => {
const light = createLitBoxScene();

const tex = new Texture(null, scene);
vi.spyOn(tex, "isReady").mockReturnValue(false);
light.iesProfileTexture = tex;

expect(await pollSceneIsReady(scene)).toBe(false);
});
});
});