Codrops 원본
Interactive 3D with Three.js BatchedMesh and WebGPURenderer
배경 · MIT
갤러리 안에서는 화면을 벗어나거나 움직임을 멈추면 예제도 닫힙니다. 다시 재생할 때는 처음부터 시작해요.
SOURCE FILES
원본 코드 읽기
수집한 원본 소스와 실행 안내를 함께 제공합니다.
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>,
)
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/>
BEHIND THE EXAMPLE
이 장면을 만드는 원리
한 블록의 아래·위 조각을 두 인덱스로 유지
입력·상태·출력·수명과 실제 결과를 명시합니다.
- 이 예제에서는
- initBlocks는 블록마다 아래·위 instance를 연속 추가하고 색을 i×2와 i×2+1에 설정합니다. 갱신도 같은 인덱스로 아래 높이를 늘리고 위 조각을 해당 높이에 배치합니다.
