mirror of
https://github.com/4ian/GDevelop.git
synced 2026-10-12 02:01:17 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user