How to make Local Clipping in Child of Group(multi clip)?

Hi everyone :waving_hand:

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.

:magnifying_glass_tilted_left: 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.

:brain: 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.


:white_check_mark: Solution: Apply Inverse Matrix to Planes

I fixed this by:

  1. Storing the original planes in local object space.
  2. 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;
            }
        }
    }
});

:package: 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();
    }
}

on Client

const HandleContent = async (data: any) => {
    if (template?.Renderer) {
        const Empty: THREE.Plane[] = [];
        template.Renderer.clippingPlanes = Empty;
        template.Renderer.localClippingEnabled = true;
    }


    carousel = new CarouselHelper({ title: 'Pandang Tak Jemu Shop', debugClipping: true });
    carousel.position.set(WorldPosition.x, WorldPosition.y, -.5);
    template?.Scene.add(carousel);

    const length = data.length;
    const lastIndex = data.length - 1;
    const lastCardX = lastIndex * CardSpacing;
    const scrollableWidth = Math.max(0, lastCardX);
    const minScroll = 0;
    const maxScroll = scrollableWidth;

    const width = .5;
    const height = .25;

    carousel.SetScroll(minScroll, maxScroll);

    for (let i = 0; i < length; i++) {
        const item = data[i];

        if (!item.quantity) item.quantity = 1;
        if (!item.rating) {
            item.rating = {
                rate: 4.9,
                count: 120
            };
        }


        const card = handleCard(width, height);
        card.userData.itemId = item.id;
        card.position.set(i * CardSpacing, 0, 0.001);

        card.geometry.computeBoundingBox();
        const rawBoundingBox = card.geometry.boundingBox;
        if (!rawBoundingBox) return;

        const image = await handleImage(item.image ?? item.link_image, width, height);
        image.position.set(rawBoundingBox.max.x * 0.7, 0, 0.001);
        card.add(image);

        const title = handleTitle(item.name ?? item.title, rawBoundingBox, width);
        card.add(title);

        const buyButton = handleButtonBuy(width, height);
        buyButton.userData.itemId = item.id;
        buyButton.userData.item = item;
        buyButton.position.set(0, rawBoundingBox.min.y * 0.8, 0.001);
        card.add(buyButton);

        const price = handlePrice(item.price, rawBoundingBox);
        card.add(price);

        const rate = handleRate(item.rating, rawBoundingBox);
        card.add(rate);

        const count = handleCount(item, rawBoundingBox);
        card.add(count);

        carousel.children.forEach(child => {
            if (child instanceof THREE.Group && child.isGroup) {
                child.add(card)
            }
        })
    }
    register.addFeatures({ requiredFeatures: ['carousel'], data: { carousel: { mesh: carousel }, controllers: template?.Controllers, renderer: template?.Renderer } })
}

const handleCard = (width: number, height: number) => {

    const geometry = new THREE.PlaneGeometry(width, height);
    const material = new THREE.MeshBasicMaterial({
        color: 0xffffff,
        clipShadows: true,
        alphaToCoverage: true,
        clippingPlanes: carousel.clippingPlanes
    })
    const card = new THREE.Mesh(geometry, material);

    return card;
}

const handleImage = async (image: string, width: number, height: number) => {
    const loader = new THREE.TextureLoader(template?.LoadingManager);
    const texture = await loader.loadAsync(image);
    texture.minFilter = THREE.LinearFilter;
    texture.magFilter = THREE.LinearFilter;
    texture.wrapS = THREE.ClampToEdgeWrapping;
    texture.wrapT = THREE.ClampToEdgeWrapping;
    texture.colorSpace = THREE.SRGBColorSpace;
    texture.anisotropy = 16;
    texture.needsUpdate = true;

    const aspect = texture.image.width / texture.image.height;
    const imageWidth = height * aspect;
    const geometry = new THREE.PlaneGeometry(imageWidth, height);
    const material = new THREE.MeshBasicMaterial({
        map: texture,
        transparent: true,
        clipShadows: true,
        alphaToCoverage: true,
        clippingPlanes: carousel.clippingPlanes
    });
    const mesh = new THREE.Mesh(geometry, material);
    mesh.name = 'image';
    imageWidth > 0.3 ? mesh.scale.set(0.3, 0.3, 0.3) : mesh.scale.set(0.5, 0.5, 0.5);

    return mesh;
}

const handleTitle = (title: string, boundingBox: BoundingBox, width: number) => {
    const content = new Text();
    content.material.side = THREE.FrontSide;
    content.fontSize = 0.015;
    content.fontStyle = 'normal';
    content.textAlign = 'center';
    content.overflowWrap = 'break-word';
    content.whiteSpace = 'normal';
    content.anchorX = 'center';
    content.anchorY = 'top';
    content.direction = 'ltr';
    content.maxWidth = width * 0.9;
    content.color = 0x000000;
    content.text = title;
    content.position.set(0, boundingBox.max.y * 0.95, 0.001);
    content.name = `title ${title}`;
    content.material.clippingPlanes = carousel.clippingPlanes;
    content.sync();

    return content;
}

const handleButtonBuy = (width: number, height: number) => {
    const geometry = new THREE.PlaneGeometry(width / 4, height / 8);
    const material = new THREE.MeshBasicMaterial({
        color: 0x228B22,
        clipShadows: true,
        alphaToCoverage: true,
        clippingPlanes: carousel.clippingPlanes
    })
    const buttonMesh = new THREE.Mesh(geometry, material) as ClickableMesh;
    buttonMesh.name = `button buy`;

    const text = new Text();
    text.material.side = THREE.FrontSide;
    text.fontSize = 0.015;
    text.fontStyle = 'normal';
    text.textAlign = 'center';
    text.overflowWrap = 'break-word';
    text.whiteSpace = 'normal';
    text.anchorX = 'center';
    text.anchorY = 'middle';
    text.direction = 'ltr';
    text.maxWidth = (width / 4) * 0.9;
    text.color = 0xffffff;
    text.text = 'ADD TO CART';
    text.position.set(0, 0, 0.001);
    text.material.clippingPlanes = carousel.clippingPlanes;
    text.sync();

    buttonMesh.add(text);

    buttonMesh.onClick = () => {
        return buttonMesh.userData?.item;
    }

    return buttonMesh;
}

const handlePrice = (price: number, boundingBox: BoundingBox) => {
    const content = new Text();
    content.name = 'price'
    content.material.side = THREE.FrontSide;
    content.fontSize = 0.012;
    content.fontStyle = 'normal';
    content.textAlign = 'center';
    content.overflowWrap = 'break-word';
    content.whiteSpace = 'normal';
    content.anchorX = 'left';
    content.anchorY = 'top';
    content.direction = 'ltr';
    content.maxWidth = 0.9;
    content.color = 0x333333;
    content.text = `Price :\n $${price}`;
    content.material.clipShadows = true;
    content.material.alphaToCoverage = true;
    content.position.set(boundingBox.min.x * .9, 0, 0.001);
    content.material.clippingPlanes = carousel.clippingPlanes;
    content.sync();
    return content;
}


const handleRate = (rating: Rating, boundingBox: BoundingBox) => {
    const rate = Math.min(Math.max(rating.rate, 0), 5);
    const count = rating.count;

    const stars = [];
    for (let i = 0; i < 5; i++) {
        if (i < Math.floor(rate)) {
            stars.push({ symbol: '★', type: 'full' });
        } else if (i < rate) {
            stars.push({ symbol: '⯨', type: 'half' });
        } else {
            stars.push({ symbol: '☆', type: 'empty' });
        }
    }

    const starsString = stars.map(s => s.symbol).join('');
    const content = new Text();
    content.name = 'rate';
    content.text = `Rate : \n${starsString} (${count})`;
    content.fontSize = 0.012;
    content.color = 0x333333;
    content.fontStyle = 'normal';
    content.textAlign = 'center';
    content.overflowWrap = 'break-word';
    content.whiteSpace = 'normal';
    content.anchorX = 'left';
    content.anchorY = 'top';
    content.direction = 'ltr';
    content.material.clipShadows = true;
    content.material.alphaToCoverage = true;
    content.position.set(boundingBox.min.x * .65, 0, 0.001);
    content.material.clippingPlanes = carousel.clippingPlanes;
    content.sync();
    return content;
}

const handleCount = (data: any, boundingBox: BoundingBox) => {

    const group = new THREE.Group();
    group.position.x = .04;

    const Quantity = new Text();
    Quantity.name = 'quantity';
    Quantity.material.side = THREE.FrontSide;
    Quantity.fontSize = 0.012;
    Quantity.fontStyle = 'normal';
    Quantity.textAlign = 'center';
    Quantity.overflowWrap = 'break-word';
    Quantity.whiteSpace = 'normal';
    Quantity.anchorX = 'left';
    Quantity.anchorY = 'top';
    Quantity.direction = 'ltr';
    Quantity.color = 0x333333;
    Quantity.text = 'Quantity : \n 1';
    Quantity.position.set(boundingBox.min.x * .3, 0, 0.001);
    Quantity.material.clippingPlanes = carousel.clippingPlanes;
    group.add(Quantity);

    Quantity.sync();
    const minusMesh = new THREE.Mesh(
        new THREE.PlaneGeometry(0.012, 0.012),
        new THREE.MeshBasicMaterial({
            color: 0x228B22,
            clipShadows: true,
            alphaToCoverage: true,
            clippingPlanes: carousel.clippingPlanes
        })
    ) as ClickableMesh;

    const minusText = new Text();
    minusText.material.side = THREE.FrontSide;
    minusText.fontSize = 0.012;
    minusText.fontStyle = 'normal';
    minusText.textAlign = 'center';
    minusText.overflowWrap = 'break-word';
    minusText.whiteSpace = 'normal';
    minusText.anchorX = 'center';
    minusText.anchorY = 'middle';
    minusText.direction = 'ltr';
    minusText.maxWidth = 0.012 * 0.9;
    minusText.color = 0xffffff;
    minusText.text = '-';
    minusText.position.set(0, 0.0015, 0.001);
    minusText.material.clippingPlanes = carousel.clippingPlanes;
    minusMesh.position.set(boundingBox.min.x * .3, -0.024, 0.001);
    minusMesh.add(minusText);
    group.add(minusMesh);
    minusText.sync();

    const plusMesh = new THREE.Mesh(
        new THREE.PlaneGeometry(0.012, 0.012),
        new THREE.MeshBasicMaterial({
            color: 0x228B22,
            clipShadows: true,
            alphaToCoverage: true,
            clippingPlanes: carousel.clippingPlanes
        })
    ) as ClickableMesh;

    const plusText = new Text();
    plusText.material.side = THREE.FrontSide;
    plusText.fontSize = 0.012;
    plusText.fontStyle = 'normal';
    plusText.textAlign = 'center';
    plusText.overflowWrap = 'break-word';
    plusText.whiteSpace = 'normal';
    plusText.anchorX = 'center';
    plusText.anchorY = 'middle';
    plusText.direction = 'ltr';
    plusText.maxWidth = 0.012 * 0.9;
    plusText.color = 0xffffff;
    plusText.text = '+';
    plusText.material.clippingPlanes = carousel.clippingPlanes;
    plusText.position.set(0, 0.0015, 0.001);
    plusMesh.position.set(-.015, -0.024, 0.001);

    plusMesh.add(plusText);
    group.add(plusMesh);
    plusText.sync();

    minusMesh.name = 'button decrement';
    plusMesh.name = 'button increment';
    minusMesh.userData.type = 'decrement';
    plusMesh.userData.type = 'increment';
    plusMesh.userData.itemId = data.id;
    minusMesh.userData.itemId = data.id;


    minusMesh.onClick = () => {
        let price: any;
        let buttonBuy: any;

        carousel.children.forEach(child => {
            if (child instanceof THREE.Group && child.isGroup) {
                child.children.forEach(grandChild => {
                    if (grandChild.userData.itemId === plusMesh.userData.itemId) {
                        price = grandChild.getObjectByName('price');
                        buttonBuy = grandChild.getObjectByName('button buy');
                    }
                })

            }
        })


        const item = data.find((val: { id: any; }) => val.id === plusMesh.userData.itemId);
        item.quantity = Math.max(1, item.quantity - 1);
        item.totalPrice = (item.quantity * item.price);

        Quantity.text = `Quantity : \n ${item.quantity}`;
        Quantity.sync();

        if (!price) return;

        price.text = `Price :\n $${item.totalPrice.toFixed(2)}`;
        price.sync();

        buttonBuy.userData.item = item;
    }


    plusMesh.onClick = () => {
        let price: any;
        let buttonBuy: any;

        carousel.children.forEach(child => {
            if (child instanceof THREE.Group && child.isGroup) {
                child.children.forEach(grandChild => {
                    if (grandChild.userData.itemId === plusMesh.userData.itemId) {
                        price = grandChild.getObjectByName('price');
                        buttonBuy = grandChild.getObjectByName('button buy');
                    }
                })

            }
        })

        const item = data.find((val: { id: any; }) => val.id === plusMesh.userData.itemId);

        item.quantity = Math.max(1, item.quantity + 1);
        item.totalPrice = (item.quantity * item.price);

        Quantity.text = `Quantity : \n ${item.quantity}`;
        Quantity.sync();

        price.text = `Price :\n $${item.totalPrice.toFixed(2)}`;
        price.sync();

        buttonBuy.userData.item = item;

    }

    return group;
}

I tried it once, without matrix inversion (see lines 61-62). You may need to use .matrixWorld if you have nested objects.

clippingPlane.copy( originalClippingPlane );
clippingPlane.applyMatrix4( object.matrix );

https://codepen.io/boytchev/pen/vYqaMxJ

if im not using updateClippingPlanes()

    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();
    }

i already created that thing in my code

    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;
                    }
                }
            }
        });
    }

still same, idk why

    public updateClippingPlanes() {
        this.updateMatrix();
        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) return;

            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;
                    }
                }
            }
        });
        // 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;
        //             }
        //         }
        //     }
        // });
    }

Sorry, it’s hard to debug in my mind just by looking at the source. Any chance to make a minimal debuggable example in codepen.io? Minimal means to remove all things that are not relevant to the issue (e.g. textures, XR mode, and so on.) and have just the bare minimum of a few objects and orbit controls that demonstrates the issue.

https://codepen.io/Wildy-Simanjuntak/pen/XJmKoLm

here’s my codepen, yah same my logic

i already create my example in codepen bro.. :grin:

With current clipping I see this:

Without clipping I see this:

What is the expected result? The outer parts of the red and blue to be clipped out?

Yes, that’s correct — the goal is for my carousel to behave like a scrollable container with visual boundaries. Specifically:

  • Only the content within a defined frame (or mask) should be visible.
  • As the user scrolls (via programmatic offset of the group inside the carousel), items that move outside this frame should be clipped, so they are no longer visible.
  • At the same time, the clipping area should follow the camera’s position and orientation, so the visible region stays properly aligned even when the user moves their head or the camera.

Think of it like a 3D version of how a <div> with overflow: hidden works in HTML.

The left/right clipping is OK, I think. Just comment the top bottom clipping and you will see.

yah i did same as u, n now its finally success

The correct clipping planes are:

          this.originalPlanes = [
            new THREE.Plane(new THREE.Vector3(1, 0, 0), width / 2),
            new THREE.Plane(new THREE.Vector3(-1, 0, 0), width / 2),
            new THREE.Plane(new THREE.Vector3(0, -1, 0), height / 2 ),
            new THREE.Plane(new THREE.Vector3(0, 1, 0), height / 2 )
          ];

Edit: when I try with extra tall boxes, they got clipped well:

https://codepen.io/boytchev/pen/yyYJZRa?editors=0010

yah and

          for (let i = 0; i < this.originalPlanes.length; i++){
            const original = this.originalPlanes[i];
            if (!original) return;

            const clip = new THREE.Plane();
            clip.copy(original).applyMatrix4(this.matrix);
            this.clippingPlanes.push(clip);                     
          }

i forgot, i was use this.matrixWorld in constructor not this.matrix

and in animate func

    function animate() {
      controls.update();

      carousel.position.x += 0.01 * direction;

      if (carousel.position.x > 1 || carousel.position.x < -1) {
        direction *= -1;
      }
      
      carousel.updateMatrixWorld(true);
      for (let i = 0; i < carousel.originalPlanes.length; i++){
            const original = carousel.originalPlanes[i];
            if (!original) return;

            
            const clip = carousel.clippingPlanes[i];
            if(clip) {
                  clip.copy(original).applyMatrix4(carousel.matrixWorld);
             }                       
          }

      renderer.render(scene, camera);
    }

i used carousel.matrixWorld

https://codepen.io/Wildy-Simanjuntak/pen/XJmKoLm

btw thank you for your response, thank you …

1 Like