Stabilize directional light shadows and add shadow intensity and softness

- Snap the shadow camera to the shadow map texels instead of 100 pixel steps,
  so shadow edges no longer shimmer when the camera moves.
- Use an up vector orthogonal to the light for the "Y-" top, as a vertical
  light made the shadow camera orientation unstable.
- Apply the shadow frustum size and distance from camera when they are
  changed after the first frame.
- Add "Shadow intensity" and "Shadow softness" properties (Three.js
  `shadow.intensity` and `shadow.radius`), with defaults keeping the
  previous rendering.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01R21PyxoRnR5h7u9NQX66ZG
This commit is contained in:
Claude
2026-10-05 15:43:43 +00:00
parent b30074ab24
commit 3a6452c755
4 changed files with 266 additions and 31 deletions
+94 -31
View File
@@ -25,6 +25,13 @@ namespace gdjs {
private _minimumShadowBias: float = 0;
private _distanceFromCamera: float = 1500;
private _frustumSize: float = 4000;
private _shadowCenter = new THREE.Vector3();
private _lightOffset = new THREE.Vector3();
private _origin = new THREE.Vector3();
private _shadowCameraRotation = new THREE.Matrix4();
private _shadowCameraRight = new THREE.Vector3();
private _shadowCameraUp = new THREE.Vector3();
private _shadowCameraForward = new THREE.Vector3();
private _isEnabled: boolean = false;
private _light: THREE.DirectionalLight;
@@ -158,50 +165,92 @@ namespace gdjs {
return;
}
const layer = target.getRuntimeLayer();
const x = layer.getCameraX();
const y = layer.getCameraY();
const z = layer.getCameraZ(layer.getInitialCamera3DFieldOfView());
// Contrary to cameras, lights are within the scene so they already
// take world scale into account.
const roundedX = Math.floor(x / 100) * 100;
const roundedY = Math.floor(y / 100) * 100;
const roundedZ = Math.floor(z / 100) * 100;
this._shadowCenter.set(
layer.getCameraX(),
layer.getCameraY(),
layer.getCameraZ(layer.getInitialCamera3DFieldOfView())
);
if (this._top === 'Y-') {
const posLightX =
roundedX +
this._lightOffset.set(
this._distanceFromCamera *
Math.cos(gdjs.toRad(-this._rotation + 90)) *
Math.cos(gdjs.toRad(this._elevation));
const posLightY =
roundedY -
this._distanceFromCamera *
Math.sin(gdjs.toRad(this._elevation));
const posLightZ =
roundedZ +
Math.cos(gdjs.toRad(this._elevation)),
-this._distanceFromCamera *
Math.sin(gdjs.toRad(this._elevation)),
this._distanceFromCamera *
Math.sin(gdjs.toRad(-this._rotation + 90)) *
Math.cos(gdjs.toRad(this._elevation));
this._light.position.set(posLightX, posLightY, posLightZ);
this._light.target.position.set(roundedX, roundedY, roundedZ);
Math.cos(gdjs.toRad(this._elevation))
);
// The default up vector (Y+ in world coordinates) is parallel to
// the light when the elevation is 90°, which makes the shadow
// camera orientation unstable. This horizontal axis is always
// orthogonal to the light and gives the same shadow frustum,
// turned by 90°.
this._light.shadow.camera.up.set(
Math.cos(gdjs.toRad(this._rotation)),
0,
-Math.sin(gdjs.toRad(this._rotation))
);
} else {
const posLightX =
roundedX +
this._lightOffset.set(
this._distanceFromCamera *
Math.cos(gdjs.toRad(this._rotation)) *
Math.cos(gdjs.toRad(this._elevation));
const posLightY =
roundedY +
Math.cos(gdjs.toRad(this._elevation)),
this._distanceFromCamera *
Math.sin(gdjs.toRad(this._rotation)) *
Math.cos(gdjs.toRad(this._elevation));
const posLightZ =
roundedZ +
this._distanceFromCamera *
Math.sin(gdjs.toRad(this._elevation));
this._light.position.set(posLightX, posLightY, posLightZ);
this._light.target.position.set(roundedX, roundedY, roundedZ);
Math.cos(gdjs.toRad(this._elevation)),
this._distanceFromCamera * Math.sin(gdjs.toRad(this._elevation))
);
this._light.shadow.camera.up.copy(THREE.Object3D.DEFAULT_UP);
}
this._snapShadowCenterToShadowMapTexels();
this._light.target.position.copy(this._shadowCenter);
this._light.position
.copy(this._shadowCenter)
.add(this._lightOffset);
}
/**
* Move the shadow center by less than a texel so that the shadow map
* texels stay at the same place in the world when the camera moves.
* Otherwise, the edges of shadows flicker as they are rasterized
* differently on each frame.
*/
private _snapShadowCenterToShadowMapTexels(): void {
// The shadow camera axes are computed like Three.js does in
// `LightShadow.updateMatrices`, in world coordinates, where the Y
// axis is the opposite of the scene one (the scene is mirrored on Y).
this._shadowCameraForward.set(
this._lightOffset.x,
-this._lightOffset.y,
this._lightOffset.z
);
this._shadowCameraRotation.lookAt(
this._shadowCameraForward,
this._origin,
this._light.shadow.camera.up
);
this._shadowCameraRotation.extractBasis(
this._shadowCameraRight,
this._shadowCameraUp,
this._shadowCameraForward
);
this._shadowCameraRight.y = -this._shadowCameraRight.y;
this._shadowCameraUp.y = -this._shadowCameraUp.y;
const texelSize = this._frustumSize / this._shadowMapSize;
const right = this._shadowCenter.dot(this._shadowCameraRight);
const up = this._shadowCenter.dot(this._shadowCameraUp);
this._shadowCenter
.addScaledVector(
this._shadowCameraRight,
Math.round(right / texelSize) * texelSize - right
)
.addScaledVector(
this._shadowCameraUp,
Math.round(up / texelSize) * texelSize - up
);
}
updateDoubleParameter(parameterName: string, value: number): void {
if (parameterName === 'intensity') {
@@ -212,10 +261,20 @@ namespace gdjs {
this._rotation = value;
} else if (parameterName === 'distanceFromCamera') {
this._distanceFromCamera = value;
this._shadowCameraDirty = true;
} else if (parameterName === 'frustumSize') {
this._frustumSize = value;
this._shadowCameraDirty = true;
} else if (parameterName === 'minimumShadowBias') {
this._minimumShadowBias = value;
} else if (parameterName === 'shadowIntensity') {
this._light.shadow.intensity = gdjs.evtTools.common.clamp(
value,
0,
1
);
} else if (parameterName === 'shadowSoftness') {
this._light.shadow.radius = Math.max(0, value);
}
}
getDoubleParameter(parameterName: string): number {
@@ -231,6 +290,10 @@ namespace gdjs {
return this._frustumSize;
} else if (parameterName === 'minimumShadowBias') {
return this._minimumShadowBias;
} else if (parameterName === 'shadowIntensity') {
return this._light.shadow.intensity;
} else if (parameterName === 'shadowSoftness') {
return this._light.shadow.radius;
}
return 0;
}
+22
View File
@@ -2298,6 +2298,28 @@ module.exports = {
.setLabel(_('Shadow quality'))
.setType('choice')
.setGroup(_('Shadows'));
properties
.getOrCreate('shadowIntensity')
.setValue('1')
.setLabel(_('Shadow intensity'))
.setDescription(
_(
'How dark the shadows are, from 0 (no shadow) to 1 (only lit by the other lights).'
)
)
.setType('number')
.setGroup(_('Shadows'));
properties
.getOrCreate('shadowSoftness')
.setValue('1')
.setLabel(_('Shadow softness'))
.setDescription(
_(
'How blurry the edges of shadows are, in texels of the shadow map. It has no impact on performance.'
)
)
.setType('number')
.setGroup(_('Shadows'));
properties
.getOrCreate('minimumShadowBias')
.setValue('0')
@@ -0,0 +1,149 @@
// @ts-check
describe('Scene3D::DirectionalLight', () => {
/**
* @param {'Z+' | 'Y-'} top
* @returns {EffectData}
*/
const makeDirectionalLightEffectData = (top) => ({
name: 'Sun',
effectType: 'Scene3D::DirectionalLight',
stringParameters: { top, shadowQuality: 'medium' },
booleanParameters: { isCastingShadow: true },
doubleParameters: {
// A vertical light is a special case for the "Y-" top.
elevation: top === 'Y-' ? 90 : 37,
rotation: 23,
intensity: 1,
frustumSize: 4000,
distanceFromCamera: 1500,
minimumShadowBias: 0,
shadowIntensity: 0.6,
shadowSoftness: 2.5,
},
});
/**
* @param {EffectData} effectData
* @returns {gdjs.RuntimeLayer}
*/
const make3DLayerWithEffect = (effectData) => {
const runtimeGame = gdjs.getPixiRuntimeGame();
runtimeGame
.getRenderer()
.createStandardCanvas(document.createElement('div'));
const runtimeScene = new gdjs.RuntimeScene(runtimeGame);
runtimeScene.addLayer({
name: '',
renderingType: '3d',
visibility: true,
effects: [effectData],
cameras: [],
ambientLightColorR: 0,
ambientLightColorG: 0,
ambientLightColorB: 0,
isLightingLayer: false,
followBaseLayerCamera: true,
});
return runtimeScene.getLayer('');
};
/**
* @param {gdjs.RuntimeLayer} layer
*/
const renderLayer = (layer) => {
const runtimeScene = layer.getRuntimeScene();
runtimeScene.render();
// The scene renderer only renders layers having 3D objects with Three.js.
const threeRenderer = runtimeScene
.getGame()
.getRenderer()
.getThreeRenderer();
const threeScene = layer.getRenderer().getThreeScene();
const threeCamera = layer.getRenderer().getThreeCamera();
if (!threeRenderer || !threeScene || !threeCamera) {
throw new Error('The layer is not rendered with Three.js.');
}
threeRenderer.render(threeScene, threeCamera);
};
/**
* @param {gdjs.RuntimeLayer} layer
* @returns {THREE.DirectionalLight}
*/
const getDirectionalLight = (layer) => {
const threeScene = layer.getRenderer().getThreeScene();
const light =
threeScene &&
threeScene.children.find(
(child) => child instanceof THREE.DirectionalLight
);
if (!(light instanceof THREE.DirectionalLight)) {
throw new Error('The directional light is not in the scene.');
}
return light;
};
/**
* @param {THREE.DirectionalLight} light
* @returns {[float, float]} The position of a fixed point in the shadow map,
* inside the texel where it falls.
*/
const getPositionInShadowMapTexel = (light) => {
const position = new THREE.Vector4(0.1, -0.2, 0.03, 1).applyMatrix4(
light.shadow.matrix
);
const x = position.x * light.shadow.mapSize.x;
const y = position.y * light.shadow.mapSize.y;
return [x - Math.floor(x), y - Math.floor(y)];
};
for (const top of /** @type {Array<'Z+' | 'Y-'>} */ (['Z+', 'Y-'])) {
it(`keeps shadow map texels in place when the camera moves (top: ${top})`, () => {
const layer = make3DLayerWithEffect(makeDirectionalLightEffectData(top));
const light = getDirectionalLight(layer);
const shadowCaster = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial()
);
shadowCaster.castShadow = true;
layer.getRenderer().getThreeScene()?.add(shadowCaster);
renderLayer(layer);
const [initialX, initialY] = getPositionInShadowMapTexel(light);
for (let i = 1; i < 6; i++) {
layer.setCameraX(123.4 + i * 37.3, 0);
layer.setCameraY(-55.1 + i * 13.7, 0);
renderLayer(layer);
const [x, y] = getPositionInShadowMapTexel(light);
expect(Math.abs(x - initialX)).to.be.below(0.001);
expect(Math.abs(y - initialY)).to.be.below(0.001);
}
});
}
it('applies the shadow intensity and softness', () => {
const layer = make3DLayerWithEffect(makeDirectionalLightEffectData('Z+'));
const light = getDirectionalLight(layer);
expect(light.shadow.intensity).to.be(0.6);
expect(light.shadow.radius).to.be(2.5);
layer.setEffectDoubleParameter('Sun', 'shadowIntensity', 2);
layer.setEffectDoubleParameter('Sun', 'shadowSoftness', 4);
expect(light.shadow.intensity).to.be(1);
expect(light.shadow.radius).to.be(4);
});
it('applies a shadow frustum size changed after the first rendering', () => {
const layer = make3DLayerWithEffect(makeDirectionalLightEffectData('Z+'));
const light = getDirectionalLight(layer);
renderLayer(layer);
// The world scale is 100.
expect(light.shadow.camera.right).to.be(4000 / 2 / 100);
layer.setEffectDoubleParameter('Sun', 'frustumSize', 1000);
renderLayer(layer);
expect(light.shadow.camera.right).to.be(1000 / 2 / 100);
});
});
+1
View File
@@ -182,6 +182,7 @@ module.exports = function (config) {
'./newIDE/app/resources/GDJS/Runtime/Extensions/3D/Cube3DRuntimeObjectPixiRenderer.js',
'./newIDE/app/resources/GDJS/Runtime/Extensions/3D/CustomRuntimeObject3D.js',
'./newIDE/app/resources/GDJS/Runtime/Extensions/3D/CustomRuntimeObject3DRenderer.js',
'./newIDE/app/resources/GDJS/Runtime/Extensions/3D/DirectionalLight.js',
{
pattern:
'./newIDE/app/resources/GDJS/Runtime/Extensions/Physics3DBehavior/jolt-physics.wasm.js',