Codrops 원본

Interactive 3D with Three.js BatchedMesh and WebGPURenderer

배경 · MIT

배경 더 보기
ORIGINAL PREVIEW
Interactive 3D with Three.js BatchedMesh and WebGPURenderer 정적 미리보기

갤러리 안에서는 화면을 벗어나거나 움직임을 멈추면 예제도 닫힙니다. 다시 재생할 때는 처음부터 시작해요.

큰 화면으로 보기 새 탭

SOURCE FILES

원본 코드 읽기

15개 파일

수집한 원본 소스와 실행 안내를 함께 제공합니다.

eslint.config.js
파일 저장

import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'

export default tseslint.config(
  { ignores: ['dist'] },
  {
    extends: [js.configs.recommended, ...tseslint.configs.recommended],
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
    },
    plugins: {
      'react-hooks': reactHooks,
      'react-refresh': reactRefresh,
    },
    rules: {
      ...reactHooks.configs.recommended.rules,
      'react-refresh/only-export-components': [
        'warn',
        { allowConstantExport: true },
      ],
    },
  },
)
index.html
파일 저장

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Three.js BatchedMesh and WebGPURenderer | Codrops</title>
    <meta name="description" content="Three.js demo using BatchedMesh for efficient mesh rendering and exploring the new post-processing pipeline with TSL." />
		<meta name="keywords" content="three.js, BatchedMesh, WebGPURenderer" />
		<meta name="author" content="Christophe Choffel for Codrops" />
		<link rel="shortcut icon" href="favicon.ico">
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
함께 쓰는 파일 13개 보기
src/App.css
파일 저장

*,
*::after,
*::before {
	box-sizing: border-box;
}

:root {
	font-size: 12px;
	--color-text: #111;
	--color-bg: #fff;
	--color-card: rgba(255, 255, 255, 0.8);
	--color-link: #0f0f0f;
	--color-link-hover: #008cff;
	--page-padding: 1.5rem;
	--content-padding: 4rem;
}



[data-theme="dark"] {
	--color-text: #ffffff;
	--color-bg: #000000;
	--color-card: rgba(0, 0, 0, 0.8);
	--color-link: #ffffff;
	--color-link-hover: #ff0080;
}




body {
	margin: 0;
	color: var(--color-text);
	background-color: var(--color-bg);
	font-family: ui-monospace, monospace;
	-webkit-font-smoothing: antialiased;
	-moz-osx-font-smoothing: grayscale;

	transition: color 0.3s;
}

/* Page Loader */
.js .loading::before,
.js .loading::after {
	content: '';
	position: fixed;
	z-index: 1000;
}

.js .loading::before {
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	background: var(--color-bg);
}

.js .loading::after {
	top: 50%;
	left: 50%;
	width: 60px;
	height: 60px;
	margin: -30px 0 0 -30px;
	border-radius: 50%;
	opacity: 0.4;
	background: var(--color-link);
	animation: loaderAnim 0.7s linear infinite alternate forwards;

}

@keyframes loaderAnim {
	to {
		opacity: 1;
		transform: scale3d(0.5, 0.5, 1);
	}
}

a {
	opacity: 0.7;
	text-decoration: none;
	color: var(--color-link);
	outline: none;
	cursor: pointer;

	transition: color 0.3s;
}

a:hover {
	opacity: 1;
	text-decoration: underline;
	color: var(--color-link-hover);
	outline: none;
}

/* Better focus styles from https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible */
a:focus {
	/* Provide a fallback style for browsers
	 that don't support :focus-visible */
	outline: none;
	background: lightgrey;
}

a:focus:not(:focus-visible) {
	/* Remove the focus indicator on mouse-focus for browsers
	 that do support :focus-visible */
	background: transparent;
}

a:focus-visible {
	/* Draw a very noticeable focus style for
	 keyboard-focus on browsers that do support
	 :focus-visible */
	outline: 2px solid red;
	background: transparent;
}

.unbutton {
	background: none;
	border: 0;
	padding: 0;
	margin: 0;
	font: inherit;
	cursor: pointer;
}

.unbutton:focus {
	outline: none;
}

.frame {
	padding: var(--page-padding);
	position: relative;
	display: grid;
	z-index: 1000;
	width: 100%;
	height: 100%;
	grid-row-gap: 1rem;
	grid-column-gap: 2rem;
	pointer-events: none;
	justify-items: start;
	grid-template-columns: auto auto;
	grid-template-areas: 'title' 'archive' 'back' 'github' 'sponsor' 'demos' 'tags';
}


.frame #cdawrap {
	justify-self: start;
}

.frame a {
	pointer-events: auto;
}

.frame__title {
	grid-area: title;
	font-size: inherit;
	margin: 0;
}

.frame__back {
	grid-area: back;
	justify-self: start;
}

.frame__archive {
	grid-area: archive;
	justify-self: start;
}

.frame__github {
	grid-area: github;
}

.frame__tags {
	grid-area: tags;
}

.frame__tags a {
	padding: .25rem;
}

.frame__demos {
	grid-area: demos;
	display: flex;
	gap: 1rem;
}

.content {
	padding: var(--page-padding);
	display: flex;
	flex-direction: column;
	width: 100vw;
	position: relative;
}

.demo__infos__container {
	margin-top: 3rem;
	display:block;
	position: fixed;
	z-index: 1000;
}

.demo__infos {
	display: flex;
	max-width: 50lh;
	font-size:small;
	width:auto;
	flex-direction: column;
	gap: 0.5rem;
	background-color: var(--color-card);
	box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
	border-radius: 12px;
	padding: 1rem;
	transition: 0.3s;
}

.demo__infos ul {
	margin:0.5rem;
	padding-left: 1.5rem;
}


@media screen and (min-width: 53em) {
	body {
		--page-padding: 2rem 3rem;
	}

	.frame {
		position: fixed;
		top: 0;
		left: 0;
		width: 100%;
		height: 100%;
		grid-template-columns: auto auto auto auto 1fr;
		grid-template-rows: auto auto;
		align-content: space-between;
		grid-template-areas: 'title back archive github sponsor' 'tags tags tags tags tags';
	}

	.frame #cdawrap {
		justify-self: end;
	}
}

#threecanvas {
	position: fixed;
	background-color:var(--color-bg);
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	transition: background-color 1.5s;
	
}

.theme-toggle {
	display: flex;
	flex-direction: row;
	flex-wrap: nowrap;
	align-items: center;
}

.switch {
	position: relative;
	display: inline-block;
	align-items: center;
	align-content: center;
	width: 60px;
	height: 34px;
}

.slider {
	position: absolute;
	cursor: pointer;
	top: 0;
	left: 0;
	right: 0;
	bottom: 0;
	background-color: var(--color-link-hover);
	-webkit-transition: .3s;
	transition: .3s;
	align-items: center;


}

.slider:before {
	position: absolute;
	content: "";
	height: 26px;
	width: 26px;
	left: 4px;
	bottom: 4px;
	background-color: white;
	-webkit-transition: .3s;
	transition: .3s ease;
}

input:checked+.slider {
	background-color: var(--color-link-hover);
}

input:focus+.slider {
	box-shadow: 0 0 1px #2196F3;
}

input:checked+.slider:before {
	-webkit-transform: translateX(26px);
	-ms-transform: translateX(26px);
	transform: translateX(26px);
}

.slider.round {
	border-radius: 34px;
}

.slider.round:before {
	border-radius: 50%;
}

.slider-label {
	display: inline-block;
	font-size: large;
	font-weight: bold;
	color: var(--color-text);
	padding-left: 0.5rem;
	transition: .3s ease;
}

.loader-container {
	display: flex;
	justify-content: center;
	align-items: center;
}

.loader {
    width: 36px;
    height: 36px;
    border: 5px solid var(--color-text);
    border-bottom-color: var(--color-link-hover);
    border-radius: 50%;
    display: inline-block;
    box-sizing: border-box;
	border-radius: 50%;
	animation: rotation 1s linear infinite;
    }

    @keyframes rotation {
    0% {
        transform: rotate(0deg);
    }
    100% {
        transform: rotate(360deg);
    }

}
src/App.tsx
파일 저장

import { useEffect, useState } from 'react'
import WebGPU from "three/examples/jsm/capabilities/WebGPU.js";

import './App.css'
import { Demo } from './Demo';
import ThreeCanvas from './components/threeCanvas';

function App() {

	const [isDemoReady, setIsDemoReady] = useState(false);
	const [isDarkTheme, setIsDarkTheme] = useState(false);
	const [isGPUAvailable, setIsGPUAvailable] = useState(WebGPU.isAvailable());


	useEffect(() => {

		document.body.classList.add('loading');

		const interval = setInterval(() => {
			if (Demo.instance != null && Demo.firstRenderDone) {
				setIsDemoReady(true);
				clearInterval(interval);
				document.body.classList.remove('loading');
			}
		}, 100);
	}, []);



	const toggleTheme = () => {
		setIsDarkTheme(prevTheme => !prevTheme);
		document.documentElement.setAttribute('data-theme', isDarkTheme ? 'light' : 'dark');

		Demo.setTheme(isDarkTheme ? 'light' : 'dark');

	};

	return (
		<>
			<header className="frame">
				<h1 className="frame__title">BatchedMesh & Post Processing by <a href="https://www.ulucode.com/" target="_blank">Christophe Choffel</a></h1>
				<a className="frame__back" href="https://tympanus.net/codrops/?p=81678">Article</a>
				<a className="frame__archive" href="https://tympanus.net/codrops/demos/">All demos</a>
				<a className="frame__github" href="https://github.com/ULuIQ12/codrops-batchedmesh">GitHub</a>
				<nav className="frame__tags">
					<a href="https://tympanus.net/codrops/demos/?tag=3d">#3d</a>
					<a href="https://tympanus.net/codrops/demos/?tag=three-js">#three.js</a>
					<a href="https://tympanus.net/codrops/demos/?tag=webgpu">#webgpu</a>
				</nav>
			</header>

			<div className="content">
				<div className='demo__infos__container'>
					{isGPUAvailable &&
						<div className='demo__infos'>
							{!isDemoReady &&
								<>
									<h1 className="frame__title">Please wait, loading & initializing ...</h1>
									<div className="loader-container">
										<span className="loader"></span>
									</div>
								</>
							}
							{isDemoReady &&
								<>
									<div>
										<h1 className="frame__title">Controls :</h1>
										<ul>
											<li>Click/Touch and drag to rotate the camera</li>
											<li>Scroll/Pinch to zoom in/out</li>
											<li>Right click/Two fingers drag to pan</li>
										</ul>
									</div>
									<div className='theme-toggle'>
										<label className='switch'>
											<input type='checkbox' onChange={toggleTheme} checked={isDarkTheme} />
											<span className='slider round'></span>
										</label>
										<span className='slider-label'>Switch to {!isDarkTheme ? 'dark mode' : 'light mode'}</span>
									</div>
								</>
							}
						</div>
					}

					{!isGPUAvailable &&
						<div className='demo__infos'>
							<h1 className="frame__title">WebGPU not available</h1>
							<p>WebGPU is not available on your device or browser. Please use a device or browser that supports WebGPU.</p>
						</div>
					}
				</div>
				<ThreeCanvas />

			</div>



		</>
	)
}

export default App
src/Demo.ts
파일 저장


import { Clock, PerspectiveCamera, Vector2, Scene, ACESFilmicToneMapping, Box2, MathUtils, BufferGeometry, PlaneGeometry, Mesh, Vector3, Color, EquirectangularReflectionMapping, BufferAttribute, BatchedMesh, Object3D, Plane, MeshStandardMaterial, MeshPhysicalMaterial, pass, PostProcessing, Renderer, fxaa, dof, ao, uniform, output, mrt, transformedNormalView, Raycaster, viewportUV, clamp, FloatType, MeshStandardNodeMaterial, MeshPhysicalNodeMaterial } from "three/webgpu";
import { OrbitControls, UltraHDRLoader } from "three/examples/jsm/Addons.js";
import WebGPU from "three/examples/jsm/capabilities/WebGPU.js";
import { ABlock } from "./lib/ABlock";
import { BlockGeometry } from "./lib/BlockGeometry";
import FastSimplexNoise from "@webvoxel/fast-simplex-noise";
import { Pointer } from "./lib/Pointer";
import { WebGPURenderer } from "three/webgpu";

export class Demo {
    static instance: Demo;
    static firstRenderDone: boolean = false;
    canvas: HTMLCanvasElement;
    renderer?: WebGPURenderer;
    camera: PerspectiveCamera = new PerspectiveCamera(20, 1, 0.1, 500);
    controls?: OrbitControls;
    post?: PostProcessing;
    scene: Scene = new Scene();
    pointerHandler?: Pointer;
    clock: Clock = new Clock(false);


    /**
     * Accessor from react to set the theme
     * @param theme "light" or "dark"
     */
    static setTheme(theme: string) {
        if (this.instance) {
            this.instance.setColorMode(theme);
        }
    }

    constructor(canvas: HTMLCanvasElement) {

        this.canvas = canvas;

        if (Demo.instance != null) {
            console.warn("Demo instance already exists");
            return null;
        }
        Demo.instance = this;
    }

    // start here
    async init() {
        if (WebGPU.isAvailable() === false) {
            //throw new Error('No WebGPU support');
            return;
        }

        this.renderer = new WebGPURenderer({ canvas: this.canvas, antialias: true });
        this.renderer.setPixelRatio(1);
        this.renderer.setSize(window.innerWidth, window.innerHeight);
        this.renderer.toneMapping = ACESFilmicToneMapping;
        this.renderer.toneMappingExposure = 0.9;
        
        window.addEventListener('resize', this.onResize.bind(this));

        this.initCamera();
        this.initPostProcessing();

        this.onResize(undefined);
        this.pointerHandler = new Pointer(this.renderer, this.camera, new Plane(new Vector3(0, 1, 0), 0));

        await BlockGeometry.init(); // loading block geomtries
        await this.initEnvironment(); // skybox and ground

        await this.initGrid(); // setup the random grid
        await this.initBlocks(); // create the mesh

        this.clock.start();
        
        this.renderer!.setAnimationLoop(this.animate.bind(this));
    }

    initCamera() {
        // setting the camera so that the scene is vaguely centered and fits the screen, at an angle where the light is gently grazing the objects
        const initCamAngle: number = -Math.PI * 2 / 3;
        const initCamDist: number = 100;
        const camOffsetToFit: Vector3 = new Vector3(Math.cos(initCamAngle), 0, Math.sin(initCamDist)).multiplyScalar(10);
        this.camera.position.set(Math.cos(initCamAngle) * initCamDist, 55, Math.sin(initCamAngle) * initCamDist).add(camOffsetToFit);
        this.camera.updateProjectionMatrix();

        this.controls = new OrbitControls(this.camera, this.canvas);
        this.controls.enableDamping = true;
        this.controls.dampingFactor = 0.1;
        this.controls.maxPolarAngle = Math.PI / 2 - Math.PI / 16;
        this.controls.minDistance = 20;
        this.controls.maxDistance = 250;
        this.controls.target.set(0, 0, 0).add(camOffsetToFit);
        this.controls.update();
    }

    effectController;
    initPostProcessing() {

        /*
        * post-processing set up with :
        * - a scene pass that outputs the color, normal and depth
        * - an ambient occlusion pass that uses the depth and normal to compute the AO
        * - a depth of field pass that uses the AO and the viewZ to compute the blur. Parameters are dynamic and updated in the animate loop
        * - an FXAA pass to antialias the final image
        */
        this.post = new PostProcessing(this.renderer as Renderer);

        const effectController = {
            focus: uniform(32.0),
            aperture: uniform(100),
            maxblur: uniform(0.02)
        };
        this.effectController = effectController;

        const scenePass = pass(this.scene, this.camera);
        scenePass.setMRT(mrt({
            output: output,
            normal: transformedNormalView
        }));

        const scenePassColor = scenePass.getTextureNode('output');
        const scenePassNormal = scenePass.getTextureNode('normal');
        const scenePassDepth = scenePass.getTextureNode('depth');

        const aoPass = ao(scenePassDepth, scenePassNormal, this.camera);
        aoPass.distanceExponent.value = 1;
        aoPass.distanceFallOff.value = .1;
        aoPass.radius.value = 1.0;
        aoPass.scale.value = 1.5;
        aoPass.thickness.value = 1;

        const blendPassAO = aoPass.getTextureNode().mul(scenePassColor);
        const scenePassViewZ = scenePass.getViewZNode();
        const dofPass = dof(blendPassAO, scenePassViewZ, effectController.focus, effectController.aperture.mul(0.00001), effectController.maxblur);
        const vignetteFactor = clamp(viewportUV.sub(0.5).length().mul(1.2), 0.0, 1.0).oneMinus().pow(0.5);
        this.post.outputNode = fxaa(dofPass.mul(vignetteFactor));
    }

    async initEnvironment() {

        const { scene } = this;

        // loads an ultra hdr skybox to get some nice natural tinting and a few reflections
        // skybox is https://polyhaven.com/a/rustig_koppie_puresky , converted to ultra hdr jpg with with https://gainmap-creator.monogrid.com/
        const texture = await new UltraHDRLoader().setDataType(FloatType).setPath('./assets/ultrahdr/').loadAsync('rustig_koppie_puresky_2k.jpg', (progress) => {
            console.log((progress.loaded / progress.total * 100) + '% skybox loaded');
        });
        texture.mapping = EquirectangularReflectionMapping;
        texture.needsUpdate = true;
        scene.background = texture;
        scene.environment = texture;

        const groundGeom: BufferGeometry = new PlaneGeometry(148, 148, 1, 1);
        groundGeom.rotateX(-Math.PI * 0.5);
        const groundMat: MeshStandardNodeMaterial = new MeshStandardNodeMaterial({ color: 0x333333 });
        const groundMesh: Mesh = new Mesh(groundGeom, groundMat);
        scene.add(groundMesh);
    }

    blocks: ABlock[] = [];
    gridZone: Box2 = new Box2(new Vector2(0, 0), new Vector2(148, 148));
    async initGrid() {
        const zone: Box2 = this.gridZone;
        const maxBlockSize: Vector2 = new Vector2(5, 5);
        maxBlockSize.x = MathUtils.randInt(1, 5);
        maxBlockSize.y = maxBlockSize.x;

        let px: number = 0;
        let py: number = 0;

        // Double array to store the occupation state of the grid. -1 means free, any other number is the block id
        const occupied: number[][] = Array.from({ length: this.gridZone.max.x }, () => 
            Array(this.gridZone.max.y).fill(-1)
        );

        const squareChance: number = 0.5;
        // fills the whole grid with blocks of random sizes and colors
        while (py < zone.max.y) {

            while (px < zone.max.x) {
                let maxW: number = 0;
                const end: number = Math.min(zone.max.x, px + maxBlockSize.x);
                // check for the maximum width available
                for (let i: number = px; i < end; i++) {
                    if (occupied[i][py] != -1) {
                        break;
                    }
                    maxW++;
                }
                if (maxW == 0) {
                    px++;
                    continue;
                }

                // create a block entity with random paramaters
                const block: ABlock = new ABlock();                
                const isSquare: boolean = MathUtils.randFloat(0, 1) < squareChance;
                block.typeTop = isSquare ? MathUtils.randInt(0, 5) : 0; // only plain rectangles can be ... rectangles
                block.typeBottom = BlockGeometry.topToBottom.get(block.typeTop)!;
                block.setTopColorIndex(MathUtils.randInt(0, ABlock.LIGHT_COLORS.length - 1));
                // define size and position
                const sx: number = MathUtils.randInt(1, maxW);
                const sy: number = isSquare ? sx : MathUtils.randInt(1, maxBlockSize.y);
                block.box.min.set(px, py);
                block.box.max.set(px + sx, py + sy);
                block.height = 1;
                block.rotation = isSquare ? MathUtils.randInt(0, 4) * Math.PI / 2 : MathUtils.randInt(0, 2) * Math.PI;

                this.blocks.push(block);

                // fill occupied grid
                const endX: number = Math.min(zone.max.x, px + sx);
                const endY: number = Math.min(zone.max.y, py + sy);                
                for (let i: number = px; i < endX; i++) {
                    for (let j: number = py; j < endY; j++) {
                        occupied[i][j] = block.id;
                    }
                }
                px += sx;
            }
            py++;
            px = 0;

            // max sizes have a chance to be randomized after every line, to create some structure
            if (MathUtils.randFloat(0, 1) > 0.8) {
                maxBlockSize.x = (MathUtils.randFloat(0, 1) > 0.5) ? 2 : 5;
                maxBlockSize.y = (MathUtils.randFloat(0, 1) > 0.5) ? 2 : 5;
            }

        }
    }

    
    blockMesh?: BatchedMesh;
    async initBlocks() {

        const matParams = {
            roughness: 0.1,
            metalness: 0.0,
        }
        const mat: MeshPhysicalNodeMaterial = new MeshPhysicalNodeMaterial(matParams);
        mat.envMapIntensity = 0.25;

        // evaluate the maximum vertex and index count of the geometries
        const geoms: BufferGeometry[] = []
        for (let i: number = 0; i < BlockGeometry.geoms.length; i++) {
            geoms.push(BlockGeometry.geoms[i]);
        }

        const vCounts: number[] = [];
        const iCounts: number[] = [];
        let totalV: number = 0;
        let totalI: number = 0;
        // get the vertex and index counts for each geometry
        for (let i: number = 0; i < geoms.length; i++) {
            const g: BufferGeometry = geoms[i];
            vCounts.push(g.attributes.position.count);
            iCounts.push((g.index as BufferAttribute).count);
        }

        // calculate the total vertex and index count
        for( let i:number = 0 ;i< this.blocks.length; i++) {
            totalV += vCounts[this.blocks[i].typeBottom];
            totalV += vCounts[this.blocks[i].typeTop];
            totalI += iCounts[this.blocks[i].typeBottom];
            totalI += iCounts[this.blocks[i].typeTop];
        }

        // create the mesh
        const maxBlocks: number = this.blocks.length * 2; // top and bottom
        this.blockMesh = new BatchedMesh(maxBlocks, totalV, totalI, mat);
        this.blockMesh.sortObjects = false; // depends on your use case, here I've had better performances without sorting
        this.blockMesh.position.x = -this.gridZone.max.x * 0.5;
        this.blockMesh.position.z = -this.gridZone.max.y * 0.5;
        this.scene.add(this.blockMesh);

        // setup the geometries and instances
        const geomIds: number[] = [];
        for (let i: number = 0; i < geoms.length; i++) {
            // all our geometries
            geomIds.push(this.blockMesh.addGeometry(geoms[i]));
        }

        // one top and one bottom for each block
        for (let i: number = 0; i < this.blocks.length; i++) {
            const block: ABlock = this.blocks[i];
            this.blockMesh.addInstance(geomIds[block.typeBottom]);
            this.blockMesh.addInstance(geomIds[block.typeTop]);
            this.blockMesh.setColorAt(i * 2, block.baseColor);
            this.blockMesh.setColorAt(i * 2 + 1, block.topColor);
        }
    }



    onResize(e?: Event, toSize?: Vector2) {
        const { camera, renderer } = this;
        const size: Vector2 = new Vector2(window.innerWidth, window.innerHeight);
        if (toSize) size.copy(toSize);

        const ww: number = window.innerWidth;
        const wh: number = window.innerHeight;
        const aspect: number = ww / wh;

        camera.aspect = aspect;
        camera.updateProjectionMatrix();

        renderer!.setPixelRatio(1);
        renderer!.setSize(size.x, size.y);
        renderer!.domElement.style.width = `${size.x}px`;
        renderer!.domElement.style.height = `${size.y}px`;
    }

    elapsed: number = 0;
    async animate() {

        const { controls, clock, post } = this;

        const dt: number = clock.getDelta();
        this.elapsed = clock.getElapsedTime();

        this.updateBlocks(dt, this.elapsed);
        this.updateCamera(dt);
        controls!.update(dt);
        await post!.renderAsync();

        if( !Demo.firstRenderDone) { 
            Demo.firstRenderDone = true;
        }
    }

    themeTransitionStart: number = -10;
    themeTransitionDuration: number = 5;
    dummy: Object3D = new Object3D();
    tempCol: Color = new Color();
    blockSize: Vector2 = new Vector2(1, 1);
    blockCenter: Vector2 = new Vector2();
    heightNoise: FastSimplexNoise = new FastSimplexNoise({ frequency: 0.05, octaves: 2, min: 0, max: 1, persistence: 0.5 });
    wavesAmplitude: number = 8;
    updateBlocks(dt: number, elapsed: number) {

        const { camera, raycaster, dummy, blockMesh, blocks, pointerHandler, groundRayPlane, heightNoise, wavesAmplitude, gridZone, blockSize, blockCenter, tempCol, cubicPulse } = this;
        if (blockMesh == null) return;

        // calculate the transition time
        const transitionTime: number = MathUtils.clamp((elapsed - this.themeTransitionStart) / this.themeTransitionDuration, 0, 1);
        const echoTime: number = MathUtils.clamp((elapsed - this.themeTransitionStart - 0.3) / this.themeTransitionDuration, 0, 1); // a bit of delay for the second ripple

        let targetHeight: number = 0;
        let baseI: number = 0;
        let topI: number = 0;

        // gets a raycast camera->ground so that the rippling effect is centered on screen
        const camDir: Vector3 = this.camDir;
        if( transitionTime < 1 ) {            
            camera.getWorldDirection(camDir);
            groundRayPlane.constant = camera.position.y * .1;
            const temp = new Vector3().copy(camera.position);
            temp.y -= 10;
            raycaster.set(temp, camDir.normalize());
            raycaster.ray.intersectPlane(this.groundRayPlane, camDir);
        }

        let block: ABlock;
        let dx: number = 0;
        let dz: number = 0;
        let cDist: number = 0;
        let cFactor: number = 0;
        let noise: number = 0;
        let from0: number = 0;
        let ripple: number = 0;
        let echoRipple: number = 0;

        // update the blocks
        for (let i: number = 0; i < blocks.length; i++) {
            block = blocks[i];
            // our indices for this block in the batched mesh, a top and a bottom
            baseI = i * 2;
            topI = i * 2 + 1;

            block.box.getSize(blockSize);
            block.box.getCenter(blockCenter);

            // get block offset from pointer
            dx = (blockCenter.x - pointerHandler!.scenePointer.x + blockMesh.position.x);
            dz = (blockCenter.y - pointerHandler!.scenePointer.z + blockMesh.position.z);

            // calculate the height of the block wrt the distance from the pointer
            cDist = Math.sqrt(dx * dx + dz * dz);
            cFactor = MathUtils.clamp(1 - cDist * 0.1, 0, 1);
            noise = heightNoise.scaled2D(block.box.min.x * .1, block.box.min.y + elapsed * 5);
            targetHeight = noise * wavesAmplitude + 1 + cFactor * 5;

            if( transitionTime < 1 ) {
                // calculate the ripple effect based on the distance from the center of the screen
                from0 = MathUtils.clamp((Math.sqrt(
                    Math.pow((blockCenter.x - camDir.x) - gridZone.max.x * 0.5, 2) +
                    Math.pow((blockCenter.y - camDir.z) - gridZone.max.y * 0.5, 2)) / gridZone.max.x * .5), 0, 1);
                ripple = cubicPulse(Math.pow(this.gain(transitionTime, 1.1), 0.9), 0.05, from0);
                echoRipple = cubicPulse(this.gain(echoTime, 1.3), 0.025, from0);
                targetHeight += (ripple) * 10 + (echoRipple) * 5;
            }

            // lerp the height of the block
            if (targetHeight >= block.height) { // raises slower than when going down
                block.height = MathUtils.lerp(block.height, targetHeight, .1);
            } else {
                block.height = MathUtils.lerp(block.height, targetHeight, .3);
            }

            // update the block mesh with matrices and colors
            // first the bottom, color changes on the first ripple
            dummy.rotation.y = block.rotation;
            dummy.position.set(blockCenter.x, 0, blockCenter.y);
            dummy.scale.set(blockSize.x, block.height, blockSize.y);
            dummy.updateMatrix();
            blockMesh.setMatrixAt(baseI, dummy.matrix);
            blockMesh.getColorAt(baseI, tempCol);
            tempCol.lerp(this.baseTargetColor, ripple);
            blockMesh.setColorAt(baseI, tempCol);

            // then the top, color changes on the second ripple
            dummy.position.y += block.height;
            dummy.scale.set(blockSize.x, 1, blockSize.y);
            dummy.updateMatrix();
            blockMesh.setMatrixAt(topI, dummy.matrix);
            blockMesh.getColorAt(topI, tempCol);
            tempCol.lerp(this.topTargetColors[block.topColorIndex], echoRipple);
            blockMesh.setColorAt(topI, tempCol);

        }

    }

    raycaster: Raycaster = new Raycaster();
    groundRayPlane: Plane = new Plane(new Vector3(0, 1, 0), 0);
    camDist: number = 50;
    camdistVel: number = 0.0;
    camK: number = 0.05;
    camDir: Vector3 = new Vector3();

    // an approximation of an auto-focus effect
    updateCamera(dt: number) {
        const camDir: Vector3 = this.camDir;
        this.camera.getWorldDirection(camDir);
        this.groundRayPlane.constant = this.camera.position.y * .1;
        this.raycaster.set(this.camera.position, camDir.normalize());
        this.raycaster.ray.intersectPlane(this.groundRayPlane, camDir);
        const dist: number = camDir.sub(this.camera.position).length();

        const targetDist: number = dist;
        const distVel: number = (targetDist - this.camDist) / dt;
        this.camdistVel = MathUtils.lerp(this.camdistVel, distVel, this.camK);
        this.camDist += this.camdistVel * dt;

        this.effectController.focus.value = MathUtils.lerp(this.effectController.focus.value, this.camDist * .85, .05);
        this.effectController.aperture.value = MathUtils.lerp(this.effectController.aperture.value, 100 - this.camDist * .5, .025);
    }

    colorsModes: string[] = ['dark', 'light'];
    baseTargetColor: Color = new Color(0x999999);
    topTargetColors: Color[] = ABlock.LIGHT_COLORS;
    colorMode: string = this.colorsModes[1];

    setColorMode(mode: string) {

        this.colorMode = mode;
        this.themeTransitionStart = this.elapsed;
        if (this.colorMode == 'dark') {
            this.baseTargetColor.copy(ABlock.DARK_BASE_COLOR);
            this.topTargetColors = ABlock.DARK_COLORS;

        } else {
            this.baseTargetColor.copy(ABlock.LIGHT_BASE_COLOR);
            this.topTargetColors = ABlock.LIGHT_COLORS;
        }


    }

    /// Inigo Quilez remaping functions https://iquilezles.org/articles/functions/
    pcurve(x: number, a: number, b: number): number {
        const k: number = Math.pow(a + b, a + b) / (Math.pow(a, a) * Math.pow(b, b));
        return k * Math.pow(x, a) * Math.pow(1.0 - x, b);
    }

    gain(x: number, k: number): number {
        const a: number = 0.5 * Math.pow(2.0 * ((x < 0.5) ? x : 1.0 - x), k);
        return (x < 0.5) ? a : 1.0 - a;
    }

    cubicPulse(c: number, w: number, x: number): number {
        let x2 = Math.abs(x - c);
        if (x2 > w) return 0.0;
        x2 /= w;
        return 1.0 - x2 * x2 * (3.0 - 2.0 * x2);
    }
}
src/components/threeCanvas.tsx
파일 저장

import { useEffect, useRef } from "react";
import { Demo } from "../Demo";

export default function ThreeCanvas() {
    const canvasRef = useRef<HTMLCanvasElement>(null);

    useEffect(() => {
        const canvas = canvasRef.current;
        if (canvas == null) {
            throw new Error('Canvas not found');
        }
        if( Demo.instance != null) {
            console.warn("Demo instance already exists : aborting");
            return;
        }
        const demo: Demo = new Demo(canvas);

        (async () => {
            await demo.init();
        })();

    }, []);

    
    useEffect(() => {
        const canvas: HTMLCanvasElement | null = canvasRef.current;

        const resizeCanvas = () => {
            if (canvas == null) throw new Error('Canvas not found');

            canvas.width = window.innerWidth;
            canvas.height = window.innerHeight;
            canvas.style.width = window.innerWidth + 'px';
            canvas.style.height = window.innerHeight + 'px';
        }
        window.addEventListener('resize', resizeCanvas);

        resizeCanvas();

        return () => {
            window.removeEventListener('resize', resizeCanvas);
        }
    }, []);

    return (
        <canvas ref={canvasRef} id="threecanvas"></canvas>
    );
}
src/lib/ABlock.ts
파일 저장

import { Box2, Color } from "three/webgpu";


/**
 * ABlock class
 * contains infos about a block, that is one top and one bottom mesh
 */
export class ABlock {

    static LIGHT_COLORS: Color[] = [
        new Color( 0xffffff ), 
        new Color( 0xcccccc ), 
        new Color( 0xaaaaaa ), 
        new Color( 0x999999 ),
        new Color( 0x086ff0 ),
    ];

    static DARK_COLORS: Color[] = [
        new Color( 0x101010 ), 
        new Color( 0x181818 ), 
        new Color( 0x202020 ), 
        new Color( 0x282828 ),
        new Color( 0xbe185d ),
    ];

    static ID:number = 0;
    static LIGHT_BASE_COLOR: Color = new Color( 0x999999 );
    static DARK_BASE_COLOR: Color = new Color( 0x000000 );
    
    id:number = ABlock.ID++;
    typeBottom:number = 0;
    typeTop:number = 0;
    box:Box2 = new Box2();
    height:number = 1;
    rotation:number = 0;
    topColorIndex:number = 0;

    topColor:Color = ABlock.LIGHT_COLORS[ this.topColorIndex ];
    baseColor:Color = ABlock.LIGHT_BASE_COLOR;

    setTopColorIndex( index:number ) {
        this.topColorIndex = index;
        this.topColor = ABlock.LIGHT_COLORS[ this.topColorIndex ];
    }
}
src/lib/BlockGeometry.ts
파일 저장

import { GLTF, GLTFLoader } from "three/examples/jsm/Addons.js";
import { BufferGeometry, Mesh } from "three/webgpu";

export class BlockGeometry {

    static geoms: BufferGeometry[] = [];

    static async init() {
        await this.loadGeometries();
    }

    static topToBottom: Map<number, number> = new Map([
        [0, 6],
        [1, 7],
        [2, 8],
        [3, 6],
        [4, 6],
        [5, 6],
    ]);

    /**
     * Load the block geometries from the gltf file
     * There's 3 base blocks and 6 top blocks
     * The above map is used to map the top blocks to the bottom blocks in the order they are stored in the "geoms" array
     */
    static async loadGeometries() {
        // a few simple models, find the blender file in /assetSrc/
        const file: string = "./assets/models/blocks.glb";
        const loader: GLTFLoader = new GLTFLoader();
        const gltf: GLTF = await loader.loadAsync(file, (progress) => {
            console.log((progress.loaded / progress.total * 100) + '% blocks loaded');
        });
        //console.log(gltf);

        const bottomBlock: BufferGeometry = this.findGeometry(gltf, 'Square_Base');
        const bottomQuart: BufferGeometry = this.findGeometry(gltf, 'Quart_Base');
        const bottomHole: BufferGeometry = this.findGeometry(gltf, 'Hole_Base');

        const topSquare: BufferGeometry = this.findGeometry(gltf, 'Square_Top');
        const topQuart: BufferGeometry = this.findGeometry(gltf, 'Quart_Top');
        const topHole: BufferGeometry = this.findGeometry(gltf, 'Hole_Top');
        const topPeg: BufferGeometry = this.findGeometry(gltf, 'Peg_Top');
        const topDivot: BufferGeometry = this.findGeometry(gltf, 'Divot_Top');
        const topCross: BufferGeometry = this.findGeometry(gltf, 'Cross_Top');

        this.geoms.push(topSquare);
        this.geoms.push(topQuart);
        this.geoms.push(topHole);
        this.geoms.push(topPeg);
        this.geoms.push(topDivot);
        this.geoms.push(topCross);

        this.geoms.push(bottomBlock);
        this.geoms.push(bottomQuart);
        this.geoms.push(bottomHole);
    }

    static findGeometry(gltf: GLTF, name: string): BufferGeometry {
        return (gltf.scene.children.find((child) => child.name === name) as Mesh).geometry;
    }
}
src/lib/Pointer.ts
파일 저장

import { WebGLRenderer } from "three";
import { Camera, Plane, Raycaster, Vector2, Vector3 } from "three/webgpu";
import { uniform, WebGPURenderer } from "three/webgpu";


/**
 * Helper class to handle pointer position and "down" with output exposed in vector3 and uniforms
 */
export class Pointer {

    camera:Camera;
    renderer:WebGPURenderer | WebGLRenderer;
    rayCaster: Raycaster = new Raycaster();
    initPlane: Plane = new Plane(new Vector3(0, 0, 1));
    iPlane: Plane = new Plane(new Vector3(0, 0, 1));
    clientPointer: Vector2 = new Vector2();
    pointer: Vector2 = new Vector2();
    scenePointer: Vector3 = new Vector3();
    pointerDown: boolean = false;
    uPointerDown = uniform(0);
    uPointer = uniform(new Vector3());

    constructor(renderer:WebGPURenderer | WebGLRenderer, camera: Camera, plane:Plane) {

        this.camera = camera;
        this.renderer = renderer;
        this.initPlane = plane;
        this.iPlane = plane.clone();
        renderer.domElement.addEventListener("pointerdown", this.onPointerDown.bind(this));
        renderer.domElement.addEventListener("pointerup", this.onPointerUp.bind(this));
        window.addEventListener("pointermove", this.onPointerMove.bind(this));
 
    }

    onPointerDown(e: PointerEvent): void {
        if (e.pointerType !== 'mouse' || e.button === 0) {
            this.pointerDown = true;
            this.uPointerDown.value = 1;
        }
        this.clientPointer.set(e.clientX, e.clientY);
        this.updateScreenPointer(e);
    }
    onPointerUp(e: PointerEvent): void {
        this.clientPointer.set(e.clientX, e.clientY);
        this.updateScreenPointer(e);
        this.pointerDown = false;
        this.uPointerDown.value = 0;

    }
    onPointerMove(e: PointerEvent): void {
        this.clientPointer.set(e.clientX, e.clientY);
        this.updateScreenPointer(e);
    }

    updateScreenPointer(e?: PointerEvent): void {
        if( e == null || e == undefined) {
            e = {clientX:this.clientPointer.x, clientY:this.clientPointer.y} as PointerEvent;
        }
        this.pointer.set(
            (e.clientX / window.innerWidth) * 2 - 1,
            - (e.clientY / window.innerHeight) * 2 + 1
        );
        this.rayCaster.setFromCamera(this.pointer, this.camera);
        this.rayCaster.ray.intersectPlane(this.iPlane, this.scenePointer);
        this.uPointer.value.x = this.scenePointer.x;
        this.uPointer.value.y = this.scenePointer.y;
        this.uPointer.value.z = this.scenePointer.z;
        //console.log( this.scenePointer );
    }

    update(dt: number, elapsed: number): void {
        this.iPlane.normal.copy(this.initPlane.normal).applyEuler(this.camera.rotation);
		this.updateScreenPointer();
    }
}
src/main.tsx
파일 저장

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>    
    <App />
  </StrictMode>,
)
src/vite-env.d.ts
파일 저장

/// <reference types="vite/client" />
vite.config.ts
파일 저장

import { defineConfig, PluginOption } from 'vite'
import react from '@vitejs/plugin-react'
import topLevelAwait from "vite-plugin-top-level-await";

const fullReloadAlways: PluginOption = {
  name: 'full-reload-always',
  handleHotUpdate({ server }) {
    server.ws.send({ type: "full-reload" })
    return []
  },
} as PluginOption


// https://vitejs.dev/config/
export default defineConfig({
  root: '',
  base: './',
  plugins: [
    react(),
    topLevelAwait(),
    fullReloadAlways
  ],
})
Original author attribution실행 안내·자료
파일 저장

Interactive 3D with Three.js BatchedMesh and WebGPURenderer
Original author: Christophe Choffel
Published by Codrops. Copyright (c) 2026 Codrops.
License: MIT, pursuant to the publisher's downloadable-demo grant.
Keep CODROPS-MIT-2026.txt and the original project notices with copies.
Third-party media exceptions and credits are recorded separately in the source inventory.
Media credits and license evidence실행 안내·자료
파일 저장

README.md
## Acknowledgements
- Skybox is https://polyhaven.com/a/rustig_koppie_puresky 


## License 
This project is licensed under the MIT License.
Bundled dependency licenses실행 안내·자료
파일 저장

react@18.3.1 — LICENSE
MIT License

Copyright (c) Facebook, Inc. and its affiliates.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


scheduler@0.23.2 — LICENSE
MIT License

Copyright (c) Facebook, Inc. and its affiliates.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


react-dom@18.3.1 — LICENSE
MIT License

Copyright (c) Facebook, Inc. and its affiliates.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


three@0.169.0 — LICENSE
The MIT License

Copyright © 2010-2024 three.js authors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.


@webvoxel/fast-simplex-noise@0.0.1-a2 — LICENSE
This is free and unencumbered software released into the public domain.

Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.

In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

For more information, please refer to <http://unlicense.org/>