Hi everyone
I’m building a CarouselHelper
class (extending Mesh
) that renders a custom 3D UI element (like a floating carousel panel). This carousel needs to:
- Follow the camera smoothly (position & rotation),
- Clip child content (like cards or buttons) based on the panel boundaries,
- Support dynamic updates like scrolling or rotating content.
The clipping works fine when the carousel is static, but once the carousel starts following the camera (position and rotation), the clipping planes no longer clip as expected.
What I Observed
- I’m using local
Plane
objects to define clip boundaries (left, right, top, bottom). - These planes are applied to all materials inside the carousel group via
material.clippingPlanes
. - When the carousel moves (using
lerp
to follow the camera), the geometry follows, but the clipping region stays in its original world-space location, which causes wrong clipping behavior.
Why This Happens
From what I understand, clippingPlanes
in Three.js are evaluated in world space. That means they don’t transform with the object unless we manually update them.
So when the carousel moves, the clipping planes remain static in world coordinates — hence, they no longer match the new object boundaries.
Solution: Apply Inverse Matrix to Planes
I fixed this by:
- Storing the original planes in local object space.
- On each frame, transforming these original planes to world space using the inverse of the object’s world matrix.
Here’s a simplified version of what I did:
this.updateMatrixWorld(true);
this._inverseMatrix.copy(this.matrixWorld).invert();
for (let i = 0; i < this.clippingPlanes.length; i++) {
const clip = this.clippingPlanes[i];
const original = this.originalPlanes.get(clip);
if (!original) continue;
clip.copy(original).applyMatrix4(this._inverseMatrix); // move from local to world space
}
And then, I apply the updated planes to all materials inside:
this.traverse(obj => {
if (obj instanceof Mesh) {
const materials = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const mat of materials) {
if ("clippingPlanes" in mat) {
mat.clippingPlanes = this.clippingPlanes;
mat.clipIntersection = true;
mat.needsUpdate = true;
}
}
}
});
What I’m Trying to Achieve
I want the carousel to:
- Move/rotate dynamically with the camera (
followCamera
logic), - Maintain correct clipping on all internal content (like scrolling cards),
- Keep things efficient and easy to debug (using
PlaneHelper
optionally).
So far, this works — but I’m wondering:
Is this the right approach?
Is there a more efficient or idiomatic way to keep clipping planes aligned with a moving object in Three.js?
my full code :
import {
FrontSide,
Mesh,
MeshBasicMaterial,
PlaneGeometry,
Plane,
Vector3,
Group,
MathUtils,
PlaneHelper,
Camera,
Matrix4
} from "three";
import type { BoundingBox } from "../types/index";
import { Text } from "troika-three-text";
export class CarouselHelper extends Mesh {
private _minScroll = 0;
private _maxScroll = 0;
public readonly clippingPlanes: Plane[] = [];
private readonly originalPlanes = new Map<Plane, Plane>();
private _followTarget = new Vector3();
private _offset = new Vector3(0, 0, -1);
private _inverseMatrix = new Matrix4();
private _debugHelpers: PlaneHelper[] = [];
constructor({ title = "VR SHOP", width = 1, height = .5, debugClipping = false }: { title: string, width?: number, height?: number, debugClipping?: boolean }) {
const geometry = new PlaneGeometry(width, height);
const material = new MeshBasicMaterial({
color: 0x000000,
side: FrontSide,
transparent: true,
opacity: 0.7
});
super(geometry, material);
this.add(new Group());
const clipLeft = new Plane(new Vector3(1, 0, 0), width / 2);
const clipRight = new Plane(new Vector3(-1, 0, 0), width / 2);
const clipTop = new Plane(new Vector3(0, -1, 0), 1.75);
const clipBottom = new Plane(new Vector3(0, 1, 0), -1.35);
this.clippingPlanes.push(clipLeft, clipTop, clipRight, clipBottom);
for (const plane of this.clippingPlanes) {
this.originalPlanes.set(plane, plane.clone());
}
if (debugClipping) {
const helpers = [
new PlaneHelper(clipLeft, 5, 0xff0000),
new PlaneHelper(clipRight, 5, 0x00ff00),
new PlaneHelper(clipTop, 5, 0x0000ff),
new PlaneHelper(clipBottom, 5, 0xffff00)
];
for (const helper of helpers) {
this.add(helper);
this._debugHelpers.push(helper);
}
}
geometry.computeBoundingBox();
const boundingBox = geometry.boundingBox;
if (boundingBox) {
this._createTitle(title, boundingBox);
}
}
private _createTitle(title: string, boundingBox: BoundingBox) {
const content = new Text();
content.material.side = FrontSide;
content.fontSize = 0.03;
content.textAlign = "center";
content.overflowWrap = "break-word";
content.whiteSpace = "normal";
content.anchorX = "center";
content.anchorY = "top";
content.direction = "ltr";
content.maxWidth = 0.9;
content.color = 0xffffff;
content.text = title;
content.position.set(0, boundingBox.max.y * 0.9, 0.001);
content.sync();
this.add(content);
}
public SetScroll(min: number, max: number) {
this._maxScroll = max;
this._minScroll = min;
}
public Scroll(delta: number) {
this.children.forEach(child => {
if (child instanceof Group && child.isGroup) {
const newX = child.position.x - delta;
child.position.x = MathUtils.clamp(newX, -this._maxScroll, this._minScroll);
}
});
}
public getOriginalPlane(p: Plane): Plane | undefined {
return this.originalPlanes.get(p);
}
public updateClippingPlanes() {
this.updateMatrixWorld(true);
this._inverseMatrix.copy(this.matrixWorld).invert();
for (let i = 0; i < this.clippingPlanes.length; i++) {
const clip = this.clippingPlanes[i];
if (!clip) return;
const original = this.originalPlanes.get(clip);
if (!original) continue;
clip.copy(original).applyMatrix4(this._inverseMatrix);
}
this.traverse(obj => {
if (obj instanceof Mesh) {
const materials = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const mat of materials) {
if ("clippingPlanes" in mat) {
mat.clippingPlanes = this.clippingPlanes;
mat.clipIntersection = true;
mat.needsUpdate = true;
}
}
}
});
}
public followCamera(camera: Camera) {
this._followTarget.copy(this._offset).applyQuaternion(camera.quaternion).add(camera.position);
this.position.lerp(this._followTarget, MathUtils.clamp(.1, 0, 1));
this.lookAt(camera.position);
this.updateClippingPlanes();
}
}
edit :
my final code after debugging and
import {
FrontSide,
Mesh,
MeshBasicMaterial,
PlaneGeometry,
Plane,
Vector3,
Group,
MathUtils,
PlaneHelper,
Camera,
Matrix4
} from "three";
import type { BoundingBox } from "../types/index";
import { Text } from "troika-three-text";
export class CarouselHelper extends Mesh {
private _minScroll = 0;
private _maxScroll = 0;
public readonly clippingPlanes: Plane[] = [];
private readonly originalPlanes: Plane[] = [];
private _followTarget = new Vector3();
private _offset = new Vector3(0, 0, -1);
private _inverseMatrix = new Matrix4();
private _debugHelpers: PlaneHelper[] = [];
constructor({ title = "VR SHOP", width = 1, height = .5, debugClipping = false }: { title: string, width?: number, height?: number, debugClipping?: boolean }) {
const geometry = new PlaneGeometry(width, height);
const material = new MeshBasicMaterial({
color: 0x000000,
side: FrontSide,
transparent: true,
opacity: 0.7
});
super(geometry, material);
this.add(new Group());
const clipLeft = new Plane(new Vector3(1, 0, 0), width / 2);
const clipRight = new Plane(new Vector3(-1, 0, 0), width / 2);
const clipTop = new Plane(new Vector3(0, -1, 0), 2);
const clipBottom = new Plane(new Vector3(0, 1, 0), -1.35);
this.originalPlanes.push(clipLeft, clipTop, clipRight, clipBottom);
for (let i = 0; i < this.originalPlanes.length; i++) {
const original = this.originalPlanes[i];
if (!original) return;
this.clippingPlanes.push(original.clone());
}
if (debugClipping) {
const helpers = [
new PlaneHelper(clipLeft, 5, 0xff0000),
new PlaneHelper(clipRight, 5, 0x00ff00),
new PlaneHelper(clipTop, 5, 0x0000ff),
new PlaneHelper(clipBottom, 5, 0xffff00)
];
for (const helper of helpers) {
this.add(helper);
this._debugHelpers.push(helper);
}
}
geometry.computeBoundingBox();
const boundingBox = geometry.boundingBox;
if (boundingBox) {
this._createTitle(title, boundingBox);
}
}
private _createTitle(title: string, boundingBox: BoundingBox) {
const content = new Text();
content.material.side = FrontSide;
content.fontSize = 0.03;
content.textAlign = "center";
content.overflowWrap = "break-word";
content.whiteSpace = "normal";
content.anchorX = "center";
content.anchorY = "top";
content.direction = "ltr";
content.maxWidth = 0.9;
content.color = 0xffffff;
content.text = title;
content.position.set(0, boundingBox.max.y * 0.9, 0.001);
content.sync();
this.add(content);
}
public SetScroll(min: number, max: number) {
this._maxScroll = max;
this._minScroll = min;
}
public Scroll(delta: number) {
this.children.forEach(child => {
if (child instanceof Group && child.isGroup) {
const newX = child.position.x - delta;
child.position.x = MathUtils.clamp(newX, -this._maxScroll, this._minScroll);
}
});
}
public getOriginalPlane(p: Plane): Plane | undefined {
return this.originalPlanes.find(plane => plane.equals(p));
}
public updateClippingPlanes() {
this.updateMatrix();
for (let i = 0; i < this.originalPlanes.length; i++) {
const original = this.originalPlanes[i];
if (!original) return;
const clip = this.clippingPlanes[i];
if(clip) clip.copy(original).applyMatrix4(this.matrix);
}
/*
this.children.forEach(child => {
child.children.forEach(grandChild => {
if (grandChild instanceof Mesh) {
const materials = Array.isArray(grandChild.material) ? grandChild.material : [grandChild.material];
for (const mat of materials) {
if ("clippingPlanes" in mat) {
mat.clippingPlanes = this.clippingPlanes;
//mat.clipIntersection = true;
mat.needsUpdate = true;
}
}
}
})
});
this.traverse(obj => {
if (obj instanceof Mesh) {
const materials = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const mat of materials) {
if ("clippingPlanes" in mat) {
mat.clippingPlanes = this.clippingPlanes;
mat.clipIntersection = true;
mat.needsUpdate = true;
}
}
}
});
*/
}
public followCamera(camera: Camera) {
this._followTarget.copy(this._offset).applyQuaternion(camera.quaternion).add(camera.position);
this.position.lerp(this._followTarget, MathUtils.clamp(.1, 0, 1));
this.lookAt(camera.position);
this.updateClippingPlanes();
}
}