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