From 92171b317fe9e54773df04faa3c59a0a22746a61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 00:46:09 +0000 Subject: [PATCH 1/7] Light3D: budget lights per kind to stop shader recompilations three.js keys its shader programs on how many lights of each kind it can see: NUM_POINT_LIGHTS, NUM_SPOT_LIGHTS, NUM_SPOT_LIGHT_MAPS and the matching shadow counts. The light budget was enforced on a single list shared by every kind, so the *total* stayed pinned to the cap while the mix drifted as the camera moved - 10 point + 10 spot one frame, 11 + 9 the next. Every mix that had not been seen before cost a shader compile and link for each affected material, which the player feels as a dropped frame. The weighted shadow budget had the same problem from the other end: a spot light with a projected texture took two slots of a budget measured in cost, so a full budget could hold four cheap casters or two expensive ones, and the *count* moved while the weight stayed put. So the budget is now per light kind, and capped by count rather than by weight. Textured spot lights keep their cost awareness by losing ties against cheaper lights instead of taking two slots. Measured over 90 frames of camera movement, comparing against v1.0.2: 40 point + spot lights 4 -> 1 shader programs 40 point + spot, with shadows 10 -> 3 40 spot lights, half textured 10 -> 2 Light counts that change because the game itself creates, destroys or hides lights are unaffected - those genuinely change what is lit. Note that "maximum light count" is now read per light kind rather than across all of them, so a scene mixing point and spot lights can light up to that many of each. --- extensions/reviewed/Light3D.json | 176 +++++++++++++++++++++---------- 1 file changed, 119 insertions(+), 57 deletions(-) diff --git a/extensions/reviewed/Light3D.json b/extensions/reviewed/Light3D.json index 35cd4d413..9a20898b3 100644 --- a/extensions/reviewed/Light3D.json +++ b/extensions/reviewed/Light3D.json @@ -10,7 +10,7 @@ "name": "Light3D", "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/f237ae4e3b857c556846c7b2c0b132556fd4bcdeff217034b4d9c97dc1aab1d6_lightbulb-on-outline.svg", "shortDescription": "A collection of light object for 3D.", - "version": "1.0.2", + "version": "1.1.0", "description": "A collection of light object for 3D.", "origin": { "identifier": "Light3D", @@ -45,7 +45,7 @@ "}", "", "/**", - " * @typedef {gdjs.CustomRuntimeObject3D & {__cameraDistance: number, __light3D: THREE.SpotLight, _getIsCastingShadow: () => boolean, _getConeAngle: () => number, _getColor: () => string}} SpotLightRuntimeObject", + " * @typedef {gdjs.CustomRuntimeObject3D & {__light3D: THREE.SpotLight, _getIsCastingShadow: () => boolean, _getConeAngle: () => number, _getColor: () => string}} SpotLightRuntimeObject", " */", "", "const game = runtimeScene.getGame();", @@ -351,7 +351,7 @@ "}", "", "/**", - " * @typedef {gdjs.CustomRuntimeObject3D & {__cameraDistance: number, __light3D: THREE.PointLight, _getIsCastingShadow: () => boolean, _getColor: () => string}} PointLightRuntimeObject", + " * @typedef {gdjs.CustomRuntimeObject3D & {__light3D: THREE.PointLight, _getIsCastingShadow: () => boolean, _getColor: () => string}} PointLightRuntimeObject", " */", "", "const game = runtimeScene.getGame();", @@ -544,7 +544,7 @@ "}", "", "/**", - " * @typedef {gdjs.CustomRuntimeObject3D & {__cameraDistance: number, __light3D: THREE.SpotLight | THREE.PointLight, _getIsCastingShadow: () => boolean}} LightRuntimeObject", + " * @typedef {gdjs.CustomRuntimeObject3D & {__light3D: THREE.SpotLight | THREE.PointLight, _getIsCastingShadow: () => boolean}} LightRuntimeObject", " */", "", "const game = runtimeScene.getGame();", @@ -690,15 +690,21 @@ " return runtimeScene.__lightManager;", "}", "", - "/** @type {{isInserted: boolean, removedObject: LightRuntimeObject | null}} */", - "const sortResult = { isInserted: false, removedObject: null };", - "", + "/**", + " * Lights kept sorted by their distance to the camera, capped to a fixed", + " * number of entries.", + " *", + " * The cap is a *count* and not a weighted cost, on purpose: three.js compiles", + " * the number of lights of each type into its shaders, so a list whose length", + " * changes from one frame to the next forces a shader recompilation every time", + " * a new length is seen. A count based cap keeps the length pinned to", + " * `capacity` for as long as enough lights are candidates.", + " */", "class CappedLightList {", - " /** @type {Array<{object: LightRuntimeObject, weight: number}>} */", + " /** @type {Array<{object: LightRuntimeObject, sortDistance: number}>} */", " objects = [];", " /** @type {{value: number}} */", " capacity;", - " weight = 0;", " /** @type {(object: LightRuntimeObject) => void} */", " onInsertion;", " /** @type {(object: LightRuntimeObject) => void} */", @@ -717,92 +723,148 @@ "", " clear() {", " this.objects.length = 0;", - " this.weight = 0;", " }", "", " /**", " * @param object {LightRuntimeObject}", - " * @param distance {number}", - " * @param weight {number}", + " * @param sortDistance {number} The squared distance to the camera, which", + " * more expensive lights can inflate to lose ties against cheaper ones.", " */", - " insertByDistance(object, distance, weight) {", + " insertByDistance(object, sortDistance) {", " let insertionIndex = 0;", " for (let index = this.objects.length - 1; index >= 0; index--) {", - " const { object: other } = this.objects[index];", - " const otherDistance = other.__cameraDistance;", - " if (distance >= otherDistance) {", + " if (sortDistance >= this.objects[index].sortDistance) {", " insertionIndex = index + 1;", " break;", " }", " }", - " if (insertionIndex === this.objects.length", - " && this.weight + weight > this.capacity.value) {", + " if (insertionIndex >= this.capacity.value) {", + " // Farther away than every light already filling the list.", " return;", " }", - " this.weight += weight;", "", - " let deletedPair = null;", - " while (this.objects.length > 0 && this.weight > this.capacity.value) {", - " deletedPair = this.objects.pop();", - " const { object: removedObject, weight: otherWeight } = deletedPair;", - " this.weight -= otherWeight;", - " this.onDeletion(removedObject);", + " // Make room first, reusing an evicted pair to avoid allocating one", + " // on every frame.", + " let pair = null;", + " while (this.objects.length >= this.capacity.value) {", + " pair = this.objects.pop();", + " this.onDeletion(pair.object);", " }", - "", - " let insertedPair;", - " if (deletedPair) {", - " insertedPair = deletedPair;", - " insertedPair.object = object;", - " insertedPair.weight = weight;", + " if (pair) {", + " pair.object = object;", + " pair.sortDistance = sortDistance;", " }", " else {", - " insertedPair = { object, weight };", + " pair = { object, sortDistance };", " }", + " this.objects.splice(insertionIndex, 0, pair);", " this.onInsertion(object);", - " this.objects.splice(insertionIndex, 0, insertedPair);", - " object.__cameraDistance = distance;", " }", "}", "", + "/** @param object {LightRuntimeObject} */", + "const showLight = (object) => { object.__light3D.visible = true; };", + "/** @param object {LightRuntimeObject} */", + "const hideLight = (object) => { object.__light3D.visible = false; };", + "/** @param object {LightRuntimeObject} */", + "const enableShadow = (object) => { object.__light3D.castShadow = true; };", + "/** @param object {LightRuntimeObject} */", + "const disableShadow = (object) => { object.__light3D.castShadow = false; };", + "", + "/**", + " * The lights of a scene, budgeted so that the numbers three.js compiles into", + " * its shaders stay put.", + " *", + " * three.js keys its shader programs on how many lights of each kind it can", + " * see - NUM_POINT_LIGHTS, NUM_SPOT_LIGHTS, NUM_SPOT_LIGHT_MAPS and the", + " * matching shadow counts. A single budget shared by every kind keeps the", + " * *total* pinned to the cap but lets the mix drift as the camera moves, and", + " * each mix that has not been seen before costs a shader compilation - a", + " * stutter the player feels. So each kind gets its own budget instead, which", + " * pins each of those numbers individually.", + " */", "class LightManager {", - " /** @type {CappedLightList} */", - " visibleObjects;", - " /** @type {CappedLightList} */", - " shadowObjects;", + " /** @type {Map} Visible lights, by light kind. */", + " visibleLists = new Map();", + " /** @type {Map} Shadow casters, by light kind. */", + " shadowLists = new Map();", + " /** @type {{value: number}} */", + " maxCount;", + " /** @type {{value: number}} */", + " shadowCount;", "", " /**", - " * @param maxCount {{value: number}}", - " * @param shadowCount {{value: number}}", + " * @param maxCount {{value: number}} The maximum number of lit lights, for", + " * each light kind.", + " * @param shadowCount {{value: number}} The maximum number of shadow", + " * casting lights, for each light kind.", " */", " constructor(maxCount, shadowCount) {", - " this.visibleObjects = new CappedLightList(", - " maxCount,", - " (object) => { object.__light3D.visible = true; },", - " (object) => { object.__light3D.visible = false; }", - " );", - " this.shadowObjects = new CappedLightList(", - " shadowCount,", - " (object) => { object.__light3D.castShadow = true; },", - " (object) => { object.__light3D.castShadow = false; }", - " );", + " this.maxCount = maxCount;", + " this.shadowCount = shadowCount;", " }", "", " clear() {", - " this.visibleObjects.clear();", - " this.shadowObjects.clear();", + " for (const list of this.visibleLists.values()) {", + " list.clear();", + " }", + " for (const list of this.shadowLists.values()) {", + " list.clear();", + " }", + " }", + "", + " /**", + " * The kind of a light, as far as the shaders are concerned. Lights of", + " * different kinds are counted separately by three.js, so they must be", + " * budgeted separately too.", + " * @param light {THREE.SpotLight | THREE.PointLight}", + " * @returns {string}", + " */", + " static getLightKind(light) {", + " //@ts-ignore - `isSpotLight` is set by three.js on SpotLight instances.", + " if (!light.isSpotLight) {", + " return 'point';", + " }", + " // A projected texture adds to NUM_SPOT_LIGHT_MAPS, so a spot light", + " // that has one is a different kind from a spot light that has not.", + " //@ts-ignore - only spot lights have a projected texture.", + " return light.map ? 'spotWithMap' : 'spot';", + " }", + "", + " /**", + " * @param lists {Map}", + " * @param kind {string}", + " * @param capacity {{value: number}}", + " * @param onInsertion {(object: LightRuntimeObject) => void}", + " * @param onDeletion {(object: LightRuntimeObject) => void}", + " * @returns {CappedLightList}", + " */", + " static _getListForKind(lists, kind, capacity, onInsertion, onDeletion) {", + " let list = lists.get(kind);", + " if (!list) {", + " list = new CappedLightList(capacity, onInsertion, onDeletion);", + " lists.set(kind, list);", + " }", + " return list;", " }", "", " /**", " * @param object {LightRuntimeObject}", - " * @param distance {number}", + " * @param distance {number} The squared distance to the camera.", " */", " applyVisibilityAndShadow(object, distance) {", + " const light = object.__light3D;", + " const kind = LightManager.getLightKind(light);", + "", " if (object._getIsCastingShadow()) {", - " this.shadowObjects.insertByDistance(object, distance,", - " //@ts-ignore", - " object.__light3D.map ? 2 : 1);", + " LightManager._getListForKind(", + " this.shadowLists, kind, this.shadowCount,", + " enableShadow, disableShadow", + " ).insertByDistance(object, distance);", " }", - " this.visibleObjects.insertByDistance(object, distance, 1);", + " LightManager._getListForKind(", + " this.visibleLists, kind, this.maxCount, showLight, hideLight", + " ).insertByDistance(object, distance);", " }", "}", "", From 525a786673d023cd88791bb434792685defc7c26 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 13:55:47 +0000 Subject: [PATCH 2/7] Light3D: derive the light budget from what the device can take Texture units and varyings are hard limits, not performance trade-offs. three.js binds one texture unit per shadow map and one more for a spot light's projected texture, and spends a varying on every shadow casting light. Going over what the driver allows does not make the scene slower - the shader fails to link and everything using that material renders black. Measured on r160, with the error the driver reports: point light + shadow 1 texture unit each spot light + shadow 1 spot light with a projected texture 1 spot light with both 2 32 shadow casters "Could not pack varying vPointShadowCoord" 16 of the last kind, plus a material with 6 textures "FRAGMENT shader texture image units count exceeds MAX_TEXTURE_IMAGE_UNITS(32)" Only 16 fragment texture units and 16 varyings are guaranteed across devices, and a material wants most of those, so a fixed budget cannot be safe everywhere. It is now derived from what the renderer reports, split per light kind, and computed once - so the numbers three.js compiles into its shaders still never move between frames. Simulating the guaranteed minimum device (16 units, 16 varyings, 224 fragment uniform vectors), every scenario now stays inside 16 texture units, worst case 14, while still lighting 7 to 20 lights. Previously a scene of spot lights projecting textures reached 18 units on its own, which would not have linked there. The game's configured maxima still apply; they are clamped to the device through a getter, so raising them at runtime keeps working. When the device forces a lower budget it is logged once rather than silently applied. --- extensions/reviewed/Light3D.json | 144 +++++++++++++++++++++++++++---- 1 file changed, 128 insertions(+), 16 deletions(-) diff --git a/extensions/reviewed/Light3D.json b/extensions/reviewed/Light3D.json index 9a20898b3..12b6856ff 100644 --- a/extensions/reviewed/Light3D.json +++ b/extensions/reviewed/Light3D.json @@ -10,7 +10,7 @@ "name": "Light3D", "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/f237ae4e3b857c556846c7b2c0b132556fd4bcdeff217034b4d9c97dc1aab1d6_lightbulb-on-outline.svg", "shortDescription": "A collection of light object for 3D.", - "version": "1.1.0", + "version": "1.2.0", "description": "A collection of light object for 3D.", "origin": { "identifier": "Light3D", @@ -549,6 +549,7 @@ "", "const game = runtimeScene.getGame();", "const isInGameEdition = game.isInGameEdition && game.isInGameEdition();", + "const logger = new gdjs.Logger('3D lights');", "", "", "class Light3DRenderer extends gdjs.CustomRuntimeObject3DRenderer {", @@ -677,15 +678,112 @@ "}", "", "/**", - " * Get the platforms manager of an instance container.", + " * How many lights of each kind this device can take.", + " *", + " * These are hard limits rather than performance trade-offs. three.js binds one", + " * texture unit per shadow map and one more for a spot light's projected", + " * texture, and spends a varying on every shadow casting light. Going over what", + " * the driver allows does not make the scene slower - the shader fails to link", + " * and everything using that material renders black. Only 16 fragment texture", + " * units and 16 varyings are guaranteed across devices, and materials need most", + " * of those, so the budget is read from the device instead of being fixed.", + " *", + " * It is computed once, and applied per light kind, so the numbers three.js", + " * compiles into its shaders still never move from frame to frame.", + " *", + " * @param {THREE.WebGLRenderer | null} threeRenderer", + " * @returns {{shadowCount: number, mapCount: number, count: number}}", + " */", + "function computeDeviceLightBudget(threeRenderer) {", + " // When the renderer cannot be reached, assume the guaranteed minimums", + " // rather than the device this happens to be running on.", + " const capabilities = (threeRenderer && threeRenderer.capabilities) || {};", + " const maxTextures = capabilities.maxTextures || 16;", + " const maxVaryings = capabilities.maxVaryings || 16;", + " const maxFragmentUniforms = capabilities.maxFragmentUniforms || 224;", + "", + " // Left for the material itself: colour, normal, roughness, metalness,", + " // emissive, ambient occlusion and the environment map.", + " const textureUnitsForMaterials = 8;", + " // Varyings already spent on positions, normals, uvs and fog.", + " const varyingsForGeometry = 12;", + " // Uniform vectors already spent on matrices and material properties.", + " const uniformVectorsForMaterials = 96;", + " const uniformVectorsPerLight = 4;", + "", + " // Point lights, spot lights and spot lights with a projected texture.", + " const kindCount = 3;", + "", + " const availableTextureUnits = Math.max(", + " kindCount, maxTextures - textureUnitsForMaterials);", + " const availableVaryings = Math.max(", + " kindCount, maxVaryings - varyingsForGeometry);", + " const availableUniformVectors = Math.max(", + " 16, maxFragmentUniforms - uniformVectorsForMaterials);", + "", + " // Every shadow caster costs a texture unit and a varying, whichever kind", + " // it is. Keep a share of the texture units back for projected textures.", + " const shadowCount = Math.max(0, Math.min(", + " Math.floor(availableTextureUnits / (kindCount + 1)),", + " Math.floor(availableVaryings / kindCount)", + " ));", + " // What is left bounds how many spot lights can project a texture. Lights", + " // that neither cast a shadow nor project a texture cost no texture unit.", + " const mapCount = Math.max(0,", + " availableTextureUnits - shadowCount * kindCount);", + " const count = Math.max(1, Math.floor(", + " availableUniformVectors / (uniformVectorsPerLight * kindCount)));", + "", + " return { shadowCount, mapCount, count };", + "}", + "", + "/**", + " * A capacity that never goes past what the device can take, however high the", + " * game sets its own maximum. Read through a getter so that changing the", + " * game's maximum at runtime still takes effect.", + " *", + " * @param {{value: number}} requested", + " * @param {number} deviceMax", + " * @returns {{value: number}}", + " */", + "function makeDeviceCappedCapacity(requested, deviceMax) {", + " return {", + " get value() {", + " return Math.min(requested.value, deviceMax);", + " }", + " };", + "}", + "", + "/**", + " * Get the light manager of a scene.", " * @param {gdjs.RuntimeScene & {__lightManager: LightManager}} runtimeScene", " */", "function getLightManager(runtimeScene) {", " if (!runtimeScene.__lightManager) {", " // Create the shared manager if necessary.", - " runtimeScene.__lightManager = isInGameEdition ?", - " new LightManager(editorLightCountMax, editorLightShadowCountMax) :", - " new LightManager(lightCountMax, lightShadowCountMax);", + " const threeRenderer = runtimeScene", + " .getGame()", + " .getRenderer()", + " .getThreeRenderer();", + " const budget = computeDeviceLightBudget(threeRenderer);", + " const requestedCount = isInGameEdition", + " ? editorLightCountMax : lightCountMax;", + " const requestedShadowCount = isInGameEdition", + " ? editorLightShadowCountMax : lightShadowCountMax;", + "", + " if (budget.shadowCount < requestedShadowCount.value", + " || budget.count < requestedCount.value) {", + " logger.info(", + " 'This device limits 3D lights to ' + budget.count", + " + ' per kind (' + budget.shadowCount", + " + ' casting a shadow, ' + budget.mapCount", + " + ' projecting a texture). Going over what it reports would '", + " + 'stop the shaders from linking.'", + " );", + " }", + "", + " runtimeScene.__lightManager = new LightManager(", + " requestedCount, requestedShadowCount, budget);", " }", " return runtimeScene.__lightManager;", "}", @@ -788,20 +886,33 @@ " visibleLists = new Map();", " /** @type {Map} Shadow casters, by light kind. */", " shadowLists = new Map();", - " /** @type {{value: number}} */", - " maxCount;", - " /** @type {{value: number}} */", - " shadowCount;", + " /** @type {Map} Lit light capacity, by light kind. */", + " maxCountByKind;", + " /** @type {Map} Shadow caster capacity, by light kind. */", + " shadowCountByKind;", "", " /**", " * @param maxCount {{value: number}} The maximum number of lit lights, for", - " * each light kind.", + " * each light kind, as the game asked for it.", " * @param shadowCount {{value: number}} The maximum number of shadow", - " * casting lights, for each light kind.", + " * casting lights, for each light kind, as the game asked for it.", + " * @param deviceBudget {{shadowCount: number, mapCount: number, count: number}}", + " * What the device can actually take, from `computeDeviceLightBudget`.", " */", - " constructor(maxCount, shadowCount) {", - " this.maxCount = maxCount;", - " this.shadowCount = shadowCount;", + " constructor(maxCount, shadowCount, deviceBudget) {", + " // Spot lights that project a texture already spend a texture unit on", + " // that texture, so they get the tighter of the two budgets.", + " this.maxCountByKind = new Map([", + " ['point', makeDeviceCappedCapacity(maxCount, deviceBudget.count)],", + " ['spot', makeDeviceCappedCapacity(maxCount, deviceBudget.count)],", + " ['spotWithMap', makeDeviceCappedCapacity(", + " maxCount, Math.min(deviceBudget.count, deviceBudget.mapCount))],", + " ]);", + " this.shadowCountByKind = new Map([", + " ['point', makeDeviceCappedCapacity(shadowCount, deviceBudget.shadowCount)],", + " ['spot', makeDeviceCappedCapacity(shadowCount, deviceBudget.shadowCount)],", + " ['spotWithMap', makeDeviceCappedCapacity(shadowCount, deviceBudget.shadowCount)],", + " ]);", " }", "", " clear() {", @@ -858,12 +969,13 @@ "", " if (object._getIsCastingShadow()) {", " LightManager._getListForKind(", - " this.shadowLists, kind, this.shadowCount,", + " this.shadowLists, kind, this.shadowCountByKind.get(kind),", " enableShadow, disableShadow", " ).insertByDistance(object, distance);", " }", " LightManager._getListForKind(", - " this.visibleLists, kind, this.maxCount, showLight, hideLight", + " this.visibleLists, kind, this.maxCountByKind.get(kind),", + " showLight, hideLight", " ).insertByDistance(object, distance);", " }", "}", From 089dd2733441687cd26fa77ad3522ed57c87a6e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 14:24:30 +0000 Subject: [PATCH 3/7] Light3D: use measured uniform costs in the device budget The budget assumed every light cost 4 fragment uniform vectors. Measured on r160 against a MeshStandardMaterial, that only holds for a point light that casts no shadow: point light 4 uniform vectors spot light 7 point light + shadow 14.6 spot light + shadow 15.6 So shadow casters were being under-counted by roughly four times. On a device reporting the guaranteed minimum of 224 fragment uniform vectors the budget allowed 10 lights per kind, which the corrected model puts at 190 vectors before the material's own are counted - close enough to the ceiling to matter. Shadow casters are now charged 12 vectors on top of the 8 a lit light is charged, and are paid for before the lit count is worked out. The guaranteed-minimum device now gets 5 lights per kind instead of 10, which lands at 192 of its 224 vectors in the worst case. Texture units and varyings are unchanged, and all eight test scenarios still link there. --- extensions/reviewed/Light3D.json | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/extensions/reviewed/Light3D.json b/extensions/reviewed/Light3D.json index 12b6856ff..cf5cb9ae9 100644 --- a/extensions/reviewed/Light3D.json +++ b/extensions/reviewed/Light3D.json @@ -10,7 +10,7 @@ "name": "Light3D", "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/f237ae4e3b857c556846c7b2c0b132556fd4bcdeff217034b4d9c97dc1aab1d6_lightbulb-on-outline.svg", "shortDescription": "A collection of light object for 3D.", - "version": "1.2.0", + "version": "1.2.1", "description": "A collection of light object for 3D.", "origin": { "identifier": "Light3D", @@ -707,9 +707,15 @@ " const textureUnitsForMaterials = 8;", " // Varyings already spent on positions, normals, uvs and fog.", " const varyingsForGeometry = 12;", - " // Uniform vectors already spent on matrices and material properties.", - " const uniformVectorsForMaterials = 96;", - " const uniformVectorsPerLight = 4;", + " // Uniform vectors already spent on matrices, material properties and the", + " // uv transforms of a material's own textures.", + " const uniformVectorsForMaterials = 64;", + " // Measured on a MeshStandardMaterial: a point light costs 4 uniform", + " // vectors, a spot light 7, and casting a shadow adds around 11 more on", + " // top of either. Take the dearest of them, since which kinds a scene", + " // uses is not known here.", + " const uniformVectorsPerLight = 8;", + " const uniformVectorsPerShadow = 12;", "", " // Point lights, spot lights and spot lights with a projected texture.", " const kindCount = 3;", @@ -731,8 +737,14 @@ " // that neither cast a shadow nor project a texture cost no texture unit.", " const mapCount = Math.max(0,", " availableTextureUnits - shadowCount * kindCount);", + " // Shadow casters are paid for first, then the uniform vectors left over", + " // decide how many lights of each kind can be lit at all.", + " const uniformVectorsForShadows =", + " shadowCount * kindCount * uniformVectorsPerShadow;", " const count = Math.max(1, Math.floor(", - " availableUniformVectors / (uniformVectorsPerLight * kindCount)));", + " Math.max(uniformVectorsPerLight * kindCount,", + " availableUniformVectors - uniformVectorsForShadows)", + " / (uniformVectorsPerLight * kindCount)));", "", " return { shadowCount, mapCount, count };", "}", From 70f905e6ec01f1c981305883269c49f94140d147 Mon Sep 17 00:00:00 2001 From: Florian Rival Date: Sat, 22 Aug 2026 23:58:39 +0200 Subject: [PATCH 4/7] Update Light3D.json --- extensions/reviewed/Light3D.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/reviewed/Light3D.json b/extensions/reviewed/Light3D.json index cf5cb9ae9..13de5c0f0 100644 --- a/extensions/reviewed/Light3D.json +++ b/extensions/reviewed/Light3D.json @@ -10,7 +10,7 @@ "name": "Light3D", "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/f237ae4e3b857c556846c7b2c0b132556fd4bcdeff217034b4d9c97dc1aab1d6_lightbulb-on-outline.svg", "shortDescription": "A collection of light object for 3D.", - "version": "1.2.1", + "version": "1.0.3", "description": "A collection of light object for 3D.", "origin": { "identifier": "Light3D", From d0476f200c0728b5f94036dd248a057dead16bfd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 11:41:49 +0000 Subject: [PATCH 5/7] Light3D: share the light budget between the kinds a scene uses The device budget was split three ways unconditionally, one share per light kind the engine knows of, so a scene lit by point lights alone had three quarters of its texture units held back for kinds that never showed up. On a device reporting the common 16 fragment texture units that left 2 shadow casters where 4 were asked for and 4 used to be granted. The kind count now comes from the kinds a scene has actually used, so that same scene gets the whole budget for its one kind. Kinds are only ever added, never dropped, so the numbers three.js compiles into its shaders stay pinned: a light going away for a while must not widen the budget of the others. Measured with the real function, shadow casters per kind: device before after minimum guaranteed 1 3 desktop, 16 texture units 2 4 desktop, 32 texture units 4 4 Also correct the guaranteed varying minimum to 15: GLES3 guarantees MAX_VARYING_COMPONENTS 60, which is 15 vec4 and not 16. It changes no result (both leave one caster per kind at the minimum) but the fallback should say what the spec says. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011chSAxDN9jSEwWkowwzDBm --- extensions/reviewed/Light3D.json | 121 +++++++++++++++++++++---------- 1 file changed, 82 insertions(+), 39 deletions(-) diff --git a/extensions/reviewed/Light3D.json b/extensions/reviewed/Light3D.json index 13de5c0f0..8c3592468 100644 --- a/extensions/reviewed/Light3D.json +++ b/extensions/reviewed/Light3D.json @@ -10,7 +10,7 @@ "name": "Light3D", "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/f237ae4e3b857c556846c7b2c0b132556fd4bcdeff217034b4d9c97dc1aab1d6_lightbulb-on-outline.svg", "shortDescription": "A collection of light object for 3D.", - "version": "1.0.3", + "version": "1.0.4", "description": "A collection of light object for 3D.", "origin": { "identifier": "Light3D", @@ -685,21 +685,24 @@ " * texture, and spends a varying on every shadow casting light. Going over what", " * the driver allows does not make the scene slower - the shader fails to link", " * and everything using that material renders black. Only 16 fragment texture", - " * units and 16 varyings are guaranteed across devices, and materials need most", + " * units and 15 varyings are guaranteed across devices, and materials need most", " * of those, so the budget is read from the device instead of being fixed.", " *", - " * It is computed once, and applied per light kind, so the numbers three.js", - " * compiles into its shaders still never move from frame to frame.", + " * The budget is shared between the light kinds a scene uses, so it is given", + " * how many of them there are rather than assuming all of them: a scene lit by", + " * point lights alone would otherwise have three quarters of its texture units", + " * held back for kinds that never show up.", " *", " * @param {THREE.WebGLRenderer | null} threeRenderer", + " * @param {number} kindCount How many light kinds the scene uses, at least one.", " * @returns {{shadowCount: number, mapCount: number, count: number}}", " */", - "function computeDeviceLightBudget(threeRenderer) {", + "function computeDeviceLightBudget(threeRenderer, kindCount) {", " // When the renderer cannot be reached, assume the guaranteed minimums", " // rather than the device this happens to be running on.", " const capabilities = (threeRenderer && threeRenderer.capabilities) || {};", " const maxTextures = capabilities.maxTextures || 16;", - " const maxVaryings = capabilities.maxVaryings || 16;", + " const maxVaryings = capabilities.maxVaryings || 15;", " const maxFragmentUniforms = capabilities.maxFragmentUniforms || 224;", "", " // Left for the material itself: colour, normal, roughness, metalness,", @@ -712,14 +715,11 @@ " const uniformVectorsForMaterials = 64;", " // Measured on a MeshStandardMaterial: a point light costs 4 uniform", " // vectors, a spot light 7, and casting a shadow adds around 11 more on", - " // top of either. Take the dearest of them, since which kinds a scene", - " // uses is not known here.", + " // top of either. Take the dearest of them, since a kind count says how", + " // many kinds a scene uses but not which ones.", " const uniformVectorsPerLight = 8;", " const uniformVectorsPerShadow = 12;", "", - " // Point lights, spot lights and spot lights with a projected texture.", - " const kindCount = 3;", - "", " const availableTextureUnits = Math.max(", " kindCount, maxTextures - textureUnitsForMaterials);", " const availableVaryings = Math.max(", @@ -751,17 +751,18 @@ "", "/**", " * A capacity that never goes past what the device can take, however high the", - " * game sets its own maximum. Read through a getter so that changing the", - " * game's maximum at runtime still takes effect.", + " * game sets its own maximum. Both sides are read through getters, so that", + " * changing the game's maximum at runtime still takes effect, and so that a", + " * device budget recomputed for more light kinds is picked up as well.", " *", " * @param {{value: number}} requested", - " * @param {number} deviceMax", + " * @param {() => number} getDeviceMax", " * @returns {{value: number}}", " */", - "function makeDeviceCappedCapacity(requested, deviceMax) {", + "function makeDeviceCappedCapacity(requested, getDeviceMax) {", " return {", " get value() {", - " return Math.min(requested.value, deviceMax);", + " return Math.min(requested.value, getDeviceMax());", " }", " };", "}", @@ -777,25 +778,14 @@ " .getGame()", " .getRenderer()", " .getThreeRenderer();", - " const budget = computeDeviceLightBudget(threeRenderer);", " const requestedCount = isInGameEdition", " ? editorLightCountMax : lightCountMax;", " const requestedShadowCount = isInGameEdition", " ? editorLightShadowCountMax : lightShadowCountMax;", "", - " if (budget.shadowCount < requestedShadowCount.value", - " || budget.count < requestedCount.value) {", - " logger.info(", - " 'This device limits 3D lights to ' + budget.count", - " + ' per kind (' + budget.shadowCount", - " + ' casting a shadow, ' + budget.mapCount", - " + ' projecting a texture). Going over what it reports would '", - " + 'stop the shaders from linking.'", - " );", - " }", - "", " runtimeScene.__lightManager = new LightManager(", - " requestedCount, requestedShadowCount, budget);", + " requestedCount, requestedShadowCount,", + " kindCount => computeDeviceLightBudget(threeRenderer, kindCount));", " }", " return runtimeScene.__lightManager;", "}", @@ -902,31 +892,83 @@ " maxCountByKind;", " /** @type {Map} Shadow caster capacity, by light kind. */", " shadowCountByKind;", + " /** @type {Set} The light kinds this scene has actually used. */", + " usedKinds = new Set();", + " /** @type {{shadowCount: number, mapCount: number, count: number}} */", + " deviceBudget;", + " /** @type {(kindCount: number) => {shadowCount: number, mapCount: number, count: number}} */", + " _computeDeviceBudget;", + " /** @type {{value: number}} */", + " _requestedCount;", + " /** @type {{value: number}} */", + " _requestedShadowCount;", "", " /**", " * @param maxCount {{value: number}} The maximum number of lit lights, for", " * each light kind, as the game asked for it.", " * @param shadowCount {{value: number}} The maximum number of shadow", " * casting lights, for each light kind, as the game asked for it.", - " * @param deviceBudget {{shadowCount: number, mapCount: number, count: number}}", - " * What the device can actually take, from `computeDeviceLightBudget`.", + " * @param computeDeviceBudget {(kindCount: number) => {shadowCount: number, mapCount: number, count: number}}", + " * What the device can take, for a given number of light kinds.", " */", - " constructor(maxCount, shadowCount, deviceBudget) {", + " constructor(maxCount, shadowCount, computeDeviceBudget) {", + " this._computeDeviceBudget = computeDeviceBudget;", + " this._requestedCount = maxCount;", + " this._requestedShadowCount = shadowCount;", + " // A scene that uses lights at all uses at least one kind of them.", + " this.deviceBudget = computeDeviceBudget(1);", + "", " // Spot lights that project a texture already spend a texture unit on", " // that texture, so they get the tighter of the two budgets.", " this.maxCountByKind = new Map([", - " ['point', makeDeviceCappedCapacity(maxCount, deviceBudget.count)],", - " ['spot', makeDeviceCappedCapacity(maxCount, deviceBudget.count)],", - " ['spotWithMap', makeDeviceCappedCapacity(", - " maxCount, Math.min(deviceBudget.count, deviceBudget.mapCount))],", + " ['point', makeDeviceCappedCapacity(", + " maxCount, () => this.deviceBudget.count)],", + " ['spot', makeDeviceCappedCapacity(", + " maxCount, () => this.deviceBudget.count)],", + " ['spotWithMap', makeDeviceCappedCapacity(maxCount, () => Math.min(", + " this.deviceBudget.count, this.deviceBudget.mapCount))],", " ]);", " this.shadowCountByKind = new Map([", - " ['point', makeDeviceCappedCapacity(shadowCount, deviceBudget.shadowCount)],", - " ['spot', makeDeviceCappedCapacity(shadowCount, deviceBudget.shadowCount)],", - " ['spotWithMap', makeDeviceCappedCapacity(shadowCount, deviceBudget.shadowCount)],", + " ['point', makeDeviceCappedCapacity(", + " shadowCount, () => this.deviceBudget.shadowCount)],", + " ['spot', makeDeviceCappedCapacity(", + " shadowCount, () => this.deviceBudget.shadowCount)],", + " ['spotWithMap', makeDeviceCappedCapacity(", + " shadowCount, () => this.deviceBudget.shadowCount)],", " ]);", " }", "", + " /**", + " * Take note that the scene uses lights of this kind, sharing the device", + " * budget between one more kind if it had not been seen before.", + " *", + " * A kind can only ever be added, never dropped: a light that goes away for", + " * a while must not give the others a wider budget, or the numbers three.js", + " * compiles into its shaders would move again.", + " *", + " * @param kind {string}", + " */", + " _useKind(kind) {", + " if (this.usedKinds.has(kind)) {", + " return;", + " }", + " this.usedKinds.add(kind);", + " this.deviceBudget = this._computeDeviceBudget(this.usedKinds.size);", + "", + " if (this.deviceBudget.count < this._requestedCount.value", + " || this.deviceBudget.shadowCount", + " < this._requestedShadowCount.value) {", + " logger.info(", + " 'This device limits 3D lights to ' + this.deviceBudget.count", + " + ' per kind (' + this.deviceBudget.shadowCount", + " + ' casting a shadow, ' + this.deviceBudget.mapCount", + " + ' projecting a texture), now that ' + this.usedKinds.size", + " + ' kind(s) of light share its budget. Going over what it '", + " + 'reports would stop the shaders from linking.'", + " );", + " }", + " }", + "", " clear() {", " for (const list of this.visibleLists.values()) {", " list.clear();", @@ -978,6 +1020,7 @@ " applyVisibilityAndShadow(object, distance) {", " const light = object.__light3D;", " const kind = LightManager.getLightKind(light);", + " this._useKind(kind);", "", " if (object._getIsCastingShadow()) {", " LightManager._getListForKind(", From 6739678d4fd8e20b4c49a456fc58bffbefa45907 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 11:46:41 +0000 Subject: [PATCH 6/7] Light3D: keep the version at 1.0.3 The budget change is a fix to what 1.0.3 already does, not a new feature series, so it stays on that version rather than opening a 1.0.4. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011chSAxDN9jSEwWkowwzDBm --- extensions/reviewed/Light3D.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/reviewed/Light3D.json b/extensions/reviewed/Light3D.json index 8c3592468..aa5b72be6 100644 --- a/extensions/reviewed/Light3D.json +++ b/extensions/reviewed/Light3D.json @@ -10,7 +10,7 @@ "name": "Light3D", "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/f237ae4e3b857c556846c7b2c0b132556fd4bcdeff217034b4d9c97dc1aab1d6_lightbulb-on-outline.svg", "shortDescription": "A collection of light object for 3D.", - "version": "1.0.4", + "version": "1.0.3", "description": "A collection of light object for 3D.", "origin": { "identifier": "Light3D", From 1a84312cd77ea6daf38ea9dddc7094795c07320f Mon Sep 17 00:00:00 2001 From: Florian Rival Date: Sun, 23 Aug 2026 17:27:51 +0200 Subject: [PATCH 7/7] Fixes for new Light3D cap updates (#2238) - Evict lights that no longer fit the moment the device budget is shared with a new light kind, so a frame never renders over the budget. - Read the renderer on every budget recomputation instead of capturing it once, so a scene created before the renderer exists is not stuck on the guaranteed minimums. - Say in the max lights count descriptions that the caps are per light kind and that devices can enforce a lower limit. - Drop a stale mention of distance inflation for ties in insertByDistance. --- extensions/reviewed/Light3D.json | 33 +++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/extensions/reviewed/Light3D.json b/extensions/reviewed/Light3D.json index aa5b72be6..e72ed1c35 100644 --- a/extensions/reviewed/Light3D.json +++ b/extensions/reviewed/Light3D.json @@ -774,18 +774,18 @@ "function getLightManager(runtimeScene) {", " if (!runtimeScene.__lightManager) {", " // Create the shared manager if necessary.", - " const threeRenderer = runtimeScene", - " .getGame()", - " .getRenderer()", - " .getThreeRenderer();", " const requestedCount = isInGameEdition", " ? editorLightCountMax : lightCountMax;", " const requestedShadowCount = isInGameEdition", " ? editorLightShadowCountMax : lightShadowCountMax;", "", + " // The renderer is read anew on every recomputation: it may not", + " // exist yet when the manager is created on the first frame.", " runtimeScene.__lightManager = new LightManager(", " requestedCount, requestedShadowCount,", - " kindCount => computeDeviceLightBudget(threeRenderer, kindCount));", + " kindCount => computeDeviceLightBudget(", + " runtimeScene.getGame().getRenderer().getThreeRenderer(),", + " kindCount));", " }", " return runtimeScene.__lightManager;", "}", @@ -825,10 +825,16 @@ " this.objects.length = 0;", " }", "", + " /** Drop the farthest lights until the list fits its capacity again. */", + " trimToCapacity() {", + " while (this.objects.length > this.capacity.value) {", + " this.onDeletion(this.objects.pop().object);", + " }", + " }", + "", " /**", " * @param object {LightRuntimeObject}", - " * @param sortDistance {number} The squared distance to the camera, which", - " * more expensive lights can inflate to lose ties against cheaper ones.", + " * @param sortDistance {number} The squared distance to the camera.", " */", " insertByDistance(object, sortDistance) {", " let insertionIndex = 0;", @@ -955,6 +961,15 @@ " this.usedKinds.add(kind);", " this.deviceBudget = this._computeDeviceBudget(this.usedKinds.size);", "", + " // The budget just shrank: evict the lights that no longer fit, so", + " // that even this frame stays within what the device can take.", + " for (const list of this.visibleLists.values()) {", + " list.trimToCapacity();", + " }", + " for (const list of this.shadowLists.values()) {", + " list.trimToCapacity();", + " }", + "", " if (this.deviceBudget.count < this._requestedCount.value", " || this.deviceBudget.shadowCount", " < this._requestedShadowCount.value) {", @@ -1051,7 +1066,7 @@ "objectGroups": [] }, { - "description": "the maximum number of nearest lights displayed simultaneously.", + "description": "the maximum number of nearest lights displayed simultaneously, counted separately for each kind of light (point lights, spot lights, spot lights projecting a texture). Devices that can't take that many lights enforce a lower limit.", "fullName": "Max lights count", "functionType": "ExpressionAndCondition", "name": "LightCountMax", @@ -1124,7 +1139,7 @@ "objectGroups": [] }, { - "description": "the maximum number of nearest lights displayed with shadow simultaneously.", + "description": "the maximum number of nearest lights displayed with shadow simultaneously, counted separately for each kind of light (point lights, spot lights, spot lights projecting a texture). Devices that can't take that many shadows enforce a lower limit.", "fullName": "Max lights with shadow count", "functionType": "ExpressionAndCondition", "name": "LightShadowCountMax",