Files
GDevelop/Extensions/3D/Model3DRuntimeObject3DRenderer.ts
Neyl dff99b79cb Add basic support for shadows (#7592)
* Shadows are rendered for 3D objects when a **Directional Light** is set up on your scene layer - which is now the case by default for new games and new layers: they will have both a Directional Light and an Ambient Light. This renders shadows like it could be done by the sun.
* 3D models and 3D cubes are now casting shadows. To see them, you must ensure you the "Standard" material type in their configuration (and not the "Basic", which means they don't react to light) and be sure to enable "Shadow casting" and "Shadow receiving". This is done by default for new objects you create or import from the Asset Store.
* Shadows are rendered around the camera on an area that is large enough for most games while giving still good quality results. This means they should work out of the box and be adapted to most games, including large maps. 
  You can adapt the quality of shadows, intensity of the light (and so shadows), the size of the rendered area by editing the Effects of the layer in the scene (as for other effects).
2025-07-02 15:49:26 +02:00

434 lines
13 KiB
TypeScript

namespace gdjs {
type FloatPoint3D = [float, float, float];
const epsilon = 1 / (1 << 16);
const removeMetalness = (material: THREE.Material): void => {
//@ts-ignore
if (material.metalness) {
//@ts-ignore
material.metalness = 0;
}
};
const removeMetalnessFromMesh = (node: THREE.Object3D) => {
const mesh = node as THREE.Mesh;
if (!mesh.material) {
return;
}
if (Array.isArray(mesh.material)) {
for (let index = 0; index < mesh.material.length; index++) {
removeMetalness(mesh.material[index]);
}
} else {
removeMetalness(mesh.material);
}
};
const traverseToRemoveMetalnessFromMeshes = (node: THREE.Object3D) =>
node.traverse(removeMetalnessFromMesh);
const convertToBasicMaterial = (
material: THREE.Material
): THREE.MeshBasicMaterial => {
const basicMaterial = new THREE.MeshBasicMaterial();
//@ts-ignore
if (material.color) {
//@ts-ignore
basicMaterial.color = material.color;
}
//@ts-ignore
if (material.map) {
//@ts-ignore
basicMaterial.map = material.map;
}
return basicMaterial;
};
const setBasicMaterialTo = (node: THREE.Object3D): void => {
const mesh = node as THREE.Mesh;
if (!mesh.material) {
return;
}
if (Array.isArray(mesh.material)) {
for (let index = 0; index < mesh.material.length; index++) {
mesh.material[index] = convertToBasicMaterial(mesh.material[index]);
}
} else {
mesh.material = convertToBasicMaterial(mesh.material);
}
};
const traverseToSetBasicMaterialFromMeshes = (node: THREE.Object3D) =>
node.traverse(setBasicMaterialTo);
class Model3DRuntimeObject3DRenderer extends gdjs.RuntimeObject3DRenderer {
private _model3DRuntimeObject: gdjs.Model3DRuntimeObject;
/**
* The 3D model stretched in a 1x1x1 cube.
*/
private _threeObject: THREE.Object3D;
private _originalModel: THREE_ADDONS.GLTF;
private _animationMixer: THREE.AnimationMixer;
private _action: THREE.AnimationAction | null;
/**
* The model origin evaluated according to the object configuration.
*
* Coordinates are between 0 and 1.
*/
private _modelOriginPoint: FloatPoint3D;
constructor(
runtimeObject: gdjs.Model3DRuntimeObject,
instanceContainer: gdjs.RuntimeInstanceContainer
) {
// GLB files with skeleton must not have any transformation to work properly.
const originalModel = instanceContainer
.getGame()
.getModel3DManager()
.getModel(runtimeObject._modelResourceName);
// _updateModel will actually add a clone of the model.
const model = new THREE.Group();
// Create a group to transform the object according to
// position, angle and dimensions.
const group = new THREE.Group();
group.rotation.order = 'ZYX';
group.add(model);
super(runtimeObject, instanceContainer, group);
this._model3DRuntimeObject = runtimeObject;
this._threeObject = model;
this._originalModel = originalModel;
this._modelOriginPoint = [0, 0, 0];
this.updateSize();
this.updatePosition();
this.updateRotation();
this._animationMixer = new THREE.AnimationMixer(model);
this._action = null;
}
updateAnimation(timeDelta: float) {
this._animationMixer.update(timeDelta);
}
updatePosition() {
const originPoint = this.getOriginPoint();
const centerPoint = this.getCenterPoint();
this.get3DRendererObject().position.set(
this._object.getX() -
this._object.getWidth() * (originPoint[0] - centerPoint[0]),
this._object.getY() -
this._object.getHeight() * (originPoint[1] - centerPoint[1]),
this._object.getZ() -
this._object.getDepth() * (originPoint[2] - centerPoint[2])
);
}
getOriginPoint() {
return this._model3DRuntimeObject._originPoint || this._modelOriginPoint;
}
getCenterPoint() {
return this._model3DRuntimeObject._centerPoint || this._modelOriginPoint;
}
private _updateDefaultTransformation(
threeObject: THREE.Object3D,
rotationX: float,
rotationY: float,
rotationZ: float,
originalWidth: float,
originalHeight: float,
originalDepth: float,
keepAspectRatio: boolean
) {
// These formulas are also used in:
// - Model3DEditor.modelSize
// - Model3DRendered2DInstance
threeObject.rotation.set(
gdjs.toRad(rotationX),
gdjs.toRad(rotationY),
gdjs.toRad(rotationZ)
);
threeObject.updateMatrixWorld(true);
const boundingBox = new THREE.Box3().setFromObject(threeObject);
const shouldKeepModelOrigin = !this._model3DRuntimeObject._originPoint;
if (shouldKeepModelOrigin) {
// Keep the origin as part of the model.
// For instance, a model can be 1 face of a cube and we want to keep the
// inside as part of the object even if it's just void.
// It also avoids to have the origin outside of the object box.
boundingBox.expandByPoint(new THREE.Vector3(0, 0, 0));
}
const modelWidth = boundingBox.max.x - boundingBox.min.x;
const modelHeight = boundingBox.max.y - boundingBox.min.y;
const modelDepth = boundingBox.max.z - boundingBox.min.z;
this._modelOriginPoint[0] =
modelWidth < epsilon ? 0 : -boundingBox.min.x / modelWidth;
this._modelOriginPoint[1] =
modelHeight < epsilon ? 0 : -boundingBox.min.y / modelHeight;
this._modelOriginPoint[2] =
modelDepth < epsilon ? 0 : -boundingBox.min.z / modelDepth;
// The model is flipped on Y axis.
this._modelOriginPoint[1] = 1 - this._modelOriginPoint[1];
// Center the model.
const centerPoint = this._model3DRuntimeObject._centerPoint;
if (centerPoint) {
threeObject.position.set(
-(boundingBox.min.x + modelWidth * centerPoint[0]),
// The model is flipped on Y axis.
-(boundingBox.min.y + modelHeight * (1 - centerPoint[1])),
-(boundingBox.min.z + modelDepth * centerPoint[2])
);
}
// Rotate the model.
threeObject.scale.set(1, 1, 1);
threeObject.rotation.set(
gdjs.toRad(rotationX),
gdjs.toRad(rotationY),
gdjs.toRad(rotationZ)
);
// Stretch the model in a 1x1x1 cube.
const scaleX = modelWidth < epsilon ? 1 : 1 / modelWidth;
const scaleY = modelHeight < epsilon ? 1 : 1 / modelHeight;
const scaleZ = modelDepth < epsilon ? 1 : 1 / modelDepth;
const scaleMatrix = new THREE.Matrix4();
// Flip on Y because the Y axis is on the opposite side of direct basis.
// It avoids models to be like a mirror refection.
scaleMatrix.makeScale(scaleX, -scaleY, scaleZ);
threeObject.updateMatrix();
threeObject.applyMatrix4(scaleMatrix);
if (keepAspectRatio) {
// Reduce the object dimensions to keep aspect ratio.
const widthRatio =
modelWidth < epsilon
? Number.POSITIVE_INFINITY
: originalWidth / modelWidth;
const heightRatio =
modelHeight < epsilon
? Number.POSITIVE_INFINITY
: originalHeight / modelHeight;
const depthRatio =
modelDepth < epsilon
? Number.POSITIVE_INFINITY
: originalDepth / modelDepth;
let scaleRatio = Math.min(widthRatio, heightRatio, depthRatio);
if (!Number.isFinite(scaleRatio)) {
scaleRatio = 1;
}
this._object._setOriginalWidth(scaleRatio * modelWidth);
this._object._setOriginalHeight(scaleRatio * modelHeight);
this._object._setOriginalDepth(scaleRatio * modelDepth);
}
}
/**
* `_updateModel` should always be called after this method.
* Ideally, use `Model3DRuntimeObject#_reloadModel` instead.
*/
_reloadModel(
runtimeObject: Model3DRuntimeObject,
instanceContainer: gdjs.RuntimeInstanceContainer
) {
this._originalModel = instanceContainer
.getGame()
.getModel3DManager()
.getModel(runtimeObject._modelResourceName);
}
_updateModel(
rotationX: float,
rotationY: float,
rotationZ: float,
originalWidth: float,
originalHeight: float,
originalDepth: float,
keepAspectRatio: boolean
) {
// Start from the original model because:
// - _replaceMaterials is destructive
// - _updateDefaultTransformation may need to work with meshes in local space
// This group hold the rotation defined by properties.
const threeObject = new THREE.Group();
threeObject.rotation.order = 'ZYX';
const root = THREE_ADDONS.SkeletonUtils.clone(this._originalModel.scene);
threeObject.add(root);
this._replaceMaterials(threeObject);
this._updateDefaultTransformation(
threeObject,
rotationX,
rotationY,
rotationZ,
originalWidth,
originalHeight,
originalDepth,
keepAspectRatio
);
// Replace the 3D object.
this.get3DRendererObject().remove(this._threeObject);
this.get3DRendererObject().add(threeObject);
this._threeObject = threeObject;
this._updateShadow();
// Start the current animation on the new 3D object.
this._animationMixer = new THREE.AnimationMixer(root);
const isAnimationPaused = this._model3DRuntimeObject.isAnimationPaused();
this._model3DRuntimeObject.setAnimationIndex(
this._model3DRuntimeObject.getAnimationIndex()
);
if (isAnimationPaused) {
this.pauseAnimation();
}
}
/**
* Replace materials to better work with lights (or no light).
*/
private _replaceMaterials(threeObject: THREE.Object3D) {
if (
this._model3DRuntimeObject._materialType ===
gdjs.Model3DRuntimeObject.MaterialType.StandardWithoutMetalness
) {
traverseToRemoveMetalnessFromMeshes(threeObject);
} else if (
this._model3DRuntimeObject._materialType ===
gdjs.Model3DRuntimeObject.MaterialType.Basic
) {
traverseToSetBasicMaterialFromMeshes(threeObject);
}
}
getAnimationCount() {
return this._originalModel.animations.length;
}
getAnimationName(animationIndex: integer) {
return this._originalModel.animations[animationIndex].name;
}
_updateShadow() {
this._threeObject.traverse((child) => {
child.castShadow = this._model3DRuntimeObject._isCastingShadow;
child.receiveShadow = this._model3DRuntimeObject._isReceivingShadow;
});
}
/**
* Return true if animation has ended.
* The animation had ended if:
* - it's not configured as a loop;
* - the current frame is the last frame;
* - the last frame has been displayed long enough.
*/
hasAnimationEnded(): boolean {
if (!this._action) {
return true;
}
return !this._action.isRunning();
}
animationPaused() {
if (!this._action) {
return;
}
return this._action.paused;
}
pauseAnimation() {
if (!this._action) {
return;
}
this._action.paused = true;
}
resumeAnimation() {
if (!this._action) {
return;
}
this._action.paused = false;
}
playAnimation(animationName: string, shouldLoop: boolean) {
const clip = THREE.AnimationClip.findByName(
this._originalModel.animations,
animationName
);
if (!clip) {
console.error(
`The GLB file: ${this._model3DRuntimeObject._modelResourceName} doesn't have any animation named: ${animationName}`
);
return;
}
const previousAction = this._action;
this._action = this._animationMixer.clipAction(clip);
// Reset the animation and play it from the start.
// `clipAction` always gives back the same action for a given animation
// and its likely to be in a finished or at least started state.
this._action.reset();
this._action.setLoop(
shouldLoop ? THREE.LoopRepeat : THREE.LoopOnce,
Number.POSITIVE_INFINITY
);
this._action.clampWhenFinished = true;
this._action.timeScale =
this._model3DRuntimeObject.getAnimationSpeedScale();
if (previousAction && previousAction !== this._action) {
this._action.crossFadeFrom(
previousAction,
this._model3DRuntimeObject._crossfadeDuration,
false
);
}
this._action.play();
// Make sure the first frame is displayed.
this._animationMixer.update(0);
}
getAnimationElapsedTime(): float {
return this._action ? this._action.time : 0;
}
setAnimationElapsedTime(time: float): void {
if (this._action) {
this._action.time = time;
}
}
setAnimationTimeScale(timeScale: float): void {
if (this._action) {
this._action.timeScale = timeScale;
}
}
getAnimationDuration(animationName: string): float {
const clip = THREE.AnimationClip.findByName(
this._originalModel.animations,
animationName
);
return clip ? clip.duration : 0;
}
}
export const Model3DRuntimeObjectRenderer = Model3DRuntimeObject3DRenderer;
export type Model3DRuntimeObjectRenderer = Model3DRuntimeObject3DRenderer;
}