Codrops 원본
Building an Endless Interactive Glass Xylophone with Three.js
배경 · MIT
갤러리 안에서는 화면을 벗어나거나 움직임을 멈추면 예제도 닫힙니다. 다시 재생할 때는 처음부터 시작해요.
SOURCE FILES
원본 코드 읽기
수집한 원본 소스와 실행 안내를 함께 제공합니다.
css/base.css
*,
*::after,
*::before {
box-sizing: border-box;
}
:root {
font-size: 12px;
--color-text: #000;
--color-bg: #fff;
--color-link: #000;
--color-link-hover: #000;
--page-padding: 1.5rem;
}
body {
margin: 0;
color: var(--color-text);
background-color: var(--color-bg);
/* the demo owns the vertical drag (it scrolls the helix), so suppress native scroll/overscroll */
touch-action: none;
overscroll-behavior: none;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (scripting: enabled) {
.loading {
&::before,
&::after {
content: "";
position: fixed;
z-index: 10000;
}
&::before {
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--color-bg);
}
&::after {
top: 50%;
left: 50%;
width: 100px;
height: 1px;
margin: 0 0 0 -50px;
background: var(--color-link);
animation: loaderAnim 1.5s ease-in-out infinite alternate forwards;
}
}
}
@keyframes loaderAnim {
0% {
transform: scaleX(0);
transform-origin: 0% 50%;
}
50% {
transform: scaleX(1);
transform-origin: 0% 50%;
}
50.1% {
transform: scaleX(1);
transform-origin: 100% 50%;
}
100% {
transform: scaleX(0);
transform-origin: 100% 50%;
}
}
a {
text-decoration: none;
color: var(--color-link);
outline: none;
cursor: pointer;
&:hover {
text-decoration: underline;
color: var(--color-link-hover);
}
&:focus {
outline: none;
background: lightgrey;
&:not(:focus-visible) {
background: transparent;
}
&:focus-visible {
outline: 2px solid red;
background: transparent;
}
}
}
.frame {
padding: var(--page-padding);
display: grid;
z-index: 1000;
position: relative;
gap: 1rem;
pointer-events: none;
justify-items: start;
grid-template-columns: 100%;
grid-template-areas:
"title"
"links"
"demos"
"tags"
"sponsor";
#cdawrap {
justify-self: start;
grid-area: sponsor;
}
a,
button {
pointer-events: auto;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
.frame__title {
grid-area: title;
font-size: inherit;
margin: 0;
}
.frame__tags {
grid-area: tags;
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.frame__links,
.frame__demos {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.frame__links {
grid-area: links;
}
.frame__demos {
grid-area: demos;
}
@media screen and (min-width: 53em) {
height: 100%;
position: fixed;
top: 0;
left: 0;
width: 100%;
grid-template-columns: auto auto 1fr;
grid-template-rows: auto auto;
align-content: space-between;
grid-template-areas:
"title links demos"
"tags tags sponsor";
.frame__tags {
align-self: end;
}
.frame__demos,
#cdawrap {
justify-self: end;
text-align: right;
max-width: 300px;
}
}
}
/* -------------------------------------------------------------------------- */
/* custom */
/* -------------------------------------------------------------------------- */
#sizer {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
pointer-events: none;
user-select: none;
visibility: hidden;
opacity: 0;
}
html,
body {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
}
body {
position: fixed;
inset: 0;
touch-action: none;
}
.frame {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
/* -------------------------------------------------------------------------- */
/* sound */
/* -------------------------------------------------------------------------- */
/* Sits in the frame's `demos` area rather than pinned to a corner — the bottom-right corner
belongs to #cdawrap, and a fixed button there overlaps the sponsor once it is injected. */
.sound-toggle {
grid-area: demos;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
cursor: pointer;
&:hover .sound-toggle__icon,
&:focus-visible .sound-toggle__icon {
opacity: 1;
}
@media screen and (min-width: 53em) {
justify-self: end;
}
}
.sound-toggle__icon {
width: 1.25rem;
height: 1.25rem;
opacity: 0.7;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
transition: opacity 0.2s;
/* the speaker body reads as a solid shape; only the waves and slash are strokes */
path:first-child {
fill: currentColor;
stroke-linejoin: round;
}
}
/* waves when audible, slash when muted */
.sound-toggle__slash {
display: none;
}
.sound-toggle.is-muted {
.sound-toggle__wave {
display: none;
}
.sound-toggle__slash {
display: block;
}
}
eslint.config.js
import js from "@eslint/js"
import parserTs from "@typescript-eslint/parser"
import globals from "globals"
import tseslint from "typescript-eslint"
export default [
js.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ["**/node_modules/**", "**/dist/**", "_test/**", "*.config.js"],
},
{
files: ["**/*.{js,ts}"],
languageOptions: {
parser: parserTs,
globals: globals.browser,
},
rules: {
"no-unused-vars": "off",
// `_`-prefixed params are the convention for required-but-unused signature slots,
// e.g. the Pass.render(renderer, inputBuffer, ...) overrides.
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
},
},
]
함께 쓰는 파일 39개 보기
src/index.html
<!doctype html>
<html lang="en" class="no-js">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WebGL Xylophone | Codrops</title>
<meta
name="description"
content="An interactive WebGL xylophone: a scrolling helix of frosted glass bars that ring as you sweep the cursor across them."
/>
<meta name="keywords" content="webgl, three.js, instancing, fluid simulation, web audio, glass, refraction" />
<link rel="icon" type="image/svg+xml" href="https://tympanus.net/favicon/favicon.svg" />
<link rel="shortcut icon" href="https://tympanus.net/favicon/favicon.ico" />
<script>
document.documentElement.className = "js"
</script>
<!-- <script src="//tympanus.net/codrops/adpacks/analytics.js"></script> -->
</head>
<!-- `loading` draws the overlay + bar loader in base.css; main.ts drops it once the bars exist -->
<body class="demo-1 loading">
<noscript>
<div style="padding: 2rem; text-align: center; color: #fff; background: #000">
<h1>JavaScript Required</h1>
<p>This demo requires JavaScript and WebGL support.</p>
<p>Please enable JavaScript and use a modern browser.</p>
</div>
</noscript>
<!-- tracks the layout viewport; steadier than innerHeight while mobile browser chrome moves -->
<div id="sizer" aria-hidden="true"></div>
<main>
<header class="frame">
<h1 class="frame__title">WebGL Xylophone</h1>
<nav class="frame__links">
<a href="https://tympanus.net/codrops/?p=118008">Article</a>
<a href="https://tympanus.net/codrops/hub/">All demos</a>
<a href="https://github.com/Sujenphea/xylophone">GitHub</a>
</nav>
<button id="sound-toggle" class="sound-toggle" type="button" aria-pressed="true">
<svg class="sound-toggle__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M4 9v6h4l5 4V5L8 9H4z" />
<path class="sound-toggle__wave" d="M16.5 8.8a4.5 4.5 0 0 1 0 6.4" />
<path class="sound-toggle__wave" d="M19.2 6.1a8.5 8.5 0 0 1 0 11.8" />
<path class="sound-toggle__slash" d="M3.5 3.5l17 17" />
</svg>
<span class="sound-toggle__text">Sound on</span>
</button>
<nav class="frame__tags">
<a href="https://tympanus.net/codrops/hub/tag/3D/">#3D</a>
<a href="https://tympanus.net/codrops/hub/tag/three-js/">#three.js</a>
<a href="https://tympanus.net/codrops/hub/tag/webgl/">#webgl</a>
</nav>
</header>
</main>
<!-- <script src="https://tympanus.net/codrops/adpacks/cda_sponsor.js"></script> -->
<script type="module" src="./js/main.ts"></script>
</body>
</html>
src/js/common/FBO.ts
import { FloatType, RenderTargetOptions, RGBAFormat, WebGLRenderTarget } from "three"
import { FBOHelper } from "./FBOHelper"
/**
* Ping-pong render target pair. GPGPU passes can't read and write the same texture, so
* each step reads `read` and renders into `write`, then `swap()`s them.
*/
export class FBO {
private fbo1: WebGLRenderTarget
private fbo2: WebGLRenderTarget
constructor(width: number, height: number, options?: RenderTargetOptions) {
const config: RenderTargetOptions = {
format: RGBAFormat,
type: FloatType,
generateMipmaps: false,
depthBuffer: false,
...options,
}
this.fbo1 = FBOHelper.createRenderTarget(width, height, config)
this.fbo2 = FBOHelper.createRenderTarget(width, height, config)
}
get read() {
return this.fbo1
}
get write() {
return this.fbo2
}
swap() {
const temp = this.fbo1
this.fbo1 = this.fbo2
this.fbo2 = temp
}
/** Disposing a render target releases its texture too — don't dispose the texture separately. */
dispose() {
this.fbo1.dispose()
this.fbo2.dispose()
}
}
src/js/common/FBOHelper.ts
import {
ClampToEdgeWrapping,
LinearFilter,
Material,
RawShaderMaterial,
RenderTargetOptions,
SRGBColorSpace,
ShaderMaterialParameters,
UnsignedByteType,
WebGLRenderTarget,
} from "three"
import { FullScreenQuad } from "three/examples/jsm/postprocessing/Pass.js"
import blitFrag from "../../shaders/postprocessing/blitFrag.glsl?raw"
import blitVert from "../../shaders/postprocessing/blitVert.glsl?raw"
import { Properties } from "../utils/properties"
/* -------------------------------------------------------------------------- */
/* main */
/* -------------------------------------------------------------------------- */
export class FBOHelper {
static fsQuad: FullScreenQuad
// prescision
static precisionPrefix = ""
static init() {
// fullscreen quad used to drive every GPGPU pass
this.fsQuad = new FullScreenQuad()
// get precision
this.precisionPrefix = `precision ${Properties.gl?.capabilities.precision} float;\n`
}
static render(material: Material, renderTarget: WebGLRenderTarget | null) {
if (!Properties.gl || !this.fsQuad) return
this.fsQuad.material = material
Properties.gl.setRenderTarget(renderTarget)
this.fsQuad.render(Properties.gl)
Properties.gl.setRenderTarget(null)
}
/* ----------------------------- render targets ----------------------------- */
static createRenderTarget(width: number, height: number, options?: RenderTargetOptions) {
return new WebGLRenderTarget(width, height, {
wrapS: ClampToEdgeWrapping,
wrapT: ClampToEdgeWrapping,
magFilter: LinearFilter,
minFilter: LinearFilter,
type: UnsignedByteType,
anisotropy: 0,
colorSpace: SRGBColorSpace,
stencilBuffer: false,
...options,
})
}
/* -------------------------------- materials ------------------------------- */
static createRawShaderMaterial(
options: ShaderMaterialParameters & { vertexShaderPrefix?: string; fragmentShaderPrefix?: string }
) {
const _options = {
vertexShader: blitVert,
fragmentShader: blitFrag,
...options,
}
_options.vertexShader =
(_options.vertexShaderPrefix !== undefined ? _options.vertexShaderPrefix : this.precisionPrefix) +
_options.vertexShader
_options.fragmentShader =
(_options.fragmentShaderPrefix !== undefined ? _options.fragmentShaderPrefix : this.precisionPrefix) +
_options.fragmentShader
delete _options.vertexShaderPrefix
delete _options.fragmentShaderPrefix
return new RawShaderMaterial(_options)
}
}
src/js/components/FluidSim.ts
// Fluid solver adapted from Pavel Dobryakov's WebGL-Fluid-Simulation (MIT License).
// https://github.com/PavelDoGreat/WebGL-Fluid-Simulation
import {
FloatType,
HalfFloatType,
LinearFilter,
NearestFilter,
RawShaderMaterial,
type RenderTargetOptions,
RGBAFormat,
type ShaderMaterialParameters,
Texture,
Vector2,
Vector3,
WebGLRenderTarget,
} from "three"
import fluidAdvectionFrag from "../../shaders/fluid/fluidAdvectionFrag.glsl?raw"
import fluidVert from "../../shaders/fluid/fluidBaseVert.glsl?raw"
import fluidClearFrag from "../../shaders/fluid/fluidClearFrag.glsl?raw"
import fluidCurlFrag from "../../shaders/fluid/fluidCurlFrag.glsl?raw"
import fluidDivergenceFrag from "../../shaders/fluid/fluidDivergenceFrag.glsl?raw"
import fluidGradientSubtractFrag from "../../shaders/fluid/fluidGradientSubtractFrag.glsl?raw"
import fluidPressureFrag from "../../shaders/fluid/fluidPressureFrag.glsl?raw"
import fluidSplatFrag from "../../shaders/fluid/fluidSplatFrag.glsl?raw"
import fluidVorticityFrag from "../../shaders/fluid/fluidVorticityFrag.glsl?raw"
import { FBO } from "../common/FBO"
import { FBOHelper } from "../common/FBOHelper"
import { Input } from "../utils/input"
import { Properties } from "../utils/properties"
import { RAFCollection } from "../utils/RAFCollection"
/* -------------------------------------------------------------------------- */
/* types */
/* -------------------------------------------------------------------------- */
type TouchPoint = {
position: Vector2
prevPosition: Vector2
lastUpdate: number
lastSplat: number
velocity: number
}
type FluidSimOptions = {
simRes: number
pressureIterations: number
pressureDissipation: number
velocityDissipation: number
curlStrength: number
splatRadius: number
splatForce: number
}
/** Seconds of pointer stillness before the solve is skipped entirely. */
const IDLE_SLEEP_AFTER = 2.5
/** Reused for the per-frame pointer delta, so `updatePoint` allocates nothing. */
const pointerDelta = new Vector2()
/* -------------------------------------------------------------------------- */
/* utils */
/* -------------------------------------------------------------------------- */
function shouldSolveFluid(time: number, lastUserInput: number, sleepAfter: number): boolean {
return time - lastUserInput <= sleepAfter
}
/* -------------------------------------------------------------------------- */
/* main */
/* -------------------------------------------------------------------------- */
export class FluidSim {
point: TouchPoint
private isRunning = false
private lastUserInput = 0
// Private configuration
private config: {
simTexelSize: number
aspect: number
} & FluidSimOptions
private materials: {
curl: RawShaderMaterial
vorticity: RawShaderMaterial
divergence: RawShaderMaterial
clear: RawShaderMaterial
pressure: RawShaderMaterial
gradientSubtract: RawShaderMaterial
advection: RawShaderMaterial
splat: RawShaderMaterial
}
private fbos: {
velocity: FBO
divergence: WebGLRenderTarget
curl: WebGLRenderTarget
pressure: FBO
}
// output (shared by reference into consumer materials): the hover-wake velocity field
uniforms: { velocity: { value: Texture | null } }
/* -------------------------------- materials ------------------------------- */
/**
* Every pass is the same shape: the shared fullscreen-quad vertex shader, no depth, and a
* precision prefix. Only the fragment shader, its uniforms and that precision differ, so this
* takes the differences and fills in the rest.
*/
private createPassMaterial(
fragmentShader: string,
precision: string,
uniforms: ShaderMaterialParameters["uniforms"],
defines?: Record<string, boolean>
) {
return FBOHelper.createRawShaderMaterial({
uniforms,
vertexShader: fluidVert,
fragmentShader,
depthTest: false,
depthWrite: false,
fragmentShaderPrefix: `precision ${precision} float;\nprecision ${precision} sampler2D;\n`,
// spread rather than pass directly: three warns about an explicitly-undefined `defines`
...(defines ? { defines } : {}),
})
}
/**
* One material per step of the solve. The passes that integrate velocity need `highp` —
* at mediump the field drifts and the wake visibly quantises. The ones that only take
* finite differences of neighbours are fine at mediump, which is cheaper on mobile.
*/
private createMaterials() {
const capabilities = Properties.gl!.capabilities // FluidSim only exists when gl does
const high = capabilities.getMaxPrecision("highp")
const medium = capabilities.getMaxPrecision("mediump")
// `u_texelSize` is per-material because each RawShaderMaterial owns its own uniform objects
const texelSize = () => ({ u_texelSize: { value: new Vector2() } })
return {
splat: this.createPassMaterial(fluidSplatFrag, high, {
...texelSize(),
u_tTarget: { value: null },
u_aspectRatio: { value: 1 },
u_splatColor: { value: new Vector3() },
u_splatPosition: { value: new Vector2() },
u_prevPoint: { value: new Vector2() },
u_splatRadius: { value: 1 },
}),
curl: this.createPassMaterial(fluidCurlFrag, medium, {
...texelSize(),
u_tVelocity: { value: null },
}),
vorticity: this.createPassMaterial(fluidVorticityFrag, high, {
...texelSize(),
u_tVelocity: { value: null },
u_tCurl: { value: null },
u_curl: { value: this.config.curlStrength },
u_dt: { value: 1 / 60 },
}),
divergence: this.createPassMaterial(fluidDivergenceFrag, medium, {
...texelSize(),
u_tVelocity: { value: null },
}),
clear: this.createPassMaterial(fluidClearFrag, medium, {
...texelSize(),
u_tTexture: { value: null },
u_value: { value: this.config.pressureDissipation },
u_dt: { value: 1 / 60 },
}),
pressure: this.createPassMaterial(fluidPressureFrag, medium, {
...texelSize(),
u_tPressure: { value: null },
u_tDivergence: { value: null },
}),
gradientSubtract: this.createPassMaterial(fluidGradientSubtractFrag, medium, {
...texelSize(),
u_tPressure: { value: null },
u_tVelocity: { value: null },
}),
advection: this.createPassMaterial(
fluidAdvectionFrag,
high,
{
...texelSize(),
u_tVelocity: { value: null },
u_tSource: { value: null },
u_dt: { value: 1 / 60 },
u_dissipation: { value: 1 },
},
// Bilinear-filter the backtraced sample in the shader. WebGL cannot linearly filter float
// textures everywhere, and there is no reliable capability flag for it in three, so the
// manual path is always on rather than probed.
{ MANUAL_FILTERING: true }
),
}
}
private createFBOs() {
// Velocity drives the sim (advection) and is the field the bars sample for their tint.
// HalfFloat + Linear is forced rather than chosen from a capability flag: smooth advection
// needs linear sampling, and falling back to NearestFilter here visibly pixelates the wake.
const velocity = new FBO(this.config.simRes, this.config.simRes, {
format: RGBAFormat,
type: HalfFloatType,
minFilter: LinearFilter,
magFilter: LinearFilter,
})
const pressure = new FBO(this.config.simRes, this.config.simRes, {
minFilter: NearestFilter,
magFilter: NearestFilter,
})
const renderTargetConfig: RenderTargetOptions = {
type: FloatType,
magFilter: NearestFilter,
minFilter: NearestFilter,
depthBuffer: false,
}
const divergence = new WebGLRenderTarget(this.config.simRes, this.config.simRes, renderTargetConfig)
const curl = new WebGLRenderTarget(this.config.simRes, this.config.simRes, renderTargetConfig)
return {
velocity,
pressure,
divergence,
curl,
}
}
updatePoint() {
const time = Properties.time
this.point.position.copy(Input.mouseScreenXY)
const point = this.point
// Skip if updated too recently (60fps throttle)
if (time - point.lastUpdate < 0.016) return
pointerDelta.subVectors(point.position, point.prevPosition)
const distance = pointerDelta.length()
point.velocity += distance * 2
if (distance > 0) {
// genuine cursor movement: note it so the idle gate keeps solving
if (distance > 0.001) {
this.lastUserInput = time
}
const shouldStartNewLine = time - point.lastSplat > 0.15
// Render velocity splat
this.materials.splat.uniforms.u_tTarget.value = this.fbos.velocity.read.texture
this.materials.splat.uniforms.u_aspectRatio.value = this.config.aspect
this.materials.splat.uniforms.u_splatPosition.value.copy(point.position)
this.materials.splat.uniforms.u_prevPoint.value.copy(shouldStartNewLine ? point.position : point.prevPosition)
this.materials.splat.uniforms.u_splatColor.value
.set(pointerDelta.x * this.config.aspect, pointerDelta.y, 0)
.multiplyScalar(this.config.splatForce)
.multiplyScalar(shouldStartNewLine ? 0 : 1)
this.materials.splat.uniforms.u_splatRadius.value = this.config.splatRadius * point.velocity
FBOHelper.render(this.materials.splat, this.fbos.velocity.write)
this.fbos.velocity.swap()
point.lastSplat = time
}
point.lastUpdate = time
point.prevPosition.copy(point.position)
point.velocity *= 0.9
point.velocity = Math.min(1, point.velocity)
}
private solve(): void {
this.config.aspect = Properties.globalUniforms.u_resolution.value.x / Properties.globalUniforms.u_resolution.value.y
const savedAutoClear = Properties.gl!.autoClear
const savedRenderTarget = Properties.gl!.getRenderTarget()
Properties.gl!.autoClear = false
this.updatePoint()
// Idle gate: once input is stale and the field has dissipated, skip the solve to save GPU/battery.
if (!shouldSolveFluid(Properties.time, this.lastUserInput, IDLE_SLEEP_AFTER)) {
Properties.gl!.autoClear = savedAutoClear
Properties.gl!.setRenderTarget(savedRenderTarget)
return
}
// Compute curl of velocity field
this.materials.curl.uniforms.u_texelSize.value.setScalar(this.config.simTexelSize)
this.materials.curl.uniforms.u_tVelocity.value = this.fbos.velocity.read.texture
FBOHelper.render(this.materials.curl, this.fbos.curl)
// Apply vorticity confinement
this.materials.vorticity.uniforms.u_texelSize.value.setScalar(this.config.simTexelSize)
this.materials.vorticity.uniforms.u_tVelocity.value = this.fbos.velocity.read.texture
this.materials.vorticity.uniforms.u_tCurl.value = this.fbos.curl.texture
this.materials.vorticity.uniforms.u_curl.value = this.config.curlStrength
this.materials.vorticity.uniforms.u_dt.value = Properties.deltaTime
FBOHelper.render(this.materials.vorticity, this.fbos.velocity.write)
this.fbos.velocity.swap()
// Compute divergence of velocity field
this.materials.divergence.uniforms.u_texelSize.value.setScalar(this.config.simTexelSize)
this.materials.divergence.uniforms.u_tVelocity.value = this.fbos.velocity.read.texture
FBOHelper.render(this.materials.divergence, this.fbos.divergence)
// Clear pressure field with dissipation
this.materials.clear.uniforms.u_tTexture.value = this.fbos.pressure.read.texture
this.materials.clear.uniforms.u_value.value = this.config.pressureDissipation
this.materials.clear.uniforms.u_dt.value = Properties.deltaTime
FBOHelper.render(this.materials.clear, this.fbos.pressure.write)
this.fbos.pressure.swap()
// Solve for pressure using Jacobi iteration
this.materials.pressure.uniforms.u_texelSize.value.setScalar(this.config.simTexelSize)
this.materials.pressure.uniforms.u_tDivergence.value = this.fbos.divergence.texture
for (let iteration = 0; iteration < this.config.pressureIterations; iteration++) {
this.materials.pressure.uniforms.u_tPressure.value = this.fbos.pressure.read.texture
FBOHelper.render(this.materials.pressure, this.fbos.pressure.write)
this.fbos.pressure.swap()
}
// Subtract pressure gradient from velocity to make it divergence-free
this.materials.gradientSubtract.uniforms.u_texelSize.value.setScalar(this.config.simTexelSize)
this.materials.gradientSubtract.uniforms.u_tPressure.value = this.fbos.pressure.read.texture
this.materials.gradientSubtract.uniforms.u_tVelocity.value = this.fbos.velocity.read.texture
FBOHelper.render(this.materials.gradientSubtract, this.fbos.velocity.write)
this.fbos.velocity.swap()
// Advect velocity through itself
this.materials.advection.uniforms.u_texelSize.value.setScalar(this.config.simTexelSize)
this.materials.advection.uniforms.u_tVelocity.value = this.fbos.velocity.read.texture
this.materials.advection.uniforms.u_tSource.value = this.fbos.velocity.read.texture
this.materials.advection.uniforms.u_dt.value = Properties.deltaTime
this.materials.advection.uniforms.u_dissipation.value = this.config.velocityDissipation
FBOHelper.render(this.materials.advection, this.fbos.velocity.write)
this.fbos.velocity.swap()
// Restore render state
Properties.gl!.autoClear = savedAutoClear
Properties.gl!.setRenderTarget(savedRenderTarget)
// Update output uniform (shared by reference into the consumer material)
this.uniforms.velocity.value = this.fbos.velocity.read.texture
}
/* ---------------------------------- main ---------------------------------- */
constructor(options: FluidSimOptions) {
this.config = {
...options,
simTexelSize: 1 / options.simRes,
aspect: 1,
}
this.point = {
position: new Vector2(0.5, 0.5),
prevPosition: new Vector2(0.5, 0.5),
lastUpdate: 0,
lastSplat: 0,
velocity: 0,
}
// create rts
this.fbos = this.createFBOs()
this.materials = this.createMaterials()
// setup uniform
this.uniforms = { velocity: { value: null } }
this.solve = this.solve.bind(this)
this.enable()
}
enable(): void {
if (!this.isRunning) {
this.isRunning = true
RAFCollection.add(this.solve)
}
}
disable(): void {
if (this.isRunning) {
this.isRunning = false
RAFCollection.remove(this.solve)
}
}
dispose(): void {
this.disable()
// Dispose all materials
this.materials.clear.dispose()
this.materials.splat.dispose()
this.materials.curl.dispose()
this.materials.vorticity.dispose()
this.materials.divergence.dispose()
this.materials.pressure.dispose()
this.materials.gradientSubtract.dispose()
this.materials.advection.dispose()
// Dispose render targets
this.fbos.velocity.dispose()
this.fbos.pressure.dispose()
this.fbos.divergence.dispose()
this.fbos.curl.dispose()
}
}
src/js/components/XylophoneBg.ts
import { Color, DoubleSide, Mesh, PlaneGeometry, ShaderMaterial } from "three"
import xylophoneBgFrag from "../../shaders/xylophoneBg/xylophoneBgFrag.glsl?raw"
import xylophoneBgVert from "../../shaders/xylophoneBg/xylophoneBgVert.glsl?raw"
import { BG_LAYER } from "../configs/XylophoneConfig"
/** Fullscreen gradient behind the bars — also what the frosted transmission samples. */
export class XylophoneBg {
readonly uniforms = {
u_color: { value: new Color(0xa391d3) },
u_debugPattern: { value: 0 }, // dev only: high-contrast checkerboard to read the transmission
}
private mesh?: Mesh
private material?: ShaderMaterial
private geometry?: PlaneGeometry
build() {
this.geometry = new PlaneGeometry(2, 2)
this.material = new ShaderMaterial({
uniforms: this.uniforms,
vertexShader: xylophoneBgVert,
fragmentShader: xylophoneBgFrag,
side: DoubleSide,
depthTest: false,
depthWrite: false,
})
this.mesh = new Mesh(this.geometry, this.material)
this.mesh.renderOrder = -1
this.mesh.frustumCulled = false // clip-space quad has no meaningful bounds
this.mesh.layers.enable(BG_LAYER) // also rendered in isolation into the backdrop buffer
return this.mesh
}
dispose() {
this.geometry?.dispose()
this.material?.dispose()
}
}
src/js/components/xylophone/Xylophone.ts
import {
BufferGeometry,
CanvasTexture,
ClampToEdgeWrapping,
DoubleSide,
Euler,
Group,
LinearFilter,
MathUtils,
Mesh,
PerspectiveCamera,
Quaternion,
Raycaster,
ShaderMaterial,
SRGBColorSpace,
Texture,
Vector3,
} from "three"
import { GLTFLoader } from "three/examples/jsm/Addons.js"
import audioUrl from "../../../assets/audio/do.wav?url"
import modelUrl from "../../../assets/models/xylophone-09.glb?url"
import xylophoneFrag from "../../../shaders/xylophone/xylophoneFrag.glsl?raw"
import xylophoneVert from "../../../shaders/xylophone/xylophoneVert.glsl?raw"
import { AUDIO, GLASS_LAYER, XYLOPHONE } from "../../configs/XylophoneConfig"
import { Input } from "../../utils/input"
import { Properties } from "../../utils/properties"
import { XylophoneAudio } from "./XylophoneAudio"
import { buildInstancedGeometry, writeHelixTransforms, type BuiltGeometry } from "./helix"
import { createHitWorkspace, hitBarIndex, type InstanceAttributes } from "./picking"
/* -------------------------------------------------------------------------- */
/* utils */
/* -------------------------------------------------------------------------- */
/** 1px-tall spectrum ramp the bars sample for their hover tint. */
function buildGradientTexture(): Texture {
const width = 256
const canvas = document.createElement("canvas")
canvas.width = width
canvas.height = 1
const ctx = canvas.getContext("2d")!
const grad = ctx.createLinearGradient(0, 0, width, 0)
grad.addColorStop(0.0, "#ff0033")
grad.addColorStop(0.3, "#ff00d4")
grad.addColorStop(0.5, "#6a00ff")
grad.addColorStop(0.8, "#0090ff")
grad.addColorStop(1.0, "#00ffe1")
ctx.fillStyle = grad
ctx.fillRect(0, 0, width, 1)
const tex = new CanvasTexture(canvas)
tex.colorSpace = SRGBColorSpace
tex.minFilter = LinearFilter
tex.magFilter = LinearFilter
tex.wrapS = ClampToEdgeWrapping
tex.wrapT = ClampToEdgeWrapping
return tex
}
/* -------------------------------------------------------------------------- */
/* main */
/* -------------------------------------------------------------------------- */
export class Xylophone {
readonly group = new Group()
readonly uniforms = {
// global
u_time: Properties.globalUniforms.u_time,
// motion — both are set for real in build(), once prefers-reduced-motion has been read
u_spinSpeed: { value: 0 }, // XYLOPHONE.spinSpeed normally, 0 under prefers-reduced-motion
u_swingScale: { value: 1.0 }, // 0 under prefers-reduced-motion
u_swingAxis: { value: new Vector3(0, 1, 0) },
// hover tint
u_tFluid: { value: null as Texture | null },
u_tGradient: { value: null as Texture | null },
u_fluidStrength: { value: 1.0 },
u_tintStrength: { value: 1.0 },
u_tintGlow: { value: 0.15 },
u_tintWrap: { value: XYLOPHONE.tintWrap },
// frosted transmission
u_tBackdrop: { value: null as Texture | null },
u_transmission: { value: 0.84 },
u_refractStrength: { value: 0.2 },
u_fresnelPower: { value: 3.0 },
// iridescence
u_iridStrength: { value: 0.6 },
u_iridCycles: { value: 3.0 },
u_iridShift: { value: 0.0 },
u_iridPower: { value: 2.5 },
u_iridBody: { value: 0.12 },
}
// mesh
private mesh?: Mesh
private material?: ShaderMaterial
private instances?: BuiltGeometry
private hitInstances?: InstanceAttributes
private geometryHeight = 1 // bar bbox height — the helix's vertical pitch
// strike (audio + swing)
private audio?: XylophoneAudio
private muted = false
private lastHitIndex = -1
private readonly raycast = new Raycaster()
private readonly hitWorkspace = createHitWorkspace()
// scroll — target accumulates raw wheel input, current eases toward it
private phaseTarget = 0
private phaseCurrent = 0
private phaseWritten = 0 // last phase actually written to the buffers
private readonly scrollEuler = new Euler(0, 0, 0, "YXZ")
private readonly scrollQuat = new Quaternion()
/* --------------------------------- public --------------------------------- */
/** Shares the fluid's velocity uniform by reference — no per-frame copy. */
setFluid(fluidVelocity: { value: Texture | null }) {
this.uniforms.u_tFluid = fluidVelocity
}
/** Held here too: the toggle can be set before the model (and its audio) finish loading. */
setMuted(muted: boolean) {
this.muted = muted
this.audio?.setMuted(muted)
}
/* --------------------------------- update --------------------------------- */
/** Scroll drives the conveyor: rewrite the helix only when the eased phase actually moved. */
private updateScroll(delta: number) {
if (!this.instances) return
this.phaseTarget += Input.deltaScrollY * XYLOPHONE.scroll.sensitivity
this.phaseCurrent = MathUtils.damp(this.phaseCurrent, this.phaseTarget, XYLOPHONE.scroll.lerp, delta)
if (Math.abs(this.phaseCurrent - this.phaseWritten) <= 1e-5) return
const { positions, rotations } = this.instances.transforms
writeHelixTransforms(
this.phaseCurrent,
this.geometryHeight,
XYLOPHONE,
positions,
rotations,
this.scrollEuler,
this.scrollQuat
)
this.instances.geometry.attributes.aPos.needsUpdate = true
this.instances.geometry.attributes.aRot.needsUpdate = true
this.phaseWritten = this.phaseCurrent
}
/** Hovering a new bar strikes it: stamp the strike time (drives the swing) and play its note. */
private updateStrike(camera: PerspectiveCamera) {
if (!this.instances || !this.hitInstances || !Input.hasPointer) return
this.raycast.setFromCamera(Input.mouseXY, camera)
const index = hitBarIndex(
this.raycast.ray,
this.hitInstances,
this.instances.localBox,
this.group.matrixWorld,
Properties.time,
this.uniforms.u_spinSpeed.value,
this.hitWorkspace
)
if (index !== -1 && index !== this.lastHitIndex) {
this.instances.aStrikeTime.setX(index, Properties.time)
this.instances.aStrikeTime.needsUpdate = true
this.audio?.playNote(index)
}
this.lastHitIndex = index
}
/* ---------------------------------- load ---------------------------------- */
private async loadBars() {
let modelGeometry: BufferGeometry
try {
const gltf = await new GLTFLoader().loadAsync(modelUrl)
modelGeometry = (gltf.scene.children[0] as Mesh).geometry
} catch (err) {
console.error("[Xylophone] bar model failed to load — nothing to render", err)
return
}
this.build(modelGeometry)
}
/* ---------------------------------- main ---------------------------------- */
private build(modelGeometry: BufferGeometry) {
this.instances = buildInstancedGeometry(modelGeometry, XYLOPHONE)
this.geometryHeight = this.instances.localBox.max.y - this.instances.localBox.min.y
this.hitInstances = {
aPos: this.instances.transforms.positions,
aRot: this.instances.transforms.rotations,
}
this.uniforms.u_tGradient.value = buildGradientTexture()
// prefers-reduced-motion suppresses the strike swing, not the interaction
this.uniforms.u_swingScale.value = Properties.reduceMotion ? 0 : 1
this.uniforms.u_spinSpeed.value = Properties.reduceMotion ? 0 : XYLOPHONE.spinSpeed
this.material = new ShaderMaterial({
uniforms: this.uniforms,
vertexShader: xylophoneVert,
fragmentShader: xylophoneFrag,
side: DoubleSide,
})
this.mesh = new Mesh(this.instances.geometry, this.material)
this.mesh.layers.enable(GLASS_LAYER) // also rendered in isolation into the view-normal buffer (SSAO)
this.group.add(this.mesh)
this.audio = new XylophoneAudio({
url: audioUrl,
count: XYLOPHONE.count,
baseFreq: AUDIO.baseFreq,
octaveSpan: AUDIO.octaveSpan,
})
this.audio.setMuted(this.muted)
void this.audio.load()
}
async load() {
this.group.scale.setScalar(XYLOPHONE.group.scale)
this.group.rotation.set(MathUtils.degToRad(XYLOPHONE.group.rotXDeg), 0, MathUtils.degToRad(XYLOPHONE.group.rotZDeg))
this.group.updateMatrixWorld()
await this.loadBars()
}
update(delta: number, camera: PerspectiveCamera): void {
if (!this.instances || !this.hitInstances) return
this.updateScroll(delta)
this.updateStrike(camera)
}
dispose() {
this.audio?.dispose()
this.instances?.geometry.dispose()
this.material?.dispose()
this.uniforms.u_tGradient.value?.dispose()
if (this.mesh) this.group.remove(this.mesh)
}
}
src/js/components/xylophone/XylophoneAudio.ts
/* -------------------------------------------------------------------------- */
/* types */
/* -------------------------------------------------------------------------- */
type XylophoneAudioOptions = {
/** Resolved URL of the recorded note sample (imported from src/assets, hashed by Vite). */
url: string
/** Number of bars / notes to tune. */
count: number
/** Fundamental pitch (Hz) the sample was recorded at — drives the pitch-shift ratio. */
baseFreq: number
/** How many octaves to spread the pentatonic scale across. Defaults to 3. */
octaveSpan?: number
/** Max simultaneous voices (anti-clip safeguard on fast sweeps). Defaults to 14. */
maxVoices?: number
/** Per-voice gain. Defaults to 0.6. */
voiceGain?: number
}
/* -------------------------------------------------------------------------- */
/* config */
/* -------------------------------------------------------------------------- */
/** Major-pentatonic scale degrees, in semitones above the root. */
const PENTATONIC = [0, 2, 4, 7, 9]
/* -------------------------------------------------------------------------- */
/* utils */
/* -------------------------------------------------------------------------- */
/**
* Per-bar frequency table spread over `octaveSpan` octaves of the major pentatonic
* scale. Bar 0 sits at `base`; pitch rises up the helix. Spreading across octaves
* keeps each voice's playbackRate near 1, so the pitch-shifted sample stays clean.
*/
export function buildPentatonicTable(count: number, base: number, octaveSpan: number): Float32Array {
const notesPerOctave = PENTATONIC.length
const totalNotes = notesPerOctave * octaveSpan
const out = new Float32Array(count)
for (let i = 0; i < count; i++) {
const t = count > 1 ? i / (count - 1) : 0
const n = Math.round(t * (totalNotes - 1))
const oct = Math.floor(n / notesPerOctave)
const semitones = PENTATONIC[n % notesPerOctave] + 12 * oct
out[i] = base * 2 ** (semitones / 12)
}
return out
}
/** 50ms of silence — 8kHz 8-bit mono WAV, ~440 bytes inline. Used only to steer iOS's audio session. */
const SILENT_WAV =
"data:audio/wav;base64,UklGRrQBAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YZABAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA"
function createSilentAudioElement(): HTMLAudioElement {
const el = document.createElement("audio")
el.src = SILENT_WAV
el.loop = true // keeps the session in the exempt category between gestures
el.volume = 0
el.setAttribute("playsinline", "") // never take over the screen on iOS
el.preload = "auto"
return el
}
/* -------------------------------------------------------------------------- */
/* main */
/* -------------------------------------------------------------------------- */
/**
* Tiny polyphonic sampler: one recorded sample pitch-shifted to a pentatonic scale,
* one throwaway AudioBufferSourceNode per note so overlapping hovers mix naturally.
* The context starts suspended (autoplay policy) and wakes on the first user gesture;
* a missing sample degrades to a silent no-op rather than throwing.
*/
export class XylophoneAudio {
private ctx?: AudioContext
private master?: GainNode
private buffer?: AudioBuffer
private readonly url: string
private readonly baseFreq: number
private readonly freq: Float32Array
private readonly maxVoices: number
private readonly voiceGain: number
private disabled = false
private muted = false
private activeVoices = 0
private silentEl?: HTMLAudioElement
// Every gesture, not just the first: resuming handles the autoplay policy, and re-playing
// the silent clip keeps iOS's audio session in the category that survives the mute switch.
private readonly onGesture = () => this.unlock()
/* --------------------------------- utils ---------------------------------- */
private resume() {
if (this.ctx?.state === "suspended") void this.ctx.resume()
}
/**
* iOS routes Web Audio through an audio session the hardware mute switch silences, while
* `<audio>` elements are exempt. Playing a silent clip through an `<audio>` element on a
* user gesture moves the session into the exempt category, so the bars stay audible with
* the ringer off. Both steps are best-effort — neither is supported everywhere.
*/
private unlock() {
// muted is the user's explicit choice — never override the ringer switch against it
if (this.muted) return
// experimental; declares intent directly where the browser understands it
const session = (navigator as Navigator & { audioSession?: { type: string } }).audioSession
if (session) session.type = "playback"
void this.silentEl?.play().catch(() => {})
this.resume()
}
/** Muting also drops the iOS session override, so the ringer switch is respected again. */
setMuted(muted: boolean) {
this.muted = muted
if (muted) this.silentEl?.pause()
else this.unlock() // called from the toggle's click, so it counts as a user gesture
}
private disable(reason: string, err?: unknown) {
this.disabled = true
console.warn(`[XylophoneAudio] disabled — ${reason}`, err ?? "")
}
private addGestureListeners() {
window.addEventListener("pointerdown", this.onGesture)
window.addEventListener("keydown", this.onGesture)
window.addEventListener("touchstart", this.onGesture)
}
private removeGestureListeners() {
window.removeEventListener("pointerdown", this.onGesture)
window.removeEventListener("keydown", this.onGesture)
window.removeEventListener("touchstart", this.onGesture)
}
/* ---------------------------------- main ---------------------------------- */
constructor(opts: XylophoneAudioOptions) {
this.url = opts.url
this.baseFreq = opts.baseFreq
this.maxVoices = opts.maxVoices ?? 14
this.voiceGain = opts.voiceGain ?? 0.6
this.freq = buildPentatonicTable(opts.count, opts.baseFreq, opts.octaveSpan ?? 3)
}
async load() {
try {
const Ctor =
window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!Ctor) {
this.disable("Web Audio not supported")
return
}
this.ctx = new Ctor()
this.master = this.ctx.createGain()
this.master.gain.value = 1
this.master.connect(this.ctx.destination)
this.silentEl = createSilentAudioElement()
const res = await fetch(this.url)
if (!res.ok) throw new Error(`status ${res.status}`)
this.buffer = await this.ctx.decodeAudioData(await res.arrayBuffer())
this.addGestureListeners()
} catch (err) {
this.disable(`failed to load sample "${this.url}"`, err)
}
}
playNote(index: number) {
if (this.muted || this.disabled || !this.ctx || !this.buffer || !this.master) return
if (this.activeVoices >= this.maxVoices) return
this.resume()
const src = this.ctx.createBufferSource()
src.buffer = this.buffer
src.playbackRate.value = this.freq[index] / this.baseFreq
const gain = this.ctx.createGain()
gain.gain.value = this.voiceGain
src.connect(gain).connect(this.master)
src.onended = () => {
src.disconnect()
gain.disconnect()
this.activeVoices--
}
src.start()
this.activeVoices++
}
dispose() {
this.removeGestureListeners()
this.silentEl?.pause()
this.silentEl = undefined
this.master?.disconnect()
void this.ctx?.close()
this.ctx = undefined
this.buffer = undefined
this.master = undefined
}
}
src/js/components/xylophone/helix.ts
import { Box3, BufferGeometry, Euler, InstancedBufferAttribute, InstancedBufferGeometry, Quaternion } from "three"
/* -------------------------------------------------------------------------- */
/* types */
/* -------------------------------------------------------------------------- */
export type HelixConfig = {
count: number
radius: number
tiltFalloff: number
thetaStep: number
thetaOffset: number
}
export type InstanceTransforms = {
positions: Float32Array // count * 3
rotations: Float32Array // count * 4
tintOffsets: Float32Array // count
}
export type BuiltGeometry = {
geometry: InstancedBufferGeometry
localBox: Box3
aStrikeTime: InstancedBufferAttribute
transforms: InstanceTransforms
}
/* -------------------------------------------------------------------------- */
/* layout */
/* -------------------------------------------------------------------------- */
function wrap(a: number, n: number): number {
return ((a % n) + n) % n
}
/**
* Writes the helix pose for every bar at a given scroll `phase`.
*
* The bars ride a conveyor: advancing the phase slides each one along the helix and
* wraps it back to the bottom, so a finite `count` reads as an endless column. Writes
* in place into the instance buffers, and borrows the caller's Euler/Quaternion, so it
* allocates nothing and is safe to call every frame.
*/
export function writeHelixTransforms(
phase: number,
geometryHeight: number,
cfg: HelixConfig,
positions: Float32Array,
rotations: Float32Array,
e: Euler,
q: Quaternion
): void {
const tiltX = Math.atan2(geometryHeight, cfg.radius * cfg.tiltFalloff)
const halfHeight = ((cfg.count - 1) * geometryHeight) / 2
for (let i = 0; i < cfg.count; i++) {
const s = wrap(i + phase, cfg.count)
const theta = s * cfg.thetaStep + cfg.thetaOffset
positions[i * 3] = cfg.radius * Math.cos(theta)
positions[i * 3 + 1] = s * geometryHeight - halfHeight
positions[i * 3 + 2] = cfg.radius * Math.sin(theta)
e.set(tiltX, -theta, 0)
q.setFromEuler(e)
rotations[i * 4] = q.x
rotations[i * 4 + 1] = q.y
rotations[i * 4 + 2] = q.z
rotations[i * 4 + 3] = q.w
}
}
export function buildInstanceTransforms(geometryHeight: number, cfg: HelixConfig): InstanceTransforms {
const positions = new Float32Array(cfg.count * 3)
const rotations = new Float32Array(cfg.count * 4)
const tintOffsets = new Float32Array(cfg.count)
// rest layout is just the conveyor at phase 0
writeHelixTransforms(0, geometryHeight, cfg, positions, rotations, new Euler(0, 0, 0, "YXZ"), new Quaternion())
for (let i = 0; i < cfg.count; i++) {
tintOffsets[i] = i / cfg.count
}
return { positions, rotations, tintOffsets }
}
/* -------------------------------------------------------------------------- */
/* geometry */
/* -------------------------------------------------------------------------- */
/** Wraps one loaded bar mesh into an instanced geometry, one instance per helix slot. */
export function buildInstancedGeometry(model: BufferGeometry, cfg: HelixConfig): BuiltGeometry {
model.computeBoundingBox()
const bbox = model.boundingBox!
const height = bbox.max.y - bbox.min.y
const transforms = buildInstanceTransforms(height, cfg)
const geometry = new InstancedBufferGeometry()
geometry.index = model.index
geometry.setAttribute("position", model.getAttribute("position"))
geometry.setAttribute("normal", model.getAttribute("normal"))
geometry.setAttribute("uv", model.getAttribute("uv"))
geometry.setAttribute("aPos", new InstancedBufferAttribute(transforms.positions, 3))
geometry.setAttribute("aRot", new InstancedBufferAttribute(transforms.rotations, 4))
geometry.setAttribute("aTintOffset", new InstancedBufferAttribute(transforms.tintOffsets, 1))
// -1e9 = never struck; the shader's decay envelope collapses to 0 at that age
const aStrikeTime = new InstancedBufferAttribute(new Float32Array(cfg.count).fill(-1e9), 1)
geometry.setAttribute("aStrikeTime", aStrikeTime)
return { geometry, localBox: bbox.clone(), aStrikeTime, transforms }
}
src/js/components/xylophone/picking.ts
import { Box3, Matrix4, Quaternion, Ray, Vector3 } from "three"
/** The two instance buffers the picker needs — the live pose written by `writeHelixTransforms`. */
export type InstanceAttributes = { aPos: Float32Array; aRot: Float32Array }
/** Reusable temp math, so `hitBarIndex` runs allocation-free every frame. */
export type HitWorkspace = ReturnType<typeof createHitWorkspace>
export function createHitWorkspace() {
return {
spin: new Quaternion(),
aRotQ: new Quaternion(),
rotQ: new Quaternion(),
pos: new Vector3(),
scale: new Vector3(1, 1, 1),
mat: new Matrix4(),
inv: new Matrix4(),
ray: new Ray(),
hit: new Vector3(),
}
}
/**
* Nearest bar under `worldRay`, or -1.
*
* The bars only exist on the GPU (one instanced draw), so there is nothing for three's
* raycaster to walk. Instead we rebuild each instance's world matrix on the CPU, push the
* ray into that instance's local space, and test it against the shared bar bounding box —
* one box test per bar, no per-triangle work.
*
* The idle spin is reconstructed here from `time`/`spinSpeed`; it must stay in step with
* xylophoneVert.glsl, which derives the same half-angle from the same two uniforms. The
* strike swing is deliberately *not* modelled — it decays in a few hundred ms, and picking
* a bar that is still ringing should hit where it rests, not where it currently is.
*/
export function hitBarIndex(
worldRay: Ray,
instances: InstanceAttributes,
localBox: Box3,
groupMatrixWorld: Matrix4,
time: number,
spinSpeed: number,
work: HitWorkspace
): number {
const { aPos, aRot } = instances
const count = aPos.length / 3
// mirrors the idle spin in xylophoneVert.glsl: half-angle = time * spinSpeed * 0.5
const half = time * spinSpeed * 0.5
work.spin.set(0, Math.sin(half), 0, Math.cos(half))
let hitIndex = -1
let hitDist = Infinity
for (let i = 0; i < count; i++) {
work.aRotQ.set(aRot[i * 4], aRot[i * 4 + 1], aRot[i * 4 + 2], aRot[i * 4 + 3])
work.rotQ.multiplyQuaternions(work.spin, work.aRotQ)
work.pos.set(aPos[i * 3], aPos[i * 3 + 1], aPos[i * 3 + 2])
work.mat.compose(work.pos, work.rotQ, work.scale)
work.mat.premultiply(groupMatrixWorld)
work.inv.copy(work.mat).invert()
work.ray.copy(worldRay).applyMatrix4(work.inv)
const pt = work.ray.intersectBox(localBox, work.hit)
if (pt) {
const dist = work.ray.origin.distanceTo(pt)
if (dist < hitDist) {
hitDist = dist
hitIndex = i
}
}
}
return hitIndex
}
src/js/configs/XylophoneConfig.ts
/* -------------------------------------------------------------------------- */
/* quality */
/* -------------------------------------------------------------------------- */
// Coarse pointer + small viewport ≈ phone. A 2x pixel ratio, a full-res view-normal buffer and
// ULTRA SMAA together are more than a phone GPU holds at 60fps, so the tier scales them back
// instead of letting the frame rate collapse. Everything else (fluid, frost) is already cheap.
const isMobile =
(window.matchMedia?.("(pointer: coarse)").matches ?? false) && Math.min(window.innerWidth, window.innerHeight) < 900
export const QUALITY = {
isMobile,
maxDpr: isMobile ? 1.5 : 2,
/** view-normal buffer size, as a fraction of the screen */
glassBufferScale: isMobile ? 0.5 : 1,
} as const
/* -------------------------------------------------------------------------- */
/* layers */
/* -------------------------------------------------------------------------- */
// Passes isolate content by narrowing the camera's layer mask, not by swapping scenes.
export const GLASS_LAYER = 1 // the bars — rendered again into the view-normal buffer (SSAO)
export const BG_LAYER = 2 // the backdrop — rendered again, blurred, for the frosted transmission
/* -------------------------------------------------------------------------- */
/* xylophone */
/* -------------------------------------------------------------------------- */
export const XYLOPHONE = {
// helix layout
count: 64,
radius: 1,
tiltFalloff: 2,
thetaStep: 0.175,
thetaOffset: Math.PI,
// idle rotation — shared by the vertex shader and the CPU picker
spinSpeed: 0.3,
// tint gradient repeats every `tintWrap` bars
tintWrap: 10,
group: { scale: 0.5, rotXDeg: 25, rotZDeg: 30 },
scroll: { sensitivity: 0.005, lerp: 5 },
} as const
export const AUDIO = {
baseFreq: 523.25, // C5 — the pitch the sample was recorded at
octaveSpan: 3,
} as const
/* -------------------------------------------------------------------------- */
/* fluid */
/* -------------------------------------------------------------------------- */
// Hover wake. Low-res on purpose: the bars only sample its velocity magnitude.
export const FLUID = {
simRes: 128,
curlStrength: 0.2,
splatRadius: 0.6,
splatForce: 20,
pressureIterations: 1,
velocityDissipation: 0.93,
pressureDissipation: 0.97,
} as const
/* -------------------------------------------------------------------------- */
/* post */
/* -------------------------------------------------------------------------- */
export const FROST = {
strength: 0.9, // 0..1, scaled to the gaussian kernel by FrostBackdropPass
maxBlurPx: 48,
} as const
// Contact shadows. The floating comp has no floor, so bar-on-bar self-occlusion IS the shadow.
export const SSAO = {
samples: 16,
rings: 7,
radius: 0.1, // sampling radius, as a fraction of resolution
intensity: 2.2,
bias: 0.03, // rejects self-occlusion on flat faces
fade: 0.02,
luminanceInfluence: 0.6, // let the bright frosted body keep some AO
worldDistanceThreshold: 20,
worldDistanceFalloff: 5,
worldProximityThreshold: 3,
worldProximityFalloff: 1,
resolutionScale: QUALITY.isMobile ? 0.5 : 1,
} as const
src/js/main.ts
import { EffectPass, RenderPass, SMAAEffect, SMAAPreset, SSAOEffect } from "postprocessing"
import {
DoubleSide,
NoToneMapping,
PerspectiveCamera,
Scene,
ShaderMaterial,
SRGBColorSpace,
WebGLRenderer,
} from "three"
import glassNormalFrag from "../shaders/xylophone/glassNormalFrag.glsl?raw"
import xylophoneVert from "../shaders/xylophone/xylophoneVert.glsl?raw"
import { FBOHelper } from "./common/FBOHelper"
import { FluidSim } from "./components/FluidSim"
import { Xylophone } from "./components/xylophone/Xylophone"
import { XylophoneBg } from "./components/XylophoneBg"
import { FLUID, FROST, QUALITY, SSAO } from "./configs/XylophoneConfig"
import { FrostBackdropPass } from "./passes/FrostBackdropPass"
import { GlassBufferPass } from "./passes/GlassBufferPass"
import { SoundToggle } from "./ui/SoundToggle"
import { Input } from "./utils/input"
import { Properties } from "./utils/properties"
import { RAFCollection } from "./utils/RAFCollection"
import "../../css/base.css"
const MAX_DELTA = 1 / 20 // clamp long frames (tab restore) so nothing integrates a huge step
class App {
private readonly gl: WebGLRenderer
private readonly scene = new Scene()
private readonly camera: PerspectiveCamera
// components
private readonly xylophone = new Xylophone()
private readonly xylophoneBg = new XylophoneBg()
private readonly fluid: FluidSim
// passes — composited in this order
private readonly frostBackdropPass: FrostBackdropPass
private readonly renderPass: RenderPass
private readonly glassBufferPass: GlassBufferPass
private readonly glassNormalMaterial: ShaderMaterial
private readonly ssaoEffect: SSAOEffect
private readonly ssaoPass: EffectPass
private readonly aaPass: EffectPass
// ui
private soundToggle: SoundToggle
// frame
private size = { width: 0, height: 0 }
private dateTime = performance.now()
private isContextLost = false
private rafId = 0
private readonly boundUpdate = this.update.bind(this)
private readonly boundResize = this.resize.bind(this)
/* ---------------------------------- setup --------------------------------- */
private createRenderer() {
const gl = new WebGLRenderer({
alpha: false,
antialias: true,
powerPreference: "high-performance",
premultipliedAlpha: false,
})
gl.outputColorSpace = SRGBColorSpace
gl.toneMapping = NoToneMapping
gl.setPixelRatio(Properties.dpr)
gl.setSize(window.innerWidth, window.innerHeight)
const canvas = gl.domElement
canvas.id = "canvas"
canvas.setAttribute("aria-hidden", "true")
canvas.style.position = "fixed"
canvas.style.left = "0px"
canvas.style.top = "0px"
canvas.style.pointerEvents = "none"
document.body.prepend(canvas)
// Context loss is common on mobile when backgrounding. three re-uploads every GPU
// resource itself on restore, so we only have to stop and resume drawing.
canvas.addEventListener("webglcontextlost", (event) => {
event.preventDefault()
this.isContextLost = true
console.warn("WebGL context lost — pausing render")
})
canvas.addEventListener("webglcontextrestored", () => {
this.isContextLost = false
console.warn("WebGL context restored — resuming render")
})
return gl
}
/** One bg render -> Gaussian-blurred backdrop the frosted bars transmit through. */
private createFrostPass() {
const pass = new FrostBackdropPass(this.scene, this.camera)
pass.blurRadius = FROST.strength * FROST.maxBlurPx
this.xylophone.uniforms.u_tBackdrop.value = pass.blurredTexture
return pass
}
/**
* View-space normals for SSAO. three's NormalPass can't reproduce our instanced helix pose,
* so we re-render the bars with the same vertex shader and share the animation uniforms —
* the buffer then tracks the live pose. Depth comes from the main render pass.
*/
private createGlassNormalMaterial() {
const u = this.xylophone.uniforms
return new ShaderMaterial({
vertexShader: xylophoneVert,
fragmentShader: glassNormalFrag,
side: DoubleSide, // match the display material so both plate faces write normals
uniforms: {
u_time: u.u_time,
u_spinSpeed: u.u_spinSpeed,
u_swingScale: u.u_swingScale,
u_swingAxis: u.u_swingAxis,
},
})
}
/* ---------------------------------- debug --------------------------------- */
// lil-gui is imported dynamically so it never lands in the production bundle.
private async setupDebug() {
if (!import.meta.env.DEV) return
const { default: GUI } = await import("three/examples/jsm/libs/lil-gui.module.min.js")
Properties.gui = new GUI()
const u = this.xylophone.uniforms
const folder = Properties.gui.addFolder("Frosted")
folder
.add({ frost: FROST.strength }, "frost", 0, 1, 0.01)
.onChange((v: number) => (this.frostBackdropPass.blurRadius = v * FROST.maxBlurPx))
folder.add(u.u_transmission, "value", 0, 1, 0.01).name("transmission")
folder.add(u.u_refractStrength, "value", 0, 0.2, 0.001).name("refract strength")
folder.add(u.u_fresnelPower, "value", 0, 10, 0.1).name("fresnel power")
folder.add(u.u_fluidStrength, "value", 0, 3, 0.01).name("fluid strength")
folder.add(u.u_tintStrength, "value", 0, 1, 0.01).name("tint strength")
folder.add(u.u_tintGlow, "value", 0, 1, 0.01).name("tint glow")
const irid = folder.addFolder("Iridescence")
irid.add(u.u_iridStrength, "value", 0, 2, 0.01).name("strength")
irid.add(u.u_iridCycles, "value", 0, 8, 0.1).name("cycles")
irid.add(u.u_iridShift, "value", 0, 1, 0.01).name("hue shift")
irid.add(u.u_iridPower, "value", 0.5, 6, 0.1).name("rim power")
irid.add(u.u_iridBody, "value", 0, 1, 0.01).name("body")
const ao = folder.addFolder("Contact shadow")
ao.add(this.ssaoPass, "enabled").name("enabled")
ao.add(this.ssaoEffect.ssaoMaterial, "intensity", 0, 6, 0.05).name("intensity")
ao.add(this.ssaoEffect.ssaoMaterial, "radius", 0.001, 0.5, 0.001).name("radius")
ao.add(this.ssaoEffect.ssaoMaterial, "bias", 0, 0.2, 0.001).name("bias")
ao.add(this.ssaoEffect.ssaoMaterial, "fade", 0, 0.2, 0.001).name("fade")
// high-contrast bg so the transmission has something obvious to reveal
folder.add(this.xylophoneBg.uniforms.u_debugPattern, "value", 0, 1, 1).name("bg test pattern")
}
/* ---------------------------------- main ---------------------------------- */
constructor() {
Properties.viewportWidth = window.innerWidth
Properties.viewportHeight = window.innerHeight
this.gl = this.createRenderer()
Properties.gl = this.gl
Properties.composer.setRenderer(this.gl)
this.camera = new PerspectiveCamera(45, Properties.viewportWidth / Properties.viewportHeight, 0.1, 200)
this.camera.position.set(0, 0, 5)
Input.init()
FBOHelper.init()
// setup components
this.scene.add(this.xylophone.group)
this.scene.add(this.xylophoneBg.build())
this.fluid = new FluidSim(FLUID)
this.xylophone.setFluid(this.fluid.uniforms.velocity)
// setup passes
// frostBackdrop -> render -> glassBuffer -> ssao -> aa
this.frostBackdropPass = this.createFrostPass()
this.renderPass = new RenderPass(this.scene, this.camera)
this.glassNormalMaterial = this.createGlassNormalMaterial()
this.glassBufferPass = new GlassBufferPass(
this.scene,
this.camera,
this.glassNormalMaterial,
QUALITY.glassBufferScale
)
this.ssaoEffect = new SSAOEffect(this.camera, this.glassBufferPass.glassTexture, SSAO)
this.ssaoPass = new EffectPass(this.camera, this.ssaoEffect)
this.aaPass = new EffectPass(
this.camera,
new SMAAEffect({ preset: QUALITY.isMobile ? SMAAPreset.MEDIUM : SMAAPreset.ULTRA })
)
for (const pass of [this.frostBackdropPass, this.renderPass, this.glassBufferPass, this.ssaoPass, this.aaPass]) {
Properties.composer.addPass(pass)
}
// post update
this.resize()
window.addEventListener("resize", this.boundResize)
this.soundToggle = new SoundToggle((muted) => this.xylophone.setMuted(muted))
this.soundToggle.mount()
// The loader covers the page until the bars exist. `load()` swallows its own asset failures,
// so the overlay can't get stuck on a failed fetch.
void this.xylophone.load().then(() => document.body.classList.remove("loading"))
void this.setupDebug()
this.update()
}
private resize() {
// #sizer tracks the layout viewport, which is steadier than innerHeight on mobile
const sizerEl = document.getElementById("sizer")
const rect = sizerEl?.getBoundingClientRect()
const width = rect?.width ?? window.innerWidth
const height = rect?.height ?? window.innerHeight
if (this.size.width === width && this.size.height === height) return
this.size = { width, height }
Properties.viewportWidth = width
Properties.viewportHeight = height
Properties.globalUniforms.u_resolution.value.set(width * Properties.dpr, height * Properties.dpr)
this.gl.setSize(width, height)
Properties.composer.setSize(width, height)
Input.resize()
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
}
private update() {
this.rafId = window.requestAnimationFrame(this.boundUpdate)
if (this.isContextLost) return
const now = performance.now()
const delta = Math.min((now - this.dateTime) / 1e3, MAX_DELTA)
this.dateTime = now
Properties.deltaTime = delta
Properties.time += delta
Properties.globalUniforms.u_deltaTime.value = delta
Properties.globalUniforms.u_time.value = Properties.time
RAFCollection.forEach((callback) => callback(delta))
this.xylophone.update(delta, this.camera)
Properties.composer.render(delta)
Input.postUpdate()
}
destroy() {
window.cancelAnimationFrame(this.rafId)
window.removeEventListener("resize", this.boundResize)
Input.destroy()
this.soundToggle.destroy()
for (const pass of [this.frostBackdropPass, this.renderPass, this.glassBufferPass, this.ssaoPass, this.aaPass]) {
Properties.composer.removePass(pass)
pass.dispose()
}
this.glassNormalMaterial.dispose()
this.fluid.dispose()
this.xylophone.dispose()
this.xylophoneBg.dispose()
Properties.composer.dispose()
this.gl.dispose()
}
}
let app: App | null = null
window.addEventListener("load", () => {
app = new App()
})
// pagehide, not beforeunload — beforeunload doesn't fire reliably on mobile Safari.
//
// `persisted` means the page is going into the back/forward cache rather than being torn down,
// so it can be restored by a back navigation with no `load` event to rebuild us. Tearing the app
// down there would strand a dead canvas on return; the browser freezes our rAF while cached, so
// staying alive costs nothing.
window.addEventListener("pagehide", (event) => {
if (event.persisted) return
app?.destroy()
app = null
})
src/js/passes/FrostBackdropPass.ts
import { GaussianBlurPass, Pass } from "postprocessing"
import {
LinearFilter,
PerspectiveCamera,
Scene,
SRGBColorSpace,
Texture,
UnsignedByteType,
WebGLRenderTarget,
WebGLRenderer,
} from "three"
import { BG_LAYER } from "../configs/XylophoneConfig"
/**
* Renders the bg-only content (BG_LAYER) once, then Gaussian-blurs it into a
* separate target the frosted bars transmit through (sampled via u_tBackdrop).
*
* The blur uses postprocessing's GaussianBlurPass (blurs one target into another,
* managing its own scratch buffers). Its low-res working buffers give a wide,
* smooth frost cheaply — the frosted fragment takes a single tap of the result.
*
* Side render: writes only to its own targets, leaves the composer buffers
* untouched (`needsSwap = false`). Isolation is by layer (camera mask narrowed to
* BG_LAYER). Must run before the main render pass so the buffer is ready.
*/
export class FrostBackdropPass extends Pass {
private renderTarget: WebGLRenderTarget // sharp bg render
private blurredTarget: WebGLRenderTarget // Gaussian-blurred copy
private bgScene: Scene
private bgCamera: PerspectiveCamera
private blurPass: GaussianBlurPass
private blurMaterial: { scale: number }
private blurInitialized = false
/* ----------------------------------- get ---------------------------------- */
// blurred bg (for the frosted transmission)
get blurredTexture(): Texture {
return this.blurredTarget.texture
}
/* ----------------------------------- set ---------------------------------- */
// frost strength: main.ts sets `frost * 48`; map that to the gaussian kernel scale (~0..1)
set blurRadius(px: number) {
this.blurMaterial.scale = px / 48
}
/* ---------------------------------- main ---------------------------------- */
constructor(scene: Scene, camera: PerspectiveCamera) {
super("FrostBackdropPass")
// side render only — do not consume/produce the composer's main buffers
this.needsSwap = false
this.bgScene = scene
this.bgCamera = camera
const options = {
minFilter: LinearFilter,
magFilter: LinearFilter,
depthBuffer: false, // single fullscreen layer, nothing to depth-sort
stencilBuffer: false,
colorSpace: SRGBColorSpace, // match the frosted body it is mixed against
}
this.renderTarget = new WebGLRenderTarget(1, 1, options)
this.renderTarget.texture.name = "FrostBackdropSharp"
this.blurredTarget = new WebGLRenderTarget(1, 1, options)
this.blurredTarget.texture.name = "FrostBackdropBlurred"
// half-res working buffers keep the wide frost cheap; scale tunes the spread at runtime
this.blurPass = new GaussianBlurPass({ kernelSize: 35, iterations: 3, resolutionScale: 0.25 })
this.blurMaterial = (this.blurPass as unknown as { blurMaterial: { scale: number } }).blurMaterial
}
render(
renderer: WebGLRenderer,
_inputBuffer: WebGLRenderTarget | null,
_outputBuffer: WebGLRenderTarget | null,
_deltaTime?: number,
_stencilTest?: boolean
): void {
const prevTarget = renderer.getRenderTarget()
const prevLayerMask = this.bgCamera.layers.mask
// isolate the bg layer so only the "behind glass" content is captured
this.bgCamera.layers.set(BG_LAYER)
renderer.setRenderTarget(this.renderTarget)
renderer.render(this.bgScene, this.bgCamera)
this.bgCamera.layers.mask = prevLayerMask
renderer.setRenderTarget(prevTarget)
// tag the blur's internal buffers to match the sRGB backdrop (one-time)
if (!this.blurInitialized) {
this.blurPass.initialize(renderer, true, UnsignedByteType)
this.blurInitialized = true
}
// sharp bg -> blurred backdrop
this.blurPass.render(renderer, this.renderTarget, this.blurredTarget)
}
setSize(width: number, height: number): void {
const w = Math.max(1, width)
const h = Math.max(1, height)
this.renderTarget.setSize(w, h)
this.blurredTarget.setSize(w, h)
this.blurPass.setSize(w, h)
}
dispose(): void {
this.renderTarget.dispose()
this.blurredTarget.dispose()
this.blurPass.dispose()
super.dispose()
}
}
src/js/passes/GlassBufferPass.ts
import { Pass } from "postprocessing"
import {
LinearFilter,
LinearSRGBColorSpace,
PerspectiveCamera,
Scene,
ShaderMaterial,
Texture,
WebGLRenderTarget,
WebGLRenderer,
} from "three"
import { GLASS_LAYER } from "../configs/XylophoneConfig"
/**
* Renders the xylophone bars into a view-normal buffer using a supplied override
* material (the glass G-buffer). Runs as a side render: it writes only to its own
* target and leaves the composer's input/output buffers untouched
* (`needsSwap = false`). SSAOEffect samples `glassTexture` as its normal buffer.
*
* Isolation is by layer, not by scene: the camera's layer mask is narrowed to
* `GLASS_LAYER` for the duration of the render, so only meshes on that layer are
* written. Non-glass meshes (e.g. the background) stay off `GLASS_LAYER`.
*/
export class GlassBufferPass extends Pass {
private renderTarget: WebGLRenderTarget
private glassScene: Scene
private glassCamera: PerspectiveCamera
// material
private normalMaterial: ShaderMaterial
// render target resolution as a fraction of the screen size
private resolutionScale: number
/* ----------------------------------- get ---------------------------------- */
get glassTexture(): Texture {
return this.renderTarget.texture
}
/* ---------------------------------- main ---------------------------------- */
constructor(scene: Scene, camera: PerspectiveCamera, normalMaterial: ShaderMaterial, resolutionScale = 0.5) {
super("GlassBufferPass")
// side render only — do not consume/produce the composer's main buffers
this.needsSwap = false
this.glassScene = scene
this.glassCamera = camera
this.normalMaterial = normalMaterial
this.resolutionScale = resolutionScale
this.renderTarget = new WebGLRenderTarget(1, 1, {
minFilter: LinearFilter,
magFilter: LinearFilter,
depthBuffer: true, // front-most bar's normal wins
stencilBuffer: false,
colorSpace: LinearSRGBColorSpace, // storing data, not color
})
this.renderTarget.texture.name = "GlassBuffer"
}
render(
renderer: WebGLRenderer,
_inputBuffer: WebGLRenderTarget | null,
_outputBuffer: WebGLRenderTarget | null,
_deltaTime?: number,
_stencilTest?: boolean
): void {
const prevTarget = renderer.getRenderTarget()
const prevClearAlpha = renderer.getClearAlpha()
const prevOverride = this.glassScene.overrideMaterial
const prevLayerMask = this.glassCamera.layers.mask
// isolate the glass layer so only the bars are written into the mask
this.glassCamera.layers.set(GLASS_LAYER)
renderer.setRenderTarget(this.renderTarget)
renderer.setClearAlpha(0) // mask = 0 outside the bars
renderer.clear()
this.glassScene.overrideMaterial = this.normalMaterial
renderer.render(this.glassScene, this.glassCamera)
this.glassCamera.layers.mask = prevLayerMask
this.glassScene.overrideMaterial = prevOverride
renderer.setClearAlpha(prevClearAlpha)
renderer.setRenderTarget(prevTarget)
}
setSize(width: number, height: number): void {
const w = Math.max(1, Math.round(width * this.resolutionScale))
const h = Math.max(1, Math.round(height * this.resolutionScale))
this.renderTarget.setSize(w, h)
}
dispose(): void {
this.renderTarget.dispose()
super.dispose()
}
}
src/js/ui/SoundToggle.ts
const STORAGE_KEY = "xylophone:sound"
// localStorage throws in some privacy modes — a lost preference is not worth breaking the page over
function readStoredMuted(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === "off"
} catch {
return false
}
}
function writeStoredMuted(muted: boolean) {
try {
localStorage.setItem(STORAGE_KEY, muted ? "off" : "on")
} catch {
// preference just won't persist
}
}
/* -------------------------------------------------------------------------- */
/* main */
/* -------------------------------------------------------------------------- */
export class SoundToggle {
muted = readStoredMuted()
private readonly el = document.getElementById("sound-toggle") as HTMLButtonElement | null
private readonly label = this.el?.querySelector(".sound-toggle__text") ?? null
private readonly onClick = () => this.set(!this.muted)
constructor(private readonly onToggle: (muted: boolean) => void) {}
mount() {
this.el?.addEventListener("click", this.onClick)
this.render()
this.onToggle(this.muted) // apply the restored preference
}
destroy() {
this.el?.removeEventListener("click", this.onClick)
}
private set(muted: boolean) {
this.muted = muted
writeStoredMuted(muted)
this.render()
this.onToggle(muted)
}
private render() {
if (!this.el) return
this.el.setAttribute("aria-pressed", String(!this.muted))
this.el.classList.toggle("is-muted", this.muted)
if (this.label) this.label.textContent = this.muted ? "Sound off" : "Sound on"
}
}
src/js/utils/RAFCollection.ts
type RAFCallback = (delta: number) => void
/** Per-frame callbacks, driven by the app's single requestAnimationFrame loop. */
export class RAFCollection {
private static callbacks = new Set<RAFCallback>()
static add(callback: RAFCallback): void {
this.callbacks.add(callback)
}
static remove(callback: RAFCallback): void {
this.callbacks.delete(callback)
}
static forEach(fn: (callback: RAFCallback) => void): void {
this.callbacks.forEach(fn)
}
}
src/js/utils/input.ts
import { MathUtils, Vector2 } from "three"
import { normalizeWheelY } from "./normalizeWheel"
import { Properties } from "./properties"
const MAX_SCROLL_PER_EVENT = 200 // one violent wheel notch shouldn't jump the whole helix
const DRAG_AXIS_LOCK_PX = 8 // travel before a touch drag commits to an axis
const DRAG_SCALE = 3
/**
* Global pointer + wheel state, sampled once per frame by the components that need it.
*
* Deltas accumulate across every event in a frame and are cleared in `postUpdate()`, so a
* consumer reading `deltaScrollY` sees the whole frame's scroll regardless of how many events
* fired. Wheel and touch drag both normalise into `deltaScrollY`, so consumers never branch
* on which device produced it.
*/
export class Input {
/** NDC, -1..1 — raycasting */
static mouseXY = new Vector2()
/** 0..1 from the bottom-left — fluid splats */
static mouseScreenXY = new Vector2()
/** scroll pixels this frame, from the wheel or a vertical touch drag */
static deltaScrollY = 0
/** false until the first pointer event — (0,0) is a valid position, so it can't be the sentinel */
static hasPointer = false
// cached viewport reciprocals, refreshed on resize
private static invViewportWidth = 0
private static invViewportHeight = 0
// touch drag — `wheel` never fires for touch, so a vertical drag drives the same scroll
private static dragStartXY = new Vector2()
private static dragPrevY = 0
private static dragAxis: "none" | "x" | "y" = "none"
/* -------------------------------- handlers -------------------------------- */
private static readonly onMouseMove = (e: MouseEvent) => this.onMove(e)
private static readonly onWheel = (e: WheelEvent) => {
this.deltaScrollY += MathUtils.clamp(normalizeWheelY(e), -MAX_SCROLL_PER_EVENT, MAX_SCROLL_PER_EVENT)
}
private static readonly onTouchStart = (e: TouchEvent) => {
const touch = e.touches[0]
if (!touch) return
this.dragStartXY.set(touch.clientX, touch.clientY)
this.dragPrevY = touch.clientY
this.dragAxis = "none"
this.onMove(touch)
}
private static readonly onTouchMove = (e: TouchEvent) => {
const touch = e.touches[0] ?? e.changedTouches[0]
if (!touch) return
this.onMove(touch)
this.updateDrag(touch)
}
private static readonly onTouchEnd = () => {
this.dragAxis = "none"
}
/* --------------------------------- public --------------------------------- */
static init() {
this.resize()
document.addEventListener("mousemove", this.onMouseMove, { passive: true })
document.addEventListener("wheel", this.onWheel, { passive: true })
// passive is safe: `touch-action: none` on <body> suppresses native scrolling for us
document.addEventListener("touchstart", this.onTouchStart, { passive: true })
document.addEventListener("touchmove", this.onTouchMove, { passive: true })
document.addEventListener("touchend", this.onTouchEnd, { passive: true })
document.addEventListener("touchcancel", this.onTouchEnd, { passive: true })
}
static resize() {
this.invViewportWidth = 1 / Properties.viewportWidth
this.invViewportHeight = 1 / Properties.viewportHeight
}
/** Clears per-frame deltas. Call after every consumer has read them. */
static postUpdate() {
this.deltaScrollY = 0
}
static destroy() {
document.removeEventListener("mousemove", this.onMouseMove)
document.removeEventListener("wheel", this.onWheel)
document.removeEventListener("touchstart", this.onTouchStart)
document.removeEventListener("touchmove", this.onTouchMove)
document.removeEventListener("touchend", this.onTouchEnd)
document.removeEventListener("touchcancel", this.onTouchEnd)
}
/* -------------------------------- internal -------------------------------- */
private static onMove(e: MouseEvent | Touch) {
this.mouseXY.set(e.clientX * this.invViewportWidth * 2 - 1, 1 - e.clientY * this.invViewportHeight * 2)
this.mouseScreenXY.set(e.clientX * this.invViewportWidth, 1 - e.clientY * this.invViewportHeight)
this.hasPointer = true
}
/**
* Axis-locked vertical drag. The first `DRAG_AXIS_LOCK_PX` of travel decide whether the
* gesture is a scroll or a horizontal sweep across the bars; once locked it stays locked
* for the rest of the touch, so a diagonal sweep doesn't also spin the helix.
*/
private static updateDrag(touch: Touch) {
if (this.dragAxis === "none") {
const dx = touch.clientX - this.dragStartXY.x
const dy = touch.clientY - this.dragStartXY.y
if (Math.hypot(dx, dy) < DRAG_AXIS_LOCK_PX) return
this.dragAxis = Math.abs(dy) > Math.abs(dx) ? "y" : "x"
this.dragPrevY = touch.clientY // drop the pre-lock travel so the helix doesn't jump
}
if (this.dragAxis !== "y") return
// dragging up scrolls forward, matching the wheel's sign
const delta = (this.dragPrevY - touch.clientY) * DRAG_SCALE
this.deltaScrollY += MathUtils.clamp(delta, -MAX_SCROLL_PER_EVENT, MAX_SCROLL_PER_EVENT)
this.dragPrevY = touch.clientY
}
}
src/js/utils/normalizeWheel.ts
/**
* Wheel deltas do not arrive in a single unit. `deltaMode` says which one the browser used:
* pixels (every modern Chromium/WebKit build), lines (Firefox on some platforms), or pages.
* Scrolling by a raw `deltaY` therefore moves a wildly different amount per browser.
*
* Converting to pixels here keeps the scroll consumer from having to know any of that.
*
* Reduced from Facebook's `normalizeWheel` to the one axis and one unit this demo uses. The
* original also handled `wheelDelta`, `event.detail` and Gecko's `axis` property — all dead in
* every browser that can run WebGL2, so none of it survives here.
*/
// WheelEvent.DOM_DELTA_* as plain constants — the named forms are instance properties, awkward
// to reach from a switch.
const DELTA_MODE_PIXEL = 0
const DELTA_MODE_LINE = 1
const DELTA_MODE_PAGE = 2
const PIXELS_PER_LINE = 40
const PIXELS_PER_PAGE = 800
/** Vertical wheel movement in pixels, whichever unit the browser reported it in. */
export function normalizeWheelY(event: WheelEvent): number {
switch (event.deltaMode) {
case DELTA_MODE_LINE:
return event.deltaY * PIXELS_PER_LINE
case DELTA_MODE_PAGE:
return event.deltaY * PIXELS_PER_PAGE
case DELTA_MODE_PIXEL:
default:
return event.deltaY
}
}
src/js/utils/properties.ts
import { EffectComposer } from "postprocessing"
import { Vector2, WebGLRenderer } from "three"
import type GUI from "three/examples/jsm/libs/lil-gui.module.min.js"
import { QUALITY } from "../configs/XylophoneConfig"
/**
* Shared render state and the uniform objects every material reads by reference.
*
* Deliberately a static singleton rather than something threaded through constructors. There is
* exactly one renderer, one clock and one composer in this demo, and the alternative is passing
* the same four things into every component and pass. The tradeoff to be aware of: `gl` and
* `composer` are only valid after `App`'s constructor has run, which is why consumers that touch
* `Properties.gl` assert it non-null — they are all built after that point.
*/
export class Properties {
static viewportWidth = 0
static viewportHeight = 0
static dpr = Math.min(QUALITY.maxDpr, window.devicePixelRatio || 1)
static reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
static gl?: WebGLRenderer
static composer = new EffectComposer()
// clock
static time = 0
static deltaTime = 0
// shared by reference into materials — assigning `.value` updates every consumer
static globalUniforms = {
u_time: { value: 0 },
u_deltaTime: { value: 0 },
u_resolution: { value: new Vector2() },
}
/** Dev-only tuning panel. null in production, and lil-gui is never bundled there. */
static gui: GUI | null = null
}
src/shaders/fluid/fluidAdvectionFrag.glsl
// Pipeline step 6: advection — the field carries itself downstream.
//
// Semi-Lagrangian: instead of pushing each texel forward (which leaves gaps), trace backwards
// along the velocity to find where this texel's contents came from, and sample there.
//
// MANUAL_FILTERING does that sample with an explicit bilinear fetch rather than relying on the
// hardware's, for platforms that cannot linearly filter float textures.
varying vec2 vUv;
uniform sampler2D u_tVelocity;
uniform sampler2D u_tSource;
uniform vec2 u_texelSize;
uniform float u_dt;
uniform float u_dissipation;
vec4 bilerp(sampler2D sam, vec2 uv, vec2 tsize) {
vec2 st = uv / tsize - 0.5;
vec2 iuv = floor(st);
vec2 fuv = fract(st);
vec4 a = texture2D(sam, (iuv + vec2(0.5, 0.5)) * tsize);
vec4 b = texture2D(sam, (iuv + vec2(1.5, 0.5)) * tsize);
vec4 c = texture2D(sam, (iuv + vec2(0.5, 1.5)) * tsize);
vec4 d = texture2D(sam, (iuv + vec2(1.5, 1.5)) * tsize);
return mix(mix(a, b, fuv.x), mix(c, d, fuv.x), fuv.y);
}
void main() {
vec4 result;
#ifdef MANUAL_FILTERING
vec2 coord = vUv - u_dt * bilerp(u_tVelocity, vUv, u_texelSize).xy * u_texelSize;
result = bilerp(u_tSource, coord, u_texelSize);
#else
vec2 coord = vUv - u_dt * texture2D(u_tVelocity, vUv).xy * u_texelSize;
result = texture2D(u_tSource, coord);
#endif
// dissipation is a per-second factor; raise it to (dt*60) so the trail fades at the same rate
// regardless of refresh rate (at 60fps the exponent is 1, preserving the tuned look).
gl_FragColor.rgb = result.rgb * pow(u_dissipation, u_dt * 60.0);
gl_FragColor.a = 1.0;
}src/shaders/fluid/fluidBaseVert.glsl
// Shared vertex shader for every fluid pass.
//
// Beyond the uv, it precomputes the four neighbour coordinates (left/right/top/bottom) that the
// finite-difference passes need. Doing it here rather than in each fragment shader means the
// interpolator hands them over for free instead of every fragment recomputing four offsets.
attribute vec2 position;
uniform vec2 u_texelSize;
varying vec2 vUv;
varying vec2 vL;
varying vec2 vR;
varying vec2 vT;
varying vec2 vB;
void main() {
vUv = position.xy * 0.5 + 0.5;
vL = vUv - vec2(u_texelSize.x, 0.0);
vR = vUv + vec2(u_texelSize.x, 0.0);
vT = vUv + vec2(0.0, u_texelSize.y);
vB = vUv - vec2(0.0, u_texelSize.y);
gl_Position = vec4(position, 0.0, 1.0);
}src/shaders/fluid/fluidClearFrag.glsl
// Fades a field toward zero by a per-second factor. Used on pressure between frames so stale
// pressure does not accumulate across the Jacobi iterations of the next solve.
varying vec2 vUv;
uniform sampler2D u_tTexture;
uniform float u_value;
uniform float u_dt;
void main() {
// u_value is a per-second dissipation factor; normalize by dt (×60 baseline) so the decay is
// refresh-rate independent (exponent is 1 at 60fps, matching the previously tuned value).
gl_FragColor.rgb = pow(u_value, u_dt * 60.0) * texture2D(u_tTexture, vUv).rgb;
gl_FragColor.a = 1.0;
}src/shaders/fluid/fluidCurlFrag.glsl
// Pipeline step 1: curl (vorticity) of the velocity field — how much each texel is rotating.
//
// A plain finite-difference of the cross terms. Stored in R for the vorticity pass to read; on
// its own it changes nothing about the flow.
uniform sampler2D u_tVelocity;
varying highp vec2 vL;
varying highp vec2 vR;
varying highp vec2 vT;
varying highp vec2 vB;
void main() {
float L = texture2D(u_tVelocity, vL).y;
float R = texture2D(u_tVelocity, vR).y;
float T = texture2D(u_tVelocity, vT).x;
float B = texture2D(u_tVelocity, vB).x;
float vorticity = R - L - T + B;
gl_FragColor = vec4(0.5 * vorticity, 0.0, 0.0, 1.0);
}src/shaders/fluid/fluidDivergenceFrag.glsl
// Pipeline step 3: divergence — net flow in or out of each texel.
//
// Incompressible fluid must have zero divergence everywhere; advection and the splat both break
// that. This measures the error so the pressure solve can correct it.
varying highp vec2 vL;
varying highp vec2 vR;
varying highp vec2 vT;
varying highp vec2 vB;
uniform sampler2D u_tVelocity;
void main() {
float L = texture2D(u_tVelocity, vL).x;
float R = texture2D(u_tVelocity, vR).x;
float T = texture2D(u_tVelocity, vT).y;
float B = texture2D(u_tVelocity, vB).y;
float div = 0.5 * (R - L + T - B);
gl_FragColor = vec4(div, 0.0, 0.0, 1.0);
}src/shaders/fluid/fluidGradientSubtractFrag.glsl
// Pipeline step 5: subtract the pressure gradient from velocity.
//
// This is the step that actually enforces incompressibility: it removes exactly the component of
// the flow that was pushing texels apart, leaving the field divergence-free.
uniform sampler2D u_tPressure;
uniform sampler2D u_tVelocity;
varying highp vec2 vUv;
varying highp vec2 vL;
varying highp vec2 vR;
varying highp vec2 vT;
varying highp vec2 vB;
void main() {
// see fluidPressureFrag: ClampToEdge targets give the free-slip boundary for free
float L = texture2D(u_tPressure, vL).x;
float R = texture2D(u_tPressure, vR).x;
float T = texture2D(u_tPressure, vT).x;
float B = texture2D(u_tPressure, vB).x;
vec2 velocity = texture2D(u_tVelocity, vUv).xy;
velocity.xy -= vec2(R - L, T - B);
gl_FragColor = vec4(velocity, 0.0, 1.0);
}
src/shaders/fluid/fluidPressureFrag.glsl
// Pipeline step 4: one Jacobi iteration of the pressure solve.
//
// Finds the pressure field whose gradient cancels the divergence measured in step 3. Each pass
// averages the four neighbours minus the local divergence, so pressure spreads outward one texel
// per iteration — the sim runs this repeatedly (FLUID.pressureIterations) to let it propagate.
uniform sampler2D u_tPressure;
uniform sampler2D u_tDivergence;
varying highp vec2 vUv;
varying highp vec2 vL;
varying highp vec2 vR;
varying highp vec2 vT;
varying highp vec2 vB;
void main() {
// Neighbour taps need no clamping: the targets are ClampToEdge, so sampling past an edge
// repeats the edge texel, which is the free-slip boundary this solve wants anyway.
float L = texture2D(u_tPressure, vL).x;
float R = texture2D(u_tPressure, vR).x;
float T = texture2D(u_tPressure, vT).x;
float B = texture2D(u_tPressure, vB).x;
float C = texture2D(u_tPressure, vUv).x;
float divergence = texture2D(u_tDivergence, vUv).x;
float pressure = (L + R + B + T - divergence) * 0.25;
gl_FragColor = vec4(pressure, 0.0, 0.0, 1.0);
}src/shaders/fluid/fluidSplatFrag.glsl
// Pipeline step 0 (input): injects the pointer's motion into the velocity field.
//
// Runs only when the cursor moved, before the solve. Everything downstream just redistributes
// what this writes, so with no pointer movement the field decays to nothing.
uniform sampler2D u_tTarget;
uniform float u_aspectRatio;
uniform vec3 u_splatColor;
uniform vec2 u_splatPosition;
uniform vec2 u_prevPoint;
uniform float u_splatRadius;
varying vec2 vUv;
void main() {
// Splat along the swipe segment (u_prevPoint -> u_splatPosition) instead of a single point, so
// fast strokes read as a continuous brush rather than a dotted trail of gaussian blobs. A
// zero-length segment (new stroke) collapses to a point gaussian via the length guard below.
vec2 uv = vUv;
vec2 a = u_prevPoint;
vec2 b = u_splatPosition;
// aspect-correct x so the brush stays round rather than stretched
uv.x *= u_aspectRatio;
a.x *= u_aspectRatio;
b.x *= u_aspectRatio;
// closest point on segment [a, b] to this fragment, then gaussian falloff from that distance
vec2 ab = b - a;
float t = clamp(dot(uv - a, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);
vec2 p = uv - (a + t * ab);
vec3 splat = exp(-dot(p, p) / (u_splatRadius / 50.0)) * u_splatColor;
vec3 base = texture2D(u_tTarget, vUv).xyz;
vec3 result = base + splat;
gl_FragColor = vec4(result, 1.0);
}src/shaders/fluid/fluidVorticityFrag.glsl
// Pipeline step 2: vorticity confinement — pushes velocity back along the curl gradient.
//
// A coarse grid bleeds angular momentum, so eddies flatten out within a frame or two. This adds
// the lost swirl back, which is what keeps the cursor wake curling instead of just smearing.
// Purely an aesthetic term: physically the solve is complete without it.
varying vec2 vUv;
varying vec2 vL;
varying vec2 vR;
varying vec2 vT;
varying vec2 vB;
uniform sampler2D u_tVelocity;
uniform sampler2D u_tCurl;
uniform float u_curl;
uniform float u_dt;
void main() {
float L = texture2D(u_tCurl, vL).x;
float R = texture2D(u_tCurl, vR).x;
float T = texture2D(u_tCurl, vT).x;
float B = texture2D(u_tCurl, vB).x;
float C = texture2D(u_tCurl, vUv).x;
vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L));
force /= length(force) + 0.0001;
force *= u_curl * C;
force.y *= -1.0;
vec2 vel = texture2D(u_tVelocity, vUv).xy;
vel += force * u_dt;
// Clamp so a frame hitch (large u_dt) can't spike the field into a value that overflows the
// half-float velocity target to Inf/NaN, which would poison the sim permanently.
vel = clamp(vel, -1000.0, 1000.0);
gl_FragColor = vec4(vel, 0.0, 1.0);
}src/shaders/postprocessing/blitFrag.glsl
uniform sampler2D u_texture;
varying vec2 v_uv;
void main() {
gl_FragColor = texture2D(u_texture, v_uv);
}src/shaders/postprocessing/blitVert.glsl
attribute vec2 position;
varying vec2 v_uv;
void main() {
v_uv = position * 0.5 + 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}src/shaders/xylophone/glassNormalFrag.glsl
// Glass G-buffer: view-space normal encoded in RGB, glass mask in A.
// Pairs with xylophoneVert.glsl (same per-instance spin/rotation), used as
// scene.overrideMaterial so the buffer matches the animated pose.
// `viewMatrix` is auto-provided by three's ShaderMaterial.
varying vec3 vNormal; // world-space normal from xylophoneVert
void main() {
vec3 N = normalize(vNormal);
// face the normal camera-ward on back faces (display material is DoubleSide)
if (!gl_FrontFacing) N = -N;
// view-space normal: xy is the screen-plane refraction direction
vec3 viewN = normalize((viewMatrix * vec4(N, 0.0)).xyz);
// rgb = encoded view normal, a = 1 marks glass (clear-color alpha 0 elsewhere)
gl_FragColor = vec4(viewN * 0.5 + 0.5, 1.0);
}
src/shaders/xylophone/xylophoneFrag.glsl
/* -------------------------------- uniforms -------------------------------- */
// color interaction
uniform sampler2D u_tFluid;
uniform sampler2D u_tGradient;
uniform float u_fluidStrength;
uniform float u_tintStrength;
uniform float u_tintGlow;
uniform float u_tintWrap;
// background
uniform sampler2D u_tBackdrop;
uniform float u_transmission;
uniform float u_refractStrength;
uniform float u_fresnelPower;
// iridescent
uniform float u_iridStrength;
uniform float u_iridCycles;
uniform float u_iridShift;
uniform float u_iridPower;
uniform float u_iridBody;
/* -------------------------------- varyings -------------------------------- */
varying vec3 vNormal;
varying vec3 vWorldPos;
varying vec2 vScreenUv;
varying float vTintOffset;
const float PI = 3.141592653589793;
/* -------------------------------------------------------------------------- */
/* look */
/* -------------------------------------------------------------------------- */
// Fixed values behind the look. Anything a reader would want to sweep live is a uniform with a
// lil-gui binding instead; these are the ones that were dialled in once and left alone.
const vec3 LIGHT_DIR = vec3(0.4, 1.0, 0.35); // key light, above and slightly camera-right
const float AMBIENT_MIX = 0.6; // ambient vs diffuse split (diffuse takes the rest)
const float FLUID_GATE_MAX = 0.2; // fluid speed that counts as a full-strength hover
const float TINT_OPACITY_DROP = 0.6; // tinted bars go this much less transmissive, so colour reads
const float SHEEN_MIX = 0.35; // peak sheen blend at grazing angles
const float RIM_LIFT = 0.06; // flat brightness added at the silhouette, to separate overlaps
/* -------------------------------------------------------------------------- */
/* iridescence */
/* -------------------------------------------------------------------------- */
vec3 iridescence(float t) {
return 0.5 + 0.5 * cos(2.0 * PI * (t + vec3(0.0, 0.33, 0.67)));
}
/* -------------------------------------------------------------------------- */
/* env sampling (edge sheen) */
/* -------------------------------------------------------------------------- */
/**
* Ground -> horizon -> sky ramp, used for the fresnel edge sheen.
*
* This stands in for an environment map. The sheen is mixed in at 0.35 * fresnel and so only
* reads at grazing angles, where a real HDR's detail is indistinguishable from a gradient —
* not worth a megabyte of equirect texture.
*/
vec3 proceduralEnv(vec3 dir) {
float t = clamp(dir.y * 0.5 + 0.5, 0.0, 1.0);
vec3 ground = vec3(0.12, 0.12, 0.14);
vec3 horizon = vec3(0.60, 0.62, 0.68);
vec3 sky = vec3(0.95, 0.97, 1.0);
return t < 0.5 ? mix(ground, horizon, t * 2.0) : mix(horizon, sky, (t - 0.5) * 2.0);
}
/* -------------------------------------------------------------------------- */
/* main */
/* -------------------------------------------------------------------------- */
void main() {
// get normal
vec3 N = normalize(vNormal);
if (!gl_FrontFacing)
N = -N;
vec3 V = normalize(cameraPosition - vWorldPos);
// soft lighting
float diffuse = max(dot(N, normalize(LIGHT_DIR)), 0.0);
vec3 ambLo = vec3(0.45, 0.46, 0.5); // ambient floor, lifted toward white as the normal points up
vec3 ambient = mix(ambLo, vec3(1.0), N.y * 0.5 + 0.5);
vec3 lighting = ambient * AMBIENT_MIX + diffuse * (1.0 - AMBIENT_MIX);
// tint reveal
float velocity = smoothstep(0.0, FLUID_GATE_MAX, length(texture2D(u_tFluid, vScreenUv).xy));
float reveal = clamp(velocity * u_fluidStrength, 0.0, 1.0);
vec3 tint = texture2D(u_tGradient, vec2(fract(vTintOffset * u_tintWrap), 0.5)).rgb;
// white body on rest
vec3 albedo = mix(vec3(1.0), tint, reveal * u_tintStrength);
vec3 body = lighting * albedo;
// add transmission
vec2 buv = vScreenUv + N.xy * u_refractStrength;
vec3 trans = texture2D(u_tBackdrop, buv).rgb;
trans = mix(trans, tint, reveal);
// blend backdrop through the body (less transmissive where the tint is revealed)
vec3 frosted = mix(body, trans, u_transmission * (1.0 - TINT_OPACITY_DROP * reveal));
// grazing-angle term shared by sheen and iridescence
float edge = clamp(1.0 - max(dot(N, V), 0.0), 0.0, 1.0);
float fres = pow(edge, u_fresnelPower);
// add sheen
vec3 sheen = proceduralEnv(reflect(-V, N));
vec3 color = mix(frosted, sheen, fres * SHEEN_MIX);
color += fres * RIM_LIFT;
// add hover color
color += tint * reveal * u_tintGlow;
// strong rainbow at grazing edges
float phase = edge * u_iridCycles + N.y * 0.5 + u_iridShift;
vec3 irid = iridescence(phase);
color += irid * (pow(edge, u_iridPower) * u_iridStrength + u_iridBody * edge);
// main
gl_FragColor = vec4(color, 1.0);
}
src/shaders/xylophone/xylophoneVert.glsl
uniform float u_time;
uniform float u_spinSpeed; // idle rotation
uniform float u_swingScale; // 0..1 global swing amount (0 under prefers-reduced-motion)
uniform vec3 u_swingAxis; // local-space axis the strike swing rotates about
attribute vec3 aPos;
attribute vec4 aRot;
attribute float aTintOffset;
attribute float aStrikeTime; // u_time when this bar was last struck (-1e9 = never)
varying vec3 vNormal;
varying vec3 vWorldPos;
varying vec2 vScreenUv;
varying float vTintOffset;
// strike swing — damped pendulum
const float SWING_AMP = 0.58; // peak swing angle (radians)
const float SWING_FREQ = 16.0; // bob frequency (rad/s)
const float SWING_DECAY = 4.5; // damping (1/s)
/* -------------------------------------------------------------------------- */
/* rotation */
/* -------------------------------------------------------------------------- */
vec3 rotateByQuat(vec3 v, vec4 q) {
return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v);
}
vec4 qmul(vec4 a, vec4 b) {
return vec4(a.w * b.xyz + b.w * a.xyz + cross(a.xyz, b.xyz), a.w * b.w - dot(a.xyz, b.xyz));
}
/* -------------------------------------------------------------------------- */
/* main */
/* -------------------------------------------------------------------------- */
void main() {
// get rotation
float a = u_time * u_spinSpeed * 0.5;
vec4 spin = vec4(0.0, sin(a), 0.0, cos(a));
vec4 rot = qmul(spin, aRot);
// strike swing — damped pendulum about the bar's origin, decaying since aStrikeTime.
// at rest (never struck) dt is huge, so env -> 0 and the pose is unchanged.
float dt = u_time - aStrikeTime;
float env = step(0.0, dt) * exp(-dt * SWING_DECAY);
float ang = env * SWING_AMP * sin(dt * SWING_FREQ) * u_swingScale;
float halfAng = ang * 0.5;
vec4 swing = vec4(normalize(u_swingAxis) * sin(halfAng), cos(halfAng));
rot = qmul(rot, swing);
// update position
vec3 transformedPos = aPos + rotateByQuat(position, rot);
vec4 worldPos = modelMatrix * vec4(transformedPos, 1.0);
// get normal
vec3 rotatedNormal = rotateByQuat(normal, rot);
// main
gl_Position = projectionMatrix * viewMatrix * worldPos;
// set normals
vWorldPos = worldPos.xyz;
vNormal = normalize(mat3(modelMatrix) * rotatedNormal);
vScreenUv = gl_Position.xy / gl_Position.w * 0.5 + 0.5;
vTintOffset = aTintOffset;
}
src/shaders/xylophoneBg/xylophoneBgFrag.glsl
uniform vec3 u_color;
uniform float u_debugPattern;
varying vec2 v_uv;
void main() {
// diagonal ramp, bottom-left -> top-right
float t = smoothstep(0.0, 1.0, v_uv.y * 0.5 + (1.0 - v_uv.x) * 0.5);
vec3 color = mix(u_color, vec3(1.0), t - 0.3);
/* ----------------------------------- dev ---------------------------------- */
// checkerboard — makes the frosted transmission obvious while tuning
if (u_debugPattern > 0.5) {
vec2 c = step(0.5, fract(v_uv * 10.0));
color = vec3(abs(c.x - c.y));
}
/* ---------------------------------- main ---------------------------------- */
gl_FragColor = vec4(color, 1.0);
}
src/shaders/xylophoneBg/xylophoneBgVert.glsl
varying vec2 v_uv;
void main() {
v_uv = uv;
// fullscreen quad in clip space — ignore the camera so the bg stays fixed on screen
gl_Position = vec4(position.xy, 0.0, 1.0);
}
src/vite-env.d.ts
/// <reference types="vite/client" />
declare module "*.glsl?raw" {
const content: string
export default content
}
declare module "*.vert?raw" {
const content: string
export default content
}
declare module "*.frag?raw" {
const content: string
export default content
}
vite.config.js
import { defineConfig } from "vite"
export default defineConfig({
base: "./", // relative asset URLs, so the build works from any sub-path
root: "./src",
publicDir: false, // every asset is imported from src/assets, so Vite hashes and emits them
build: {
outDir: "../dist",
emptyOutDir: true,
minify: "terser",
terserOptions: {
compress: {
drop_debugger: true,
// strip dev logging, but keep warn/error — they carry the asset + audio failure paths
pure_funcs: ["console.log", "console.info", "console.debug"],
},
},
rollupOptions: {
output: {
manualChunks: { three: ["three"] }, // own chunk so app edits don't bust the three cache
},
},
// three is ~550kB minified and deliberately its own chunk — don't warn about a known size
chunkSizeWarningLimit: 600,
// keep the model + audio sample as separate hashed files rather than inlining them as base64
assetsInlineLimit: 4096,
sourcemap: false,
},
server: {
port: 5173,
open: true,
},
})
Media credits and license evidence실행 안내·자료
README.md
## Credits
- Fluid solver adapted from [WebGL-Fluid-Simulation](https://github.com/PavelDoGreat/WebGL-Fluid-Simulation) by Pavel Dobryakov (MIT)
- Built with [three.js](https://threejs.org/) and [postprocessing](https://github.com/pmndrs/postprocessing)
## License
[MIT](LICENSE)
LICENSE실행 안내·자료
MIT License
Copyright (c) 2009 - 2026 [Codrops](https://codrops.com)
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.
Bundled dependency licenses실행 안내·자료
three@0.182.0 — LICENSE
The MIT License
Copyright © 2010-2025 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.
postprocessing@6.39.3 — LICENSE.md
Copyright © 2015 Raoul van Rüschen
This software is provided 'as-is', without any express or implied warranty. In
no event will the authors be held liable for any damages arising from the use of
this software.
Permission is granted to anyone to use this software for any purpose, including
commercial applications, and to alter it and redistribute it freely, subject to
the following restrictions:
1. The origin of this software must not be misrepresented; you must not claim
that you wrote the original software. If you use this software in a product,
an acknowledgment in the product documentation would be appreciated but is
not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
BEHIND THE EXAMPLE
이 장면을 만드는 원리
회전하는 막대의 로컬 공간에서 충돌 판정
화면 크기·입력·동시 작업을 고정하고 실제 렌더링 실패와 비용을 관찰합니다.
- 이 예제에서는
- hitBarIndex는 시간에 따른 회전과 막대별 회전을 합쳐 각 인스턴스 행렬을 만들고 광선을 역변환합니다. 로컬 박스와 만나는 후보 중 거리가 작은 인덱스를 선택합니다.
코드와 함께 확인하기
코드에서 찾기
hitBarIndexpicking.ts화면용 회전과 대응하는 행렬로 ray를 localBox 공간에 옮깁니다.
직접 해보기
회전 중 서로 겹쳐 보이는 막대의 가장자리와 중앙을 누릅니다.
살펴볼 변화보이는 막대와 선택 인덱스가 일치하는지 확인하고 클릭과 소리 시작의 관계를 별도로 검증해야 합니다.
