Skip to content

Instantly share code, notes, and snippets.

@kazzohikaru
Created February 13, 2026 01:46
Show Gist options
  • Select an option

  • Save kazzohikaru/af7ce748a5379481d29f79cd8259bf97 to your computer and use it in GitHub Desktop.

Select an option

Save kazzohikaru/af7ce748a5379481d29f79cd8259bf97 to your computer and use it in GitHub Desktop.
Text Reflection 3D
<div id="loader">Loading Scene...</div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/[email protected]/build/three.module.js",
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/",
"opentype.js": "https://unpkg.com/opentype.js@latest/dist/opentype.module.js"
}
}
</script>
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { FontLoader } from 'three/addons/loaders/FontLoader.js';
import { TTFLoader } from 'three/addons/loaders/TTFLoader.js';
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
import { Water } from 'three/addons/objects/Water.js';
import GUI from 'three/addons/libs/lil-gui.module.min.js';
let scene, camera, renderer, controls;
let water, textGroup;
let ambientLight, dirLight;
// --- FONTS ---
const fontBaseURL = 'https://unpkg.com/@fontsource/[email protected]/files/';
const fontFiles = {
'Thin': 'inter-latin-100-normal.woff',
'Light': 'inter-latin-300-normal.woff',
'Regular': 'inter-latin-400-normal.woff',
'Medium': 'inter-latin-500-normal.woff',
'Bold': 'inter-latin-700-normal.woff',
'Black': 'inter-latin-900-normal.woff'
};
const loadedFonts = {};
const ttfLoader = new TTFLoader();
const fontLoader = new FontLoader();
// --- PARAMETERS ---
const params = {
// Text
text: 'FUTURE',
fontWeight: 'Black',
size: 20,
letterSpacing: -1.0,
textColor: '#000000',
// Water Properties
distortionScale: 3.7,
speed: 1.0,
waterColor: '#ffffff', // Color of the water itself
sunColor: '#ffffff', // Glare color (what was white)
waterOpacity: 1.0, // Reflection opacity
// Environment
bgColor: '#ffffff',
// Lighting
ambientColor: '#ffffff',
ambientIntensity: 0.5,
dirColor: '#ffffff',
dirIntensity: 1.5,
lightX: -10,
lightY: 10,
lightZ: 10
};
init();
animate();
function init() {
// 1. Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(params.bgColor);
scene.fog = new THREE.FogExp2(params.bgColor, 0.0025);
// 2. Camera
camera = new THREE.PerspectiveCamera(35, window.innerWidth / window.innerHeight, 1, 1000);
camera.position.set(0, 15, 300);
// 3. Renderer
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);
// 4. Lighting
// Ambient (Fill light)
ambientLight = new THREE.AmbientLight(params.ambientColor, params.ambientIntensity);
scene.add(ambientLight);
// Directional (Sun)
dirLight = new THREE.DirectionalLight(params.dirColor, params.dirIntensity);
dirLight.position.set(params.lightX, params.lightY, params.lightZ);
scene.add(dirLight);
// 5. Water
const waterGeometry = new THREE.PlaneGeometry(10000, 10000);
const textureLoader = new THREE.TextureLoader();
const waterNormals = textureLoader.load('https://threejs.org/examples/textures/water/Water_1_M_Normal.jpg', function(t) {
t.wrapS = t.wrapT = THREE.RepeatWrapping;
});
water = new Water(
waterGeometry,
{
textureWidth: 512,
textureHeight: 512,
waterNormals: waterNormals,
sunDirection: dirLight.position.clone().normalize(),
sunColor: params.sunColor,
waterColor: params.waterColor,
distortionScale: params.distortionScale,
fog: scene.fog !== undefined
}
);
// IMPORTANT: Enable transparency to work with Opacity
water.material.transparent = true;
water.material.opacity = params.waterOpacity;
water.rotation.x = -Math.PI / 2;
scene.add(water);
// 6. Text
loadFontAndCreateText();
// 7. Controls
controls = new OrbitControls(camera, renderer.domElement);
controls.enablePan = false;
controls.minDistance = 20;
controls.maxDistance = 200;
controls.maxPolarAngle = Math.PI / 2 - 0.05;
setupGUI();
window.addEventListener('resize', onWindowResize);
}
function loadFontAndCreateText() {
const loaderEl = document.getElementById('loader');
const weightKey = params.fontWeight;
const fileName = fontFiles[weightKey];
if (loadedFonts[weightKey]) {
loaderEl.style.opacity = 0;
createText(loadedFonts[weightKey]);
return;
}
loaderEl.style.opacity = 1;
const url = fontBaseURL + fileName;
ttfLoader.load(url, (json) => {
const font = fontLoader.parse(json);
loadedFonts[weightKey] = font;
loaderEl.style.opacity = 0;
createText(font);
}, undefined, (err) => {
console.error(err);
loaderEl.innerText = "Error loading font";
});
}
function createText(font) {
if (textGroup) {
scene.remove(textGroup);
textGroup.traverse((c) => { if(c.isMesh) c.geometry.dispose(); });
}
textGroup = new THREE.Group();
if (!params.text) return;
// IMPORTANT: Use MeshStandardMaterial so that light affects the text
const material = new THREE.MeshStandardMaterial({
color: params.textColor,
roughness: 0.4,
metalness: 0.1
});
const chars = params.text.split('');
let xOffset = 0;
chars.forEach((char) => {
if (char === ' ') {
xOffset += params.size / 2 + params.letterSpacing;
return;
}
const geo = new TextGeometry(char, {
font: font, size: params.size, height: 0,
curveSegments: 5, bevelEnabled: false
});
geo.computeBoundingBox();
const width = geo.boundingBox.max.x - geo.boundingBox.min.x;
const mesh = new THREE.Mesh(geo, material);
mesh.position.x = xOffset;
textGroup.add(mesh);
xOffset += width + params.letterSpacing;
});
const box = new THREE.Box3().setFromObject(textGroup);
const center = new THREE.Vector3();
box.getCenter(center);
textGroup.position.x = -center.x;
textGroup.position.y = 2;
textGroup.position.z = 0;
scene.add(textGroup);
}
function updateLightsAndWater() {
// Update physical light sources
ambientLight.color.set(params.ambientColor);
ambientLight.intensity = params.ambientIntensity;
dirLight.color.set(params.dirColor);
dirLight.intensity = params.dirIntensity;
dirLight.position.set(params.lightX, params.lightY, params.lightZ);
// Update water parameters that depend on light
if (water) {
water.material.uniforms['sunColor'].value.set(params.sunColor);
water.material.uniforms['waterColor'].value.set(params.waterColor);
water.material.uniforms['sunDirection'].value.copy(dirLight.position).normalize();
// Reflection opacity
water.material.opacity = params.waterOpacity;
}
}
function setupGUI() {
const gui = new GUI({ title: 'Scene Settings' });
// Text
const fText = gui.addFolder('Text');
fText.add(params, 'text').name('Content').onChange(() => loadFontAndCreateText());
fText.add(params, 'fontWeight', Object.keys(fontFiles)).onChange(() => loadFontAndCreateText());
fText.addColor(params, 'textColor').name('Color').onChange(() => loadFontAndCreateText());
fText.add(params, 'size', 5, 100).onChange(() => { if(loadedFonts[params.fontWeight]) createText(loadedFonts[params.fontWeight]); });
fText.add(params, 'letterSpacing', -5, 10).name('Spacing').onChange(() => { if(loadedFonts[params.fontWeight]) createText(loadedFonts[params.fontWeight]); });
// Water
const fWater = gui.addFolder('Water & Reflection');
fWater.add(params, 'waterOpacity', 0, 1).name('Refl. Opacity').onChange(updateLightsAndWater); // OPACITY
fWater.addColor(params, 'waterColor').name('Water Color').onChange(updateLightsAndWater);
fWater.addColor(params, 'sunColor').name('Sun/Glare Color').onChange(updateLightsAndWater); // GLARE COLOR
fWater.add(params, 'distortionScale', 0, 8).name('Ripple Strength').onChange((v) => water.material.uniforms['distortionScale'].value = v);
fWater.add(params, 'speed', 0, 5).name('Flow Speed');
// Lighting
const fLight = gui.addFolder('Lighting');
fLight.addColor(params, 'dirColor').name('Sun Light Color').onChange(updateLightsAndWater);
fLight.add(params, 'dirIntensity', 0, 5).name('Sun Intensity').onChange(updateLightsAndWater);
fLight.add(params, 'lightX', -50, 50).name('Sun X').onChange(updateLightsAndWater);
fLight.add(params, 'lightY', 0, 50).name('Sun Y').onChange(updateLightsAndWater);
fLight.addColor(params, 'ambientColor').name('Ambient Color').onChange(updateLightsAndWater);
fLight.add(params, 'ambientIntensity', 0, 2).name('Amb Intensity').onChange(updateLightsAndWater);
// BG
gui.addColor(params, 'bgColor').name('Background').onChange((v) => {
scene.background.set(v);
scene.fog.color.set(v);
});
fText.open();
fWater.open();
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function animate() {
requestAnimationFrame(animate);
if (water) water.material.uniforms['time'].value += 1.0 / 60.0 * params.speed;
controls.update();
renderer.render(scene, camera);
}
body { margin: 0; overflow: hidden; background-color: #ffffff; }
canvas { display: block; }
.lil-gui.root {
position: absolute;
bottom: 20px;
right: 20px;
top: auto !important;
}
#loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-family: sans-serif;
font-size: 14px;
color: #555;
background: rgba(255, 255, 255, 0.9);
padding: 15px 30px;
border-radius: 8px;
pointer-events: none;
transition: opacity 0.3s;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment