Kumyu HomeLearn
Creative practice · Three.js / GLSL / WebGL2

A shader is something
you can take apart.

Change a height. Move a light. Watch which part of the image responds. Then implement the small calculation that caused it.

This field guide accompanies a local, three-stage ocean lab. The picture is a restrained visual approximation built from two sine waves, a custom light calculation, a specular highlight, and a crest-color mask. It does not simulate water flow, wave breaking, shoreline foam, reflection, or refraction, and it is not a physically based water material.

Start with a prediction

Use Node.js 24 or newer and npm. Run the downloaded folder with the commands below, then open 127.0.0.1:4173. In a source checkout, run node setup.js first. Three.js 0.186.1 is installed locally; the scene has no runtime CDN or downloaded visual assets.

npm ci --ignore-scripts
npm start
  1. Reset everything → Flat plane → Wireframe. Reset restores the controls, camera, and time zero, with animation paused; notebook text is cleared. Find the triangles. They are the places where the shape can bend.
  2. Vertex waves → Capture baseline → Amplitude. Capture pauses at 2 seconds. Predict “the outline rises, but crest spacing stays fixed.” Write it, change amplitude only, press Compare one changed control, and inspect the outline.
  3. Reset → Vertex waves → Capture baseline → Frequency. Predict whether more crests fit across the same plane. Compare at the same paused time.
  4. Light & color → Light angle. Predict where the bright area moves. The light's horizontal direction changes; its elevation and the mesh shape stay fixed.
  5. Specular. Raise the highlight's strength. This control scales intensity; it is not a PBR roughness parameter. The crest mask makes selected high areas pale without moving vertices.
  6. Play → Speed. Change the phase rate. Or pause and use Step for an exact 1/30-second advance. Record what changed before trying another value.

Use Tab to reach controls and arrow keys on sliders. Change one slider or the wireframe checkbox per baseline comparison; several changed controls produce a request to narrow the trial. Notes stay in page memory. The lab starts paused, and turning on OS reduced motion pauses it. If WebGL2 is unavailable, the page explains the failure and offers a static learning alternative; that image is not a successful GPU render.

Write one row for each trial; observations begin blank.
Stage / timeOne changePredictionObservation / reason
Vertex waves, baseline at 2 sAmplitude 0.35 → 0.7Higher outline; same crest spacingFill in after running

Where the picture comes from

GeometryPositions connected into triangles
Vertex shaderMove each vertex and pass values onward
FragmentsRasterize triangles into covered regions
Fragment shaderCalculate the visible surface color

A shader is a program run by the GPU. In this lab, GLSL source is supplied to Three.js ShaderMaterial and rendered with WebGLRenderer. The geometry supplies vertex attributes such as position. The vertex shader chooses a height at each grid point; the fragment shader combines color, light, and the highlight. Three.js supplies the built-in shader attributes and matrices for this material. This is the GLSL/WebGL route; WebGPU and TSL examples use a separate route. ShaderMaterial reference

PlaneGeometry(12, 12, 96, 96) creates the grid. The geometry is rotated onto the local xz plane, so y is height. There are 97 × 97 grid vertices and 96 × 96 × 2 triangles. With only a few vertices, the outline cannot follow a finely sampled wave, even if fragment colors suggest detail. PlaneGeometry · BufferGeometry

The wave is a function, not a water solver

h(x,z,t) = A × [sin(kx − st) + 0.5 sin(0.7kz + 0.8st)] / 1.5
amplitude A  ·  spatial frequency k  ·  phase rate s  ·  time t

src/shaders.js applies this height to each vertex on the GPU. It does not rewrite the CPU position buffer every frame. src/math.js has numeric counterparts for checking the calculation. A simulation would define how a system's state evolves; here the authored formula directly samples a chosen shape at a given time. Rendering converts that shape and its appearance rules into an image.

A uniform is a shared shader input for a draw, such as amplitude or time. JavaScript changes uniforms.uAmplitude.value, and all vertices use that value during that draw. Time advances when animation runs; the shader then evaluates the same function at a new time. A uniform is different from an attribute, which can hold a different value for each vertex. Uniforms in ShaderMaterial

Normals connect shape to light

The normal tells which direction a surface faces. For the height function y = h(x,z), compute its slopes dx = ∂h/∂x and dz = ∂h/∂z, then use normalize(vec3(-dx, 1, -dz)). Keeping a flat upward normal after moving vertices would give the wave the wrong lighting. The shader passes the corrected normal to the fragment shader and normalizes it again after interpolation.

Light-facing slopes become brighter. The custom specular term also compares the normal with a direction derived from the light and view. The foam mask depends on the ratio of height to amplitude: it fades in from 0.65 to 0.95. These thresholds are authored color choices; they do not describe where actual foam forms.

A coordinate is an address in a space

“Up” and “toward the light” only make sense after deciding which space is being used. Keep the lighting directions in the same space.

SpaceMeaning in the labNext transform
Model / localThe mesh's own xz grid; wave height changes y.modelMatrix
WorldThe shared scene; the light direction begins here.viewMatrix
ViewThe scene relative to the camera; lighting is compared here.projectionMatrix
ClipThe homogeneous coordinates written to gl_Position; clipping and perspective division precede screen placement.The graphics pipeline
vec4 viewPosition = modelViewMatrix * vec4(p, 1.0);
gl_Position = projectionMatrix * viewPosition;
// modelViewMatrix combines model → world → view.

The normal uses normalMatrix to reach view space. The world light direction uses viewMatrix as a direction, with no translation. The view direction comes from -viewPosition.xyz, since the camera is at the origin of view space. A normal requires a normal matrix rather than a position transform, especially with unequal scaling. Object transforms · Camera matrices · Normal matrix

Implement three small calculations

Open exercises.js. Its three TODOs are deliberately separate from the working scene. The first practices one wave; the scene combines two. Start with the simplest input, predict its value, and then run a check.

  1. singleWave(x, time, settings): return a sine-wave height. With amplitude zero, every height should be zero.
  2. normalFromGradient(dx, dz): return an upward unit vector. A flat patch should return [0, 1, 0].
  3. crestMask(height, amplitude): return a smooth 0–1 mask from the 0.65–0.95 height ratio. Amplitude zero must return zero.
npm run exercise -- --list
npm run exercise -- wave-height
npm run exercise -- normal
npm run exercise -- foam

Read answers.md and reference-answers.js after your attempt. Follow the actual rendering path in src/app.js → src/ocean.js → src/shaders.js and compare the math in src/math.js.

A real GLSL edit: widen the white crest

  1. In Light & color, capture a baseline at 2 seconds. Keep the default controls and note the white areas.
  2. In src/shaders.js, find smoothstep(0.65, 0.95, vHeight / uAmplitude). Change only the first threshold from 0.65 to 0.35; keep 0.95.
  3. Predict “more of the surface becomes white; the outline is unchanged.” Reload the page, select the same stage, and capture again at 2 seconds with the same controls.
  4. Compare the white coverage and silhouette. Restore 0.65, reload, and run npm test plus npm run test:browser.

The edit is in the fragment shader, so it changes color rather than vertex positions. Leave the CPU counterpart in src/math.js alone for this temporary experiment; its numeric result still represents the original threshold. Restore the shader before checking the final source.

Check the result you actually ran

npm test
npm run test:browser

Numeric checks protect values and limits; the browser smoke check uses already installed Google Chrome with headless ANGLE SwiftShader software WebGL2 and downloads no browser. It tests shader compilation and a rendered scene. WebGLRenderer provides shader diagnostics, and a compile/link failure means that material cannot render. A software/headless GPU check has a different device boundary from a real phone or laptop. Keep browser/GPU failures explicit, inspect the image when available, and record performance only after measuring it. This guide reports no FPS result. WebGLRenderer diagnostics

All linked technical references are official Three.js documentation, checked 2026-10-03 UTC. This date records verification, not publication. 日本語版へ ↓