Files
GDevelop-Heretek/Extensions/3D/A_RuntimeObject3D.ts
T
John Smith 08a49e2f55 fix: remediate audit of studio pipeline, 3D culling, and instancing
16-reviewer audit of ae815f489 (103 findings) remediated in six groups.

1. Lazy THREE init (runtimeobject.ts): scratch Box3/Matrix4/Euler allocated on
   first use, so a 2D-only export (never loads three.js) boots.

2. Culling correctness: computeAABB uses |size|/2 half-extents and places the box
   at the true rotation pivot C' = P + R*(C-P); custom-object children union
   conservative boxes; the frustum is rebuilt in the boxes' space (right-multiply
   _threeGroup.matrixWorld) with a numeric cache incl. camera-Z; hide/cull route
   through setCullingVisible/applyRenderVisibility (no detached-carrier writes);
   custom-object children and nested tile maps no longer cull with a wrong window.

3. Studio pipeline wedge (Utils.js + Studio/*): lock release over all calls,
   working marks only editor calls, spawn failures reach the model, plan merge on
   objects (JSON.parse bug), one persistent LoopGuard with real call_id +
   re-arming + recordSuccess, RequestWriteGate self-retry + supersede-rejects,
   UseStudioRuntime fire-and-forget gate write + recomputed plan + sent check,
   FinalizeSubAgents terminal-'error' + executed-but-unsent + turn-cap + enriched
   report, send-returns-boolean, BYOK cache write-through, suspension cascade,
   turn-cap dispatch, stand-alone cannot spawn.

4. Instancing: useInstancing now serializes (C++); the pool grows without null.add
   and collapses deleted slots; the instance matrix is normalized
   (carrier * correction) and rewritten on rotation/hide; animated/skinned/morph/
   multi-mesh models rejected; per-slot shadows; layer moves re-acquire.

5. BYOK role enforcement: studioRoleId persisted and re-applied every turn (tools +
   prompt); dispatch filters tools outside the role; readOnly roles skip edit
   approval.

6. Warnings + Info: mutating-tool classification, hasOwnProperty role guards,
   cache-message merge, spawn arg caps, per-batch GDD note, fan-out cap, plan
   dependsOn-omission + caps + duplicate-id + fail-closed + trimmed ids, tilemap
   rotated/mirror bail + window margin + null-map guard, pool dispose wired to
   unloadScene, schema description required, plus doc/comment corrections.

Verified: engine builds clean; boot + computeAABB (13/13) + Model3DInstancePool
(9/9) harnesses pass; Jest green for src/AiGeneration + src/AI (175), CustomAIClient
(27, incl. the critical second-turn role regression), PlanStore (25),
FinalizeSubAgents (21), LoopGuard (18), RequestWriteGate (11), Roles (9).
2026-09-22 15:31:25 -04:00

661 lines
17 KiB
TypeScript

namespace gdjs {
const getValidDimensionValue = (value: float | undefined) =>
value === undefined ? 100 : value <= 0 ? 1 : value;
type Object3DNetworkSyncDataType = {
// z is position on the Z axis, different from zo, which is Z order
z: number;
d: number;
rx: number;
ry: number;
// no need for rz, as it is the angle from gdjs.RuntimeObject
flipX: boolean;
flipY: boolean;
flipZ: boolean;
};
/** @category Objects > 3D Objects */
export type Object3DNetworkSyncData = ObjectNetworkSyncData &
Object3DNetworkSyncDataType;
/**
* Base class for 3D objects.
* @category Objects > 3D Objects
*/
export abstract class RuntimeObject3D
extends gdjs.RuntimeObject
implements gdjs.AbstractRuntimeObject3D
{
/**
* Position on the Z axis.
*/
private _z: float = 0;
/**
* `_width` takes this value when the scale equals 1.
*
* It can't be 0.
*/
private _originalWidth: float;
/**
* `_height` takes this value when the scale equals 1.
*
* It can't be 0.
*/
private _originalHeight: float;
/**
* `depth` takes this value when the scale equals 1.
*
* It can't be 0.
*/
private _originalDepth: float;
private _width: float;
private _height: float;
private _depth: float;
private _flippedX: boolean = false;
private _flippedY: boolean = false;
private _flippedZ: boolean = false;
/**
* Euler angle with the `ZYX` order.
*
* Note that `_rotationZ` is `angle` from `gdjs.RuntimeObject`.
*/
private _rotationX: float = 0;
/**
* Euler angle with the `ZYX` order.
*
* Note that `_rotationZ` is `angle` from `gdjs.RuntimeObject`.
*/
private _rotationY: float = 0;
private _hasEstimatedVelocity = false;
private _estimatedVelocityX: float = 0;
private _estimatedVelocityY: float = 0;
private _estimatedVelocityZ: float = 0;
private static _temporaryVector = new THREE.Vector3();
constructor(
instanceContainer: gdjs.RuntimeInstanceContainer,
objectData: gdjs.Object3DData,
instanceData: InstanceData | undefined
) {
super(instanceContainer, objectData, instanceData);
// TODO Should 0 be replaced by 0.01 instead of using the default value?
this._width = this._originalWidth = getValidDimensionValue(
objectData.content.width
);
this._height = this._originalHeight = getValidDimensionValue(
objectData.content.height
);
this._depth = this._originalDepth = getValidDimensionValue(
objectData.content.depth
);
}
abstract getRenderer(): gdjs.RuntimeObject3DRenderer;
/**
* Writes the object's conservative axis-aligned box into `box` and returns
* it. Allocates nothing. See `gdjs.Object3DCulling.computeAABB`.
*/
getAABB3D(box: THREE.Box3): THREE.Box3 {
return gdjs.Object3DCulling.computeAABB(this, box);
}
/**
* True when the object's box intersects `frustum`. Allocates nothing, so it
* is safe to call for every object every frame.
*/
isInFrustum(frustum: THREE.Frustum): boolean {
return gdjs.Object3DCulling.isInFrustum(this, frustum);
}
setCullingVisible(culled: boolean): void {
this.getRenderer().setCullingVisible(culled);
}
getRendererObject() {
return null;
}
get3DRendererObject() {
return this.getRenderer().get3DRendererObject();
}
updateFromObjectData(
oldObjectData: Object3DData,
newObjectData: Object3DData
): boolean {
this.updateOriginalDimensionsFromObjectData(oldObjectData, newObjectData);
return true;
}
updateOriginalDimensionsFromObjectData(
oldObjectData: Object3DData,
newObjectData: Object3DData
): void {
// There is no need to check if they changed because events can't modify them.
this._setOriginalWidth(
getValidDimensionValue(newObjectData.content.width)
);
this._setOriginalHeight(
getValidDimensionValue(newObjectData.content.height)
);
this._setOriginalDepth(
getValidDimensionValue(newObjectData.content.depth)
);
}
getNetworkSyncData(
syncOptions: GetNetworkSyncDataOptions
): Object3DNetworkSyncData {
return {
...super.getNetworkSyncData(syncOptions),
z: this.getZ(),
d: this.getDepth(),
rx: this.getRotationX(),
ry: this.getRotationY(),
flipX: this.isFlippedX(),
flipY: this.isFlippedY(),
flipZ: this.isFlippedZ(),
};
}
updateFromNetworkSyncData(
networkSyncData: Object3DNetworkSyncData,
options: UpdateFromNetworkSyncDataOptions
) {
super.updateFromNetworkSyncData(networkSyncData, options);
if (networkSyncData.z !== undefined) this.setZ(networkSyncData.z);
if (networkSyncData.d !== undefined) this.setDepth(networkSyncData.d);
if (networkSyncData.rx !== undefined)
this.setRotationX(networkSyncData.rx);
if (networkSyncData.ry !== undefined)
this.setRotationY(networkSyncData.ry);
if (networkSyncData.flipX !== undefined)
this.flipX(networkSyncData.flipX);
if (networkSyncData.flipY !== undefined)
this.flipY(networkSyncData.flipY);
if (networkSyncData.flipZ !== undefined)
this.flipZ(networkSyncData.flipZ);
}
extraInitializationFromInitialInstance(initialInstanceData: InstanceData) {
if (initialInstanceData.customSize) {
this.setWidth(initialInstanceData.width);
this.setHeight(initialInstanceData.height);
}
if (initialInstanceData.depth !== undefined) {
this.setDepth(initialInstanceData.depth);
}
this.flipX(!!initialInstanceData.flippedX);
this.flipY(!!initialInstanceData.flippedY);
this.flipZ(!!initialInstanceData.flippedZ);
}
setX(x: float): void {
super.setX(x);
this.getRenderer().updatePosition();
}
setY(y: float): void {
super.setY(y);
this.getRenderer().updatePosition();
}
/**
* Set the object position on the Z axis.
*/
setZ(z: float): void {
if (z === this._z) return;
this._z = z;
this.getRenderer().updatePosition();
}
/**
* Get the object position on the Z axis.
*/
getZ(): float {
return this._z;
}
/**
* Get the Z position of the rendered object.
*
* For most objects, this will returns the same value as getZ(). But if the
* object has an origin that is not the same as the point (0,0,0) of the
* object displayed, getDrawableZ will differ.
*
* @return The Z position of the rendered object.
*/
getDrawableZ(): float {
return this._z;
}
/**
* Return the bottom Z of the object.
* Rotations around X and Y are not taken into account.
*/
getUnrotatedAABBMinZ(): number {
return this.getDrawableZ();
}
/**
* Return the top Z of the object.
* Rotations around X and Y are not taken into account.
*/
getUnrotatedAABBMaxZ(): number {
return this.getDrawableZ() + this.getDepth();
}
/**
* Return the Z position of the object center, **relative to the object Z
* position** (`getDrawableX`).
*
* Use `getCenterZInScene` to get the position of the center in the scene.
*
* @return the Z position of the object center, relative to
* `getDrawableZ()`.
*/
getCenterZ(): float {
return this.getDepth() / 2;
}
getCenterZInScene(): float {
return this.getDrawableZ() + this.getCenterZ();
}
setCenterZInScene(z: float): void {
this.setZ(z + this._z - (this.getDrawableZ() + this.getCenterZ()));
}
setAngle(angle: float): void {
super.setAngle(angle);
this.getRenderer().updateRotation();
}
/**
* Set the object rotation on the X axis.
*
* This is an Euler angle. Objects use the `ZYX` order.
*/
setRotationX(angle: float): void {
this._rotationX = angle;
this.getRenderer().updateRotation();
}
/**
* Set the object rotation on the Y axis.
*
* This is an Euler angle. Objects use the `ZYX` order.
*/
setRotationY(angle: float): void {
this._rotationY = angle;
this.getRenderer().updateRotation();
}
/**
* Get the object rotation on the X axis.
*
* This is an Euler angle. Objects use the `ZYX` order.
*/
getRotationX(): float {
return this._rotationX;
}
/**
* Get the object rotation on the Y axis.
*
* This is an Euler angle. Objects use the `ZYX` order.
*/
getRotationY(): float {
return this._rotationY;
}
/**
* Turn the object around the scene x axis at its center.
* @param deltaAngle the rotation angle
*/
turnAroundX(deltaAngle: float): void {
const axisX = gdjs.RuntimeObject3D._temporaryVector;
axisX.set(1, 0, 0);
const mesh = this.getRenderer().get3DRendererObject();
mesh.rotateOnWorldAxis(axisX, gdjs.toRad(deltaAngle));
this._rotationX = gdjs.toDegrees(mesh.rotation.x);
this._rotationY = gdjs.toDegrees(mesh.rotation.y);
this.setAngle(gdjs.toDegrees(mesh.rotation.z));
}
/**
* Turn the object around the scene y axis at its center.
* @param deltaAngle the rotation angle
*/
turnAroundY(deltaAngle: float): void {
const axisY = gdjs.RuntimeObject3D._temporaryVector;
axisY.set(0, 1, 0);
const mesh = this.getRenderer().get3DRendererObject();
mesh.rotateOnWorldAxis(axisY, gdjs.toRad(deltaAngle));
this._rotationX = gdjs.toDegrees(mesh.rotation.x);
this._rotationY = gdjs.toDegrees(mesh.rotation.y);
this.setAngle(gdjs.toDegrees(mesh.rotation.z));
}
/**
* Turn the object around the scene z axis at its center.
* @param deltaAngle the rotation angle
*/
turnAroundZ(deltaAngle: float): void {
const axisZ = gdjs.RuntimeObject3D._temporaryVector;
axisZ.set(0, 0, 1);
const mesh = this.getRenderer().get3DRendererObject();
mesh.rotateOnWorldAxis(axisZ, gdjs.toRad(deltaAngle));
this._rotationX = gdjs.toDegrees(mesh.rotation.x);
this._rotationY = gdjs.toDegrees(mesh.rotation.y);
this.setAngle(gdjs.toDegrees(mesh.rotation.z));
}
getForwardX(): float {
return this.getRenderer().getForwardX();
}
getForwardY(): float {
return this.getRenderer().getForwardY();
}
getForwardZ(): float {
return this.getRenderer().getForwardZ();
}
getUpX(): float {
return this.getRenderer().getUpX();
}
getUpY(): float {
return this.getRenderer().getUpY();
}
getUpZ(): float {
return this.getRenderer().getUpZ();
}
getRightX(): float {
return this.getRenderer().getRightX();
}
getRightY(): float {
return this.getRenderer().getRightY();
}
getRightZ(): float {
return this.getRenderer().getRightZ();
}
override getOriginalWidth(): float {
return this._originalWidth;
}
override getOriginalHeight(): float {
return this._originalHeight;
}
getOriginalDepth(): float {
return this._originalDepth;
}
getWidth(): float {
return this._width;
}
getHeight(): float {
return this._height;
}
/**
* Get the object size on the Z axis (called "depth").
*/
getDepth(): float {
return this._depth;
}
setWidth(width: float): void {
if (this._width === width) return;
this._width = width;
this.getRenderer().updateSize();
this.invalidateHitboxes();
}
setHeight(height: float): void {
if (this._height === height) return;
this._height = height;
this.getRenderer().updateSize();
this.invalidateHitboxes();
}
setSize(newWidth: number, newHeight: number): void {
this.setWidth(newWidth);
this.setHeight(newHeight);
}
/**
* Set the object size on the Z axis (called "depth").
*/
setDepth(depth: float): void {
if (this._depth === depth) return;
this._depth = depth;
this.getRenderer().updateSize();
}
/**
* Set the width of the object for a scale of 1.
*/
_setOriginalWidth(originalWidth: float): void {
if (originalWidth <= 0) {
originalWidth = 1;
}
const oldOriginalWidth = this._originalWidth;
this._originalWidth = originalWidth;
if (oldOriginalWidth === this._width) {
this.setWidth(originalWidth);
}
}
/**
* Set the height of the object for a scale of 1.
*/
_setOriginalHeight(originalHeight: float): void {
if (originalHeight <= 0) {
originalHeight = 1;
}
const oldOriginalHeight = this._originalHeight;
this._originalHeight = originalHeight;
if (oldOriginalHeight === this._height) {
this.setHeight(originalHeight);
}
}
/**
* Set the object size on the Z axis (called "depth") when the scale equals 1.
*/
_setOriginalDepth(originalDepth: float): void {
if (originalDepth <= 0) {
originalDepth = 1;
}
const oldOriginalDepth = this._originalDepth;
this._originalDepth = originalDepth;
if (oldOriginalDepth === this._depth) {
this.setDepth(originalDepth);
}
}
/**
* Change the scale on X, Y and Z axis of the object.
*
* @param newScale The new scale (must be greater than 0).
*/
setScale(newScale: number): void {
this.setScaleX(newScale);
this.setScaleY(newScale);
this.setScaleZ(newScale);
}
/**
* Change the scale on X axis of the object (changing its width).
*
* @param newScale The new scale (must be greater than 0).
*/
setScaleX(newScale: number): void {
if (newScale < 0) {
newScale = 0;
}
this.setWidth(this._originalWidth * newScale);
}
/**
* Change the scale on Y axis of the object (changing its height).
*
* @param newScale The new scale (must be greater than 0).
*/
setScaleY(newScale: number): void {
if (newScale < 0) {
newScale = 0;
}
this.setHeight(this._originalHeight * newScale);
}
/**
* Change the scale on Z axis of the object (changing its height).
*
* @param newScale The new scale (must be greater than 0).
*/
setScaleZ(newScale: number): void {
if (newScale < 0) {
newScale = 0;
}
this.setDepth(this._originalDepth * newScale);
}
/**
* Get the scale of the object (or the geometric average of X, Y and Z scale in case they are different).
*
* @return the scale of the object (or the geometric average of X, Y and Z scale in case they are different).
*/
getScale(): number {
const scaleX = this.getScaleX();
const scaleY = this.getScaleY();
const scaleZ = this.getScaleZ();
return scaleX === scaleY && scaleX === scaleZ
? scaleX
: Math.pow(scaleX * scaleY * scaleZ, 1 / 3);
}
/**
* Get the scale of the object on X axis.
*
* @return the scale of the object on X axis
*/
getScaleX(): float {
return Math.abs(this._width / this._originalWidth);
}
/**
* Get the scale of the object on Y axis.
*
* @return the scale of the object on Y axis
*/
getScaleY(): float {
return Math.abs(this._height / this._originalHeight);
}
/**
* Get the scale of the object on Z axis.
*
* @return the scale of the object on Z axis
*/
getScaleZ(): float {
return Math.abs(this._depth / this._originalDepth);
}
flipX(enable: boolean) {
if (enable !== this._flippedX) {
this._flippedX = enable;
this.getRenderer().updateSize();
}
}
flipY(enable: boolean) {
if (enable !== this._flippedY) {
this._flippedY = enable;
this.getRenderer().updateSize();
}
}
flipZ(enable: boolean) {
if (enable !== this._flippedZ) {
this._flippedZ = enable;
this.getRenderer().updateSize();
}
}
isFlippedX(): boolean {
return this._flippedX;
}
isFlippedY(): boolean {
return this._flippedY;
}
isFlippedZ(): boolean {
return this._flippedZ;
}
hide(enable: boolean): void {
super.hide(enable);
this.getRenderer().updateVisibility(!this.isHidden());
}
hasEstimatedVelocity(): boolean {
return this._hasEstimatedVelocity;
}
resetEstimatedVelocity(): void {
this._hasEstimatedVelocity = false;
this._estimatedVelocityX = 0;
this._estimatedVelocityY = 0;
this._estimatedVelocityZ = 0;
}
getEstimatedVelocityX(): float {
return this._estimatedVelocityX;
}
getEstimatedVelocityY(): float {
return this._estimatedVelocityY;
}
getEstimatedVelocityZ(): float {
return this._estimatedVelocityZ;
}
setEstimatedVelocityX(velocityX: float): void {
this._estimatedVelocityX = velocityX;
this._hasEstimatedVelocity = true;
}
setEstimatedVelocityY(velocityY: float): void {
this._estimatedVelocityY = velocityY;
this._hasEstimatedVelocity = true;
}
setEstimatedVelocityZ(velocityZ: float): void {
this._estimatedVelocityZ = velocityZ;
this._hasEstimatedVelocity = true;
}
}
}