Skip to content

Instantly share code, notes, and snippets.

@greggman
Last active December 18, 2025 07:55
Show Gist options
  • Select an option

  • Save greggman/102e89329ed0ace6d6aa8d44203505cb to your computer and use it in GitHub Desktop.

Select an option

Save greggman/102e89329ed0ace6d6aa8d44203505cb to your computer and use it in GitHub Desktop.
Three.js HSL Cylinder
:root {
color-scheme: light dark;
}
body {
margin: 0px;
background-color: #333;
}
#ui {
position: absolute;
z-index: 1;
left: 0;
top: 0;
}
<!-- https://unpkg.com/three@0.182.0/build/three.tsl.js -->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.182.0/build/three.webgpu.js",
"three/webgpu": "https://unpkg.com/three@0.182.0/build/three.webgpu.js",
"three/tsl": "https://unpkg.com/three@0.182.0/build/three.tsl.js",
"three/addons/": "https://unpkg.com/three@0.182.0/examples/jsm/"
}
}
</script>
<div id="ui"><input type="color"></div>
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import {
//MeshBasicNodeMaterial,
Fn as tslFn,
positionLocal,
positionWorld,
vec2, vec3, float,
atan,
length,
clamp,
abs,
mod,
oneMinus,
TWO_PI,
} from 'three/tsl';
const hsl2rgb = tslFn(([ h, s, l ]) => {
const k = vec3(0.0, 4.0, 2.0);
const p = abs(
mod(h.mul(6.0).add(k), 6.0)
.sub(3.0)
).sub(1.0);
const rgb = clamp(p, 0.0, 1.0);
const c = oneMinus(abs(l.mul(2.0).sub(1.0))).mul(s);
return vec3(l).add(c.mul(rgb.sub(0.5)));
});
const cylinderHSL = tslFn(() => {
const p = positionWorld;
const h = atan(p.z, p.x)
.div(TWO_PI)
.add(0.5);
const s = clamp(
length(vec2(p.x, p.z)),
0.0,
1.0
);
const l = oneMinus(
p.y.add(-1.0).mul(-0.5)
);
return hsl2rgb(h, s, l);
});
const adapter = await navigator.gpu?.requestAdapter();
const device = await adapter?.requestDevice();
const canvas = document.createElement('canvas');
Object.assign(canvas.style, {
width: '100%',
height: '100%',
aspectRatio: '1 / 1',
});
class CircleCurve extends THREE.Curve {
constructor() {
super();
this.start = 0;
this.end = Math.PI * 2;
this.radius = 1;
}
getPoint(t) {
const a = this.start + t * (this.end - this.start);
const tx = Math.cos(a) * this.radius;
const ty = 0;
const tz = Math.sin(a) * this.radius;
return new THREE.Vector3( tx, ty, tz );
}
}
const circleCurve = new CircleCurve();
function makeRing(radius = 1, start = 0, end = Math.PI * 1) {
const tubularSegments = 128;
const tubeRadius = 0.005;
const radialSegments = 24;
const closed = false;
circleCurve.radius = Math.max(radius, 0.01);
circleCurve.start = start;
const range = Math.max(end - start, 0.01);
circleCurve.end = start + range;
return new THREE.TubeGeometry(circleCurve, tubularSegments, tubeRadius, radialSegments, closed);
}
const renderer = new THREE.WebGPURenderer({ device, canvas });
document.body.append(canvas);
const scene = new THREE.Scene();
const clipPlane1 = new THREE.Plane(new THREE.Vector3(1, 0, 0), 0);
const clipPlane2 = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const clipPlane3 = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
const clippingGroup = new THREE.ClippingGroup();
clippingGroup.clippingPlanes = [ clipPlane1, clipPlane2, clipPlane3 ];
clippingGroup.enabled = true;
clippingGroup.clipIntersection = true;
scene.add(clippingGroup);
const circleMat = new THREE.MeshBasicMaterial({color: 0xFF0000});
const circleGeo = makeRing();
const circle = new THREE.Mesh(circleGeo, circleMat);
circle.position.y = 1.1;
circle.rotation.y = Math.PI;
scene.add(circle);
const camera = new THREE.PerspectiveCamera(40, 1, 1, 10);
camera.position.set(2.5, 2.5, 2.5);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enablePan = false;
controls.enableZoom = false;
const lineMaterial = new THREE.MeshBasicNodeMaterial();
lineMaterial.colorNode = vec3(0.5, 0.5, 0.5);
const radialSegments = 16;
const bigCylinderGeo = new THREE.CylinderGeometry(
1, // radius top,
1, // radius bottom,
2, // height,
radialSegments * 4, // radial segments
1, // height segments
false, // open ended
0, // start
Math.PI * 2, // end
);
const smallCylinderGeo = new THREE.CylinderGeometry(
1, // radius top,
1, // radius bottom,
2, // height,
radialSegments * 4, // radial segments
1, // height segments
false, // open ended
Math.PI * 2 * 1 / 3 * 0, // start
Math.PI * 2 * 2 / 3, // end
);
const plane = new THREE.PlaneGeometry(1, 2);
const planeLine = (() => {
const points = [];
points.push(new THREE.Vector3(-0.5, -1, 0));
points.push(new THREE.Vector3( 0.5, -1, 0));
points.push(new THREE.Vector3( 0.5, 1, 0));
points.push(new THREE.Vector3(-0.5, 1, 0));
points.push(new THREE.Vector3(-0.5, -1, 0));
return new THREE.BufferGeometry().setFromPoints(points);
})();
const hslMaterial = new THREE.MeshBasicNodeMaterial({
side: THREE.DoubleSide,
});
hslMaterial.colorNode = cylinderHSL();
const bigCylinder = new THREE.Mesh(bigCylinderGeo, hslMaterial);
const smallCylinder = new THREE.Mesh(smallCylinderGeo, hslMaterial);
const p1Mesh = new THREE.Mesh(plane, hslMaterial);
const p2Mesh = new THREE.Mesh(plane, hslMaterial);
const p1Line = new THREE.Line(planeLine, lineMaterial);
const p2Line = new THREE.Line(planeLine, lineMaterial);
p1Mesh.position.x = 0.5;
p2Mesh.position.x = 0.5;
p1Line.position.x = 0.5;
p2Line.position.x = 0.5;
const p1 = new THREE.Object3D();
const p2 = new THREE.Object3D();
const cyl = new THREE.Object3D();
p1.add(p1Mesh);
p2.add(p2Mesh);
p1.add(p1Line);
p2.add(p2Line);
cyl.add(bigCylinder);
cyl.add(smallCylinder);
cyl.add(p1);
cyl.add(p2);
p1.rotation.y = Math.PI * -0.5 + Math.PI * 2 * 2 / 3;
p2.rotation.y = Math.PI * -0.5;
scene.add(cyl);
const stuff = new THREE.Object3D();
//scene.add(stuff);
clippingGroup.add(stuff);
const midDisc = new THREE.Object3D();
scene.add(midDisc);
const markerMaterial = new THREE.MeshBasicMaterial({
color: 0x0FF0000,
});
const marker = (() => {
const sphere = new THREE.SphereGeometry(0.05);
return new THREE.Mesh(sphere, markerMaterial);
})();
scene.add(marker);
{
const points = [];
for (let i = 0; i <= radialSegments; ++i) {
const a = i / radialSegments * Math.PI * 2;
const r = 1.01;
points.push(new THREE.Vector3(Math.cos(a) * r, 0, Math.sin(a) * r));
}
const circle = new THREE.BufferGeometry().setFromPoints(points);
for (let y = -1; y <= 1; y += 0.25) {
const line = new THREE.Line(circle, lineMaterial);
line.position.y = y;
line.scale.x = 1.01;
line.scale.z = 1.01;
stuff.add(line);
}
const gridGeo = (() => {
const points = [];
const hParts = 5;
for (let i = 1; i < hParts; ++i) {
const x = i / hParts;
points.push(new THREE.Vector3(x, -1, 0));
points.push(new THREE.Vector3(x, 1, 0));
}
const vParts = 8;
for (let i = 1; i < vParts; ++i) {
const y = i / vParts * 2 - 1;
points.push(new THREE.Vector3(0, y, 0));
points.push(new THREE.Vector3(1, y, 0));
}
return new THREE.BufferGeometry().setFromPoints(points);
})();
const pg1 = new THREE.LineSegments(gridGeo, lineMaterial);
const pg2 = new THREE.LineSegments(gridGeo, lineMaterial);
pg1.position.z = -0.01;
pg2.position.z = 0.01;
p1.add(pg1);
p2.add(pg2);
const helper = new THREE.PolarGridHelper(
1, // radius,
radialSegments, // sectors,
5, // rings,
64, // divisions
0x808080,
0x808080,
);
midDisc.add(helper);
for (let r = 0; r < 1; r += 0.2) {
for (let y = -1; y <= 1; y += 2) {
const line = new THREE.Line(circle, lineMaterial);
line.position.y = y * 1.01;
line.scale.x = r;
line.scale.z = r;
stuff.add(line);
}
}
for (let i = 0; i <= radialSegments; ++i) {
const line = new THREE.Line(planeLine, lineMaterial);
line.position.x = 0.5;
line.scale.x = 1.01;
line.scale.y = 1.01;
const o = new THREE.Object3D();
o.add(line);
o.rotation.y = i / radialSegments * Math.PI * 2;
stuff.add(o);
}
}
await renderer.init();
function render() {
renderer.render(scene, camera);
}
controls.addEventListener('change', render);
function setHSL(hsl) {
const [h, s, l] = hsl;
const a = h * Math.PI * 2 + Math.PI;
const r = s;
const y = l * 2 - 1;
marker.position.x = Math.cos(a) * r;
marker.position.z = Math.sin(a) * r;
marker.position.y = y;
cyl.rotation.y = -a + Math.PI * 0.5;
bigCylinder.scale.y = l;
bigCylinder.position.y = -(1 - l);
midDisc.position.y = l * 2 - 1 + 0.01;
const a1 = a + Math.PI + 0.5;
clipPlane2.normal.set(Math.cos(a1), 0, Math.sin(a1));
const a2 = a1 + 60 * Math.PI / 180;
clipPlane1.normal.set(Math.cos(a2), 0, Math.sin(a2));
clipPlane3.constant = l * 2 - 1;
markerMaterial.color.setHSL(0, 0, (l + 0.5) % 1);
circle.geometry.dispose();
circle.geometry = makeRing(s, 0, h * Math.PI * 2);
render();
}
const observer = new ResizeObserver(entries => {
const entry = entries[0];
const width = entry.devicePixelContentBoxSize?.[0].inlineSize ||
entry.contentBoxSize[0].inlineSize * devicePixelRatio;
const height = entry.devicePixelContentBoxSize?.[0].blockSize ||
entry.contentBoxSize[0].blockSize * devicePixelRatio;
renderer.setSize(
Math.max(1, Math.min(width, device.limits.maxTextureDimension2D)),
Math.max(1, Math.min(height, device.limits.maxTextureDimension2D)),
false,
);
render();
});
observer.observe(renderer.domElement);
const cssColorToRGBA8 = (() => {
const canvas = new OffscreenCanvas(1, 1);
const ctx = canvas.getContext('2d', {willReadFrequently: true});
return cssColor => {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = cssColor;
ctx.fillRect(0, 0, 1, 1);
return Array.from(ctx.getImageData(0, 0, 1, 1).data);
};
})();
const hslCSS = (h, s, l) => `hsl(${h * 360 | 0}, ${s * 100}%, ${l * 100 | 0}%)`;
let hsl = [.16, 9, 0.5];
const colorElem = document.querySelector('input[type="color"]');
const colorRE = /#(..)(..)(..)/
colorElem.addEventListener('input', function() {
const rgb = colorRE.exec(this.value).slice(1, 4).map(v => parseInt(v, 16) / 255);
hsl = rgbToHsl(rgb);
setHSL(hsl);
});
colorElem.value = `#${cssColorToRGBA8(hslCSS(...hsl)).slice(0, 3).map(v => v.toString(16).padStart(2, '0')).join('')}`;
setHSL(hsl);
const euclideanModulo = (n, m) => ((n % m) + m) % m;
function rgbToHsl(rgb) {
const min = Math.min(rgb[0], rgb[1], rgb[2]);
const max = Math.max(rgb[0], rgb[1], rgb[2]);
const delta = max - min;
const l = (max + min) / 2;
if (delta === 0) {
return [0, 0, l];
}
const s = l < 0.5
? delta / (max + min)
: delta / (2 - max - min);
const deltaR = (((max - rgb[0]) / 6) + (delta / 2)) / delta;
const deltaG = (((max - rgb[1]) / 6) + (delta / 2)) / delta;
const deltaB = (((max - rgb[2]) / 6) + (delta / 2)) / delta;
const h = rgb[0] === max
? deltaB - deltaG
: rgb[1] === max
? (1 / 3) + deltaR - deltaB
: (2 / 3) + deltaG - deltaR;
return [euclideanModulo(h, 1), s, l];
}
{"name":"Three.js HSL Cylinder","settings":{},"filenames":["index.html","index.css","index.js"]}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment