How to create a scrollable plane with clipping in Three.js

Hello everyone,

I’m working on a project where I need to create a scrollable plane in Three.js that contains multiple items (such as planes and text) as children. I want the content within this plane to be scrollable, and I also need to clip the content when it exceeds the boundaries of the panel (plane). Specifically, I want the content to stop scrolling once it reaches a minimum or maximum height, and anything outside of that range should be clipped.

Here’s what I’ve tried so far:

  1. I created a plane using THREE.PlaneGeometry for the panel, and then added visual items (such as smaller planes and text) as children of the panel.
  2. I used clipping planes to try to hide any content that is outside the bounds of the panel.

The issue I’m facing is:

  • Even though I’ve set the clippingPlanes for the material, some of the objects are still visible outside the bounds of the scrollable area.
  • I want the scrolling to be constrained within the panel, and the content should be clipped based on the panel’s size and position.

Here’s a simplified version of my code:

import * as THREE from 'three';
import {Text} from 'troika-three-text';
import { System, Component, Types, World } from "three/addons/libs/ecsy.module.js";
import Stats from 'three/addons/libs/stats.module.js';
import { PerformanceHelper } from './js/PerformanceHelper.js';
import { XRControllerModelFactory } from 'three/addons/Addons.js';
import { VRButton } from 'three/addons/webxr/VRButton.js';
import { Announcement } from './js/Announcement.js'

class App {
    constructor() {
        this.manager = null;
        this.clock = new THREE.Clock();

        this.scene = new THREE.Scene();
        this.scene.background = new THREE.Color(0x2f2f2f);

        const capsuleGeometry = new THREE.CapsuleGeometry(0.3, 0.6, 10, 10);
        const capsuleMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff, wireframe: true });
        this.cameraCapsule = new THREE.Mesh(capsuleGeometry, capsuleMaterial);
        this.cameraCapsule.position.y = 0.8;
        this.camera = new THREE.PerspectiveCamera(25, window.innerWidth / window.innerHeight, 0.1, 1000);
        this.camera.updateMatrixWorld();
        this.renderer = new THREE.WebGLRenderer({ antialias: true, depth: true, powerPreference: 'high-performance' });
        this.renderer.xr.enabled = true;
        this.renderer.setPixelRatio(window.devicePixelRatio);
        this.renderer.setSize(window.innerWidth, window.innerHeight);
        this.renderer.shadowMap.enabled = true;
        this.renderer.autoClear = false;
        this.renderer.shadowMap.type = THREE.PCFShadowMap;
        this.renderer.shadowMap.needsUpdate = true;
        this.renderer.toneMapping = THREE.ReinhardToneMapping;
        this.renderer.toneMappingExposure = 1.2;
        this.renderer.outputEncoding = THREE.sRGBEncoding;
        this.renderer.setAnimationLoop(this.animate.bind(this));


        document.body?.appendChild(this.renderer.domElement);
        const sessionInit = {
            optionalFeatures: ['dom-overlay'],
            requiredFeatures: ['hand-tracking']
        };
        document.body?.appendChild(VRButton.createButton(this.renderer, sessionInit));

        window.addEventListener('resize', () => {
            this.camera.aspect = window.innerWidth / window.innerHeight;
            this.camera.updateProjectionMatrix();
            this.renderer.setSize(window.innerWidth, window.innerHeight);
        });

        this.stats = new Stats();
        this.stats.dom.style.width = '80px';
        this.stats.dom.style.height = '48px';
        document.body?.appendChild(this.stats.dom);

        this.performance = new PerformanceHelper();
        document.body?.appendChild(this.performance.dom);

        this.announcement = new Announcement();
        this.announcement.textContent(`Gunakan joystick pada controller untuk scroll\n @author Wildy13`);
        document.body?.appendChild(this.announcement.dom);

        this.cubeRenderTarget = new THREE.WebGLCubeRenderTarget(256, {
            type: THREE.HalfFloatType,
            format: THREE.RGBAFormat,
            encoding: THREE.sRGBEncoding,
        });
        this.cubeRenderTarget.texture.encoding = THREE.sRGBEncoding;
        this.cubeCamera = new THREE.CubeCamera(0.1, 20, this.cubeRenderTarget);
        this.cubeCamera.position.y = 1.6;

        this.scene.environment = this.cubeRenderTarget.texture;

        this.ambientLight = new THREE.AmbientLight(0xffffff, 0.3);
        this.scene.add(this.ambientLight);

        this.directionalLight = new THREE.DirectionalLight(0xffffff, 10);
        this.directionalLight.position.set(-30, 5, 0);
        this.directionalLight.shadow.mapSize.width = 2048;
        this.directionalLight.shadow.mapSize.height = 2048;
        this.directionalLight.shadow.camera.near = 0.1;
        this.directionalLight.shadow.camera.far = 200;
        this.directionalLight.shadow.camera.left = -200;
        this.directionalLight.shadow.camera.right = 200;
        this.directionalLight.shadow.camera.top = 200;
        this.directionalLight.shadow.camera.bottom = -200;
        this.directionalLight.shadow.bias = -0.0005;
        this.scene.add(this.directionalLight);

        //const helper = new THREE.DirectionalLightHelper(this.directionalLight, 5);
        //this.scene.add(helper);

        this.spotLight = new THREE.SpotLight(0xffffff, 10, 100, Math.PI / 4, 0.5, 2);
        this.spotLight.position.set(-100, 10, 0);
        this.spotLight.target.position.set(0, 0, 0)
        this.spotLight.castShadow = true;
        this.spotLight.shadow.mapSize.width = 4096;
        this.spotLight.shadow.mapSize.height = 4096;
        this.spotLight.shadow.camera.near = 500;
        this.spotLight.shadow.camera.far = 500;
        this.spotLight.shadow.camera.fov = 30;
        this.spotLight.penumbra = .2;
        this.scene.add(this.spotLight);

        //const spotLightHelper = new THREE.SpotLightHelper(this.spotLight);
        //this.scene.add(spotLightHelper);


        this.rightController = this.renderer.xr.getController(0);
        this.leftController = this.renderer.xr.getController(1);
        this.leftController.userData.handedness = 'left';
        this.rightController.userData.handedness = 'right';

        const controllerModelFactory = new XRControllerModelFactory();

        this.rightControllerGrip = this.renderer.xr.getControllerGrip(0);
        this.leftControllerGrip = this.renderer.xr.getControllerGrip(1);

        this.leftControllerGrip.add(controllerModelFactory.createControllerModel(this.leftControllerGrip));
        this.rightControllerGrip.add(controllerModelFactory.createControllerModel(this.rightControllerGrip));

        const leftMaterial = new THREE.ShaderMaterial({
            uniforms: {
                lineColor: { value: new THREE.Color(0xffffff) }
            },
            vertexShader:
                `
            varying float vDistance;
            void main() {
                vec4 worldPosition = modelViewMatrix * vec4(position, 1.0);
                vDistance = length(worldPosition.xyz);
                gl_Position = projectionMatrix * worldPosition;
            }
            `
            ,
            fragmentShader:
                `
            uniform vec3 lineColor;
            varying float vDistance;
            void main() {
                float opacity = 1.0 - smoothstep(0.5, 1.0, vDistance);
                gl_FragColor = vec4(lineColor, opacity);
            }
            `,
            transparent: true
        });

        const rightMaterial = new THREE.ShaderMaterial({
            uniforms: {
                lineColor: { value: new THREE.Color(0xffffff) }
            },
            vertexShader:
                `
            varying float vDistance;
            void main() {
                vec4 worldPosition = modelViewMatrix * vec4(position, 1.0);
                vDistance = length(worldPosition.xyz);
                gl_Position = projectionMatrix * worldPosition;
            }
            `
            ,
            fragmentShader:
                `
            uniform vec3 lineColor;
            varying float vDistance;
            void main() {
                float opacity = 1.0 - smoothstep(0.5, 1.0, vDistance);
                gl_FragColor = vec4(lineColor, opacity);
            }
            `,
            transparent: true
        });

        const leftGeometry = new THREE.BufferGeometry().setFromPoints([
            new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -3)
        ]);
        const rightGeometry = new THREE.BufferGeometry().setFromPoints([
            new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -3)
        ]);

        const leftLine = new THREE.Line(leftGeometry, leftMaterial);
        leftLine.name = 'line left';
        leftLine.frustumCulled = false;

        const rightLine = new THREE.Line(rightGeometry, rightMaterial);
        rightLine.name = 'line right';
        rightLine.frustumCulled = false;


        this.leftController.add(leftLine);
        this.rightController.add(rightLine);
        this.cameraCapsule.add(this.leftController);
        this.cameraCapsule.add(this.rightController);
        this.cameraCapsule.add(this.leftControllerGrip);
        this.cameraCapsule.add(this.rightControllerGrip);
        this.cameraCapsule.add(this.camera);

        this.scene.add(this.cameraCapsule);

    }

    createPanel() {
        const width = 1024;
        const height = 2048

        const canvas = document.createElement('canvas');
        canvas.width = width;
        canvas.height = height;
        const ctx = canvas.getContext('2d');

        /// initial scroll offset
        this.scrollOffset = 0;

        // draw some demo content
        const drawCanvas = () => {
            ctx.fillStyle = '#333';
            ctx.fillRect(0, 0, width, height);
            ctx.fillStyle = '#fff';
            ctx.font = '40px sans-serif';
            for (let i = 0; i < 50; i++) {
                ctx.fillText(`Item ${i + 1}`, 50, 50 + i * 60);
            }
        };
        drawCanvas();

        // create texture
        this.scrollCanvas = canvas;
        this.scrollTexture = new THREE.CanvasTexture(canvas);
        this.scrollTexture.encoding = THREE.sRGBEncoding;
        this.scrollTexture.needsUpdate = true;

        // show only portion of canvas as visible area (e.g., 1024x512)
        const visibleHeight = 512;
        const visibleCanvas = document.createElement('canvas');
        visibleCanvas.width = width;
        visibleCanvas.height = visibleHeight;
        const visibleCtx = visibleCanvas.getContext('2d');

        this.visibleCanvas = visibleCanvas;
        this.visibleCtx = visibleCtx;
        this.visibleTexture = new THREE.CanvasTexture(visibleCanvas);
        this.visibleTexture.encoding = THREE.sRGBEncoding;
        this.visibleTexture.minFilter = THREE.LinearFilter;

        const updateVisibleCanvas = () => {
            this.visibleCtx.clearRect(0, 0, width, visibleHeight);
            this.visibleCtx.drawImage(
                this.scrollCanvas,
                0, this.scrollOffset,
                width, visibleHeight,
                0, 0,
                width, visibleHeight
            );
            this.visibleTexture.needsUpdate = true;
        };
        this.updateVisibleCanvas = updateVisibleCanvas;

        // mesh
        const material = new THREE.MeshBasicMaterial({ map: this.visibleTexture, side: THREE.DoubleSide });
        const geometry = new THREE.PlaneGeometry(2, 1); // size in world units
        this.scrollPlane = new THREE.Mesh(geometry, material);
        this.scrollPlane.position.set(0, 2, -2);
        this.scene.add(this.scrollPlane);

        // auto update
        setInterval(updateVisibleCanvas, 1000 / 30); // ~30fps update

        // scroll method
        this.scrollContent = (deltaY) => {
            this.scrollOffset = Math.max(0, Math.min(this.scrollCanvas.height - visibleHeight, this.scrollOffset + deltaY));
        };
    }

    addController() {
        if (this.manager) return;

        this.isScrollingUp = false;
        this.isScrollingDown = false;
        this.manager = new Register();
        const entity = this.manager.createEntity();

        entity.addComponent(Object3D, { object: this.scrollPlane });
        entity.addComponent(PanelComponent, { scrollContent: this.scrollContent });
        entity.addComponent(ControllerComponent, { renderer: this.renderer, controllers: [this.leftController, this.rightController] })
    }

    animate() {
        this.stats.update();
        this.performance.update(this.renderer);
        this.cubeCamera.update(this.renderer, this.scene);

        this.spotLight.target.position.set(
            this.cameraCapsule.position.x + this.cameraCapsule.getWorldDirection(new THREE.Vector3()).x * 10,
            this.cameraCapsule.position.y + this.cameraCapsule.getWorldDirection(new THREE.Vector3()).y * 10,
            this.cameraCapsule.position.z + this.cameraCapsule.getWorldDirection(new THREE.Vector3()).z * 10
        )

        if (this.manager) this.manager.update(this.clock.getDelta(), this.clock.elapsedTime);

        this.renderer.render(this.scene, this.camera);
    }
}



class ControllerComponent extends Component { }

ControllerComponent.schema = {
    renderer: { type: Types.Ref, default: null },
    controllers: { type: Types.Ref, default: null },
}


class ControllerSystem extends System {
    init() {
        this.controllers = null;
        this.objectsToTest = null;
        this.previousButtonStates = {
            left: [],
            right: []
        };
        this.prevColor = {
            left: null,
            right: null
        };
    }

    execute() {
        this.queries.controllers.results.forEach(entity => {
            const components = entity.getComponent(ControllerComponent);
            const renderer = components.renderer;
            const controllers = components.controllers;
            const session = renderer.xr.getSession();

            if (session) {
                session.inputSources.forEach(source => {
                    this.handleController(source, controllers, entity);
                });
            }
        });
    }

    handleController(source, controllers, entity) {
        const object = entity.getComponent(Object3D)?.object;
        if (object) {
            controllers.forEach((c, i) => {
                if (source.handedness === c.userData.handedness) {
                    const intersections = this.getIntersections(c, object);
                    if (intersections.length > 0) {
                        const intersection = intersections[0];
                        this.updateControllerLine(c, intersection);

                        source.gamepad.buttons.forEach((b, i) => {
                            this.handleButtonsPress(b, i, c, intersection, entity);
                            this.handleJoystick(source, intersection, entity);
                        });
                    }
                }
            });
        }
    }

    handleJoystick(source, intersection, entity) {
        if (!source || !intersection || !entity) return;

        const handedness = source.handedness;
        const gamepad = source.gamepad;
        const intersectionObject = intersection.object;
        if (!intersectionObject) return;

        if (entity.hasComponent(PanelComponent)) {
            const panel = entity.getMutableComponent(PanelComponent);
            if (gamepad) {
                const axes = gamepad.axes;
                if (axes.length >= 2) {
                    const vertical = axes[3]; // thumbstick Y-axis

                    if (vertical > 0.3) {
                        panel.scrollContent(vertical);
                    } else if (vertical < -0.3) {
                        panel.scrollContent(vertical);
                    }
                }
            }
        }
    }

    getIntersections(controller, object) {
        const tempMatrix = new THREE.Matrix4();
        const raycaster = new THREE.Raycaster();
        tempMatrix.identity().extractRotation(controller.matrixWorld);
        raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
        raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
        controller.updateMatrixWorld();
        return raycaster.intersectObject(object, false);
    }

    updateControllerLine(controller, intersection) {
        const line = controller.getObjectByName(`line ${controller.userData.handedness}`);

        if (line) {
            const scale = line.scale.z;
            line.scale.z = intersection.distance || scale;
        }
    }

    handleButtonsPress(button, index, controller, intersection, entity) {
        if (!this.previousButtonStates[controller.userData.handedness]) {
            this.previousButtonStates[controller.userData.handedness] = [];
        }
        const wasPressed = this.previousButtonStates[controller.userData.handedness][index];

        if (button.pressed && !wasPressed) {
            this.ChangeLineController(controller, 0x22d3ee);
            this.processButtonAction(index, controller, entity, intersection);
        } else if (!button.pressed && wasPressed) {
            this.ChangeLineController(controller, 0xffffff);
        }

        this.previousButtonStates[controller.userData.handedness][index] = button.pressed;
    }

    ChangeLineController(controller, color) {
        const line = controller.getObjectByName(`line ${controller.userData.handedness}`);
        const hand = controller.userData.handedness;

        const newColor = new THREE.Color(color);

        if (line && line.material && (!this.prevColor[hand] || !newColor.equals(this.prevColor[hand]))) {
            line.material.uniforms.lineColor.value.set(newColor);
            line.material.needsUpdate = true;

            this.prevColor[hand] = newColor.clone();
        }
    }

    processButtonAction(index, controller, entity, intersection) {
        switch (index) {
            case 0:
                console.log(`button ${index} has been clicked in ${controller.userData.handedness} controller`);
                this.action(controller, entity, intersection);
                break;
            case 1:
                console.log(`button ${index} has been clicked in ${controller.userData.handedness} controller`);
                this.action(controller, entity, intersection);
                break;
            case 3:
                console.log(`button ${index} has been clicked in ${controller.userData.handedness} controller`);
                this.action(controller, entity, intersection);
                break;
        }
    }

    action(controller, entity, intersection) {
        if (!intersection) return;

        const intersectionObject = intersection.object;
        if (!intersectionObject) return;

        if (entity.hasComponent(PanelComponent)) {
            const panel = entity.getMutableComponent(PanelComponent);
            console.log(intersectionObject)
        }
    }
}

ControllerSystem.queries = {
    controllers: {
        components: [ControllerComponent]
    }
};


class PanelComponent extends Component { }
PanelComponent.schema = {
    state: { type: Types.String, default: 'none' },
    scrollContent: { type: Types.Ref }
};


class PanelSystem extends System {
    execute() {
        this.queries.panels.results.forEach(entity => {
            const components = entity.getMutableComponent(PanelComponent);
            const object = entity.getComponent(Object3D).object;

            switch (components.state) {
                case 'scrollUp':
                    break;

                case 'scrollDown':
                    break;

                default:
                    break;
            }
        });
    }
}

PanelSystem.queries = {
    panels: {
        components: [PanelComponent]
    }
};



class Object3D extends Component { }
Object3D.schema = {
    object: { type: Types.Ref },
};


class Register {
    constructor() {
        this.world = new World();

        if (!this.world.hasRegisteredComponent(PanelComponent)) this.world.registerComponent(PanelComponent);
        if (!this.world.getSystem(PanelSystem)) this.world.registerSystem(PanelSystem);
        if (!this.world.hasRegisteredComponent(Object3D)) this.world.registerComponent(Object3D);
        if (!this.world.hasRegisteredComponent(ControllerComponent)) this.world.registerComponent(ControllerComponent);
        if (!this.world.getSystem(ControllerSystem)) this.world.registerSystem(ControllerSystem);
    }

    createEntity() {
        return this.world.createEntity();
    }

    update(delta, time) {
        this.world.execute(delta, time);
    }
}

const app = new App();
app.createPanel();
app.addController();


document.addEventListener('DOMContentLoaded', () => {
    try {
        document.getElementById('VRButton').click();
    } catch (error) {
        console.error('there is error while DOMContentLoaded', error);
    }
})

i tried for clip using this code before :slight_smile:

   const minHeight = this.scrollPlane.position.y + this.scrollPlane.geometry.parameters.height / 2;
    const maxHeight = minHeight - (this.contentGroup.children.length * 0.4);  // Adjust based on item height and number of items

    // Create clipping plane
    const clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), minHeight);

but fail idk why, can you help me?

The clipping planes only works with the built in materials.

Your shaderMaterials would have to be modified to account for the clipping planes.

A possibly simpler solution is to render all your panel stuff into a separate rendertarget of some fixed width/height, and use that as the planes material.map.

Another approach would be to use the depth buffer or stencil buffer to mask out your plane area, but that gets pretty complex.

ahh i see, so clipping planes only work to standardMaterial, etc not ctx .

so if i using normal plane from planeGeometry and have planes as its children, its better using shaderMaterial because we can using clip on it ? sorry if im wrong