← Mike Higgins Home
Custom Code Experiment •

ThreeJS: Torus Knot 3

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ThreeJS: Torus Knot Remix • Cyberpunk Duo (No Stars)</title>
    <style>
        body { margin: 0; overflow: hidden; background-color: #0a0a14; }
        .info {
            position: fixed;
            bottom: 20px;
            right: 20px;
            color: #aaccff;
            font-family: 'Segoe UI', system-ui, monospace;
            font-size: 15px;
            letter-spacing: 2px;
            pointer-events: none;
            z-index: 20;
            backdrop-filter: blur(8px);
            padding: 8px 20px;
            border-radius: 40px;
            background: rgba(10, 20, 30, 0.5);
            border: 1px solid rgba(100, 180, 255, 0.3);
            box-shadow: 0 0 20px rgba(0, 160, 255, 0.2);
            text-shadow: 0 0 8px rgba(0,200,255,0.5);
        }
        .info span {
            color: #ff66cc;
            font-weight: bold;
            margin: 0 4px;
        }
    </style>
    <!-- Import map using esm.sh (same as original) -->
    <script type="importmap">
        {
            "imports": {
                "three": "https://esm.sh/three@0.128.0",
                "three/addons/": "https://esm.sh/three@0.128.0/examples/jsm/"
            }
        }
    </script>
</head>
<body>

    <script type="module">
        import * as THREE from 'three';

        // --- Setup core: scene, camera, renderer (with subtle tone mapping) ---
        const scene = new THREE.Scene();
        scene.background = new THREE.Color(0x0a0a14); // deep cosmic

        const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
        camera.position.set(5, 2, 8);

        const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        renderer.toneMapping = THREE.ACESFilmicToneMapping;
        renderer.toneMappingExposure = 1.0;
        document.body.appendChild(renderer.domElement);

        // --- Environment map (Swedish Royal Castle) provides all reflections, no lights needed ---
        const loader = new THREE.CubeTextureLoader();
        const envTexture = loader.load([
            'https://threejs.org/examples/textures/cube/SwedishRoyalCastle/px.jpg',
            'https://threejs.org/examples/textures/cube/SwedishRoyalCastle/nx.jpg',
            'https://threejs.org/examples/textures/cube/SwedishRoyalCastle/py.jpg',
            'https://threejs.org/examples/textures/cube/SwedishRoyalCastle/ny.jpg',
            'https://threejs.org/examples/textures/cube/SwedishRoyalCastle/pz.jpg',
            'https://threejs.org/examples/textures/cube/SwedishRoyalCastle/nz.jpg'
        ]);
        scene.environment = envTexture; // automatically used by all physical materials

        // --- UTILITY: create a knot group (mesh, wireframe, points) with custom style ---
        function createKnotGroup(geometryParams, style) {
            const {
                p = 2, q = 3,
                radius = 1.2, tube = 0.35,
                tubularSegments = 256, radialSegments = 24,
                materialColor, materialOpacity = 0.9, metalness = 0.8, roughness = 0.15, envIntensity = 2.5,
                wireframeColor,
                pointsColor, pointsSize = 0.09, pointsAdditive = true
            } = style;

            // Base geometry
            const geo = new THREE.TorusKnotGeometry(radius, tube, tubularSegments, radialSegments, p, q);

            // Main reflective shape (physical material, takes env map from scene)
            const mat = new THREE.MeshPhysicalMaterial({
                color: materialColor,
                metalness: metalness,
                roughness: roughness,
                clearcoat: 1.0,
                clearcoatRoughness: 0.1,
                emissive: 0x000000,
                envMapIntensity: envIntensity,
                transparent: materialOpacity < 1.0,
                opacity: materialOpacity,
                side: THREE.DoubleSide
            });
            const mesh = new THREE.Mesh(geo, mat);

            // Wireframe edges
            const edges = new THREE.EdgesGeometry(geo);
            const lineMat = new THREE.LineBasicMaterial({
                color: wireframeColor,
                depthTest: false,      // always on top
                linewidth: 1            // (not supported everywhere, but adds intent)
            });
            const wireframe = new THREE.LineSegments(edges, lineMat);

            // Vertex nodes (points) with optional additive blending
            const pointsGeo = new THREE.BufferGeometry();
            pointsGeo.setAttribute('position', geo.attributes.position.clone()); // use same positions
            const pointsMat = new THREE.PointsMaterial({
                color: pointsColor,
                size: pointsSize,
                sizeAttenuation: true,
                depthTest: true,
                blending: pointsAdditive ? THREE.AdditiveBlending : THREE.NormalBlending
            });
            const points = new THREE.Points(pointsGeo, pointsMat);

            const group = new THREE.Group();
            group.add(mesh);
            group.add(wireframe);
            group.add(points);

            return group;
        }

        // --- STYLE 1: Primary knot (cyan/blue tones) ---
        const style1 = {
            p: 2, q: 3,
            radius: 1.2,
            tube: 0.35,
            tubularSegments: 280,
            radialSegments: 32,
            materialColor: 0x33aaff,
            materialOpacity: 0.7,
            metalness: 0.9,
            roughness: 0.1,
            envIntensity: 3.0,
            wireframeColor: 0x66ffff, // cyan
            pointsColor: 0x88ddff,
            pointsSize: 0.1,
            pointsAdditive: true
        };
        const knot1 = createKnotGroup({}, style1);
        knot1.scale.set(1.2, 1.2, 1.2); // make it slightly larger

        // --- STYLE 2: Secondary knot (magenta/pink tones) with different knot parameters ---
        const style2 = {
            p: 3, q: 7,           // different knot structure
            radius: 1.0,
            tube: 0.3,
            tubularSegments: 240,
            radialSegments: 24,
            materialColor: 0xff44aa,
            materialOpacity: 0.6,
            metalness: 0.7,
            roughness: 0.2,
            envIntensity: 2.2,
            wireframeColor: 0xff88ff, // magenta
            pointsColor: 0xffaa66,
            pointsSize: 0.12,
            pointsAdditive: true
        };
        const knot2 = createKnotGroup({}, style2);
        knot2.scale.set(0.9, 0.9, 0.9); // a bit smaller

        // Add both knots to scene
        scene.add(knot1);
        scene.add(knot2);

        // --- small extra: floating dust particles (tiny, subtle) near the center ---
        const dustGeo = new THREE.BufferGeometry();
        const dustCount = 400;
        const dustPos = new Float32Array(dustCount * 3);
        for (let i = 0; i < dustCount * 3; i += 3) {
            // random within a sphere radius 3
            const r = Math.random() * 3.5;
            const theta = Math.random() * Math.PI * 2;
            const phi = Math.acos(2 * Math.random() - 1);
            dustPos[i] = Math.sin(phi) * Math.cos(theta) * r;
            dustPos[i+1] = Math.sin(phi) * Math.sin(theta) * r;
            dustPos[i+2] = Math.cos(phi) * r;
        }
        dustGeo.setAttribute('position', new THREE.BufferAttribute(dustPos, 3));
        const dustMat = new THREE.PointsMaterial({
            color: 0x99aaff,
            size: 0.04,
            sizeAttenuation: true,
            blending: THREE.AdditiveBlending,
            transparent: true,
            opacity: 0.4
        });
        const dustParticles = new THREE.Points(dustGeo, dustMat);
        scene.add(dustParticles);

        // --- Animation variables for color cycling (subtle) ---
        let clock = new THREE.Clock();

        // --- Animation loop ---
        function animate() {
            requestAnimationFrame(animate);

            const delta = clock.getDelta();
            const elapsedTime = performance.now() / 1000; // seconds

            // Rotate knots independently to create interweaving effect
            knot1.rotation.x = elapsedTime * 0.25;
            knot1.rotation.y = elapsedTime * 0.5;
            knot1.rotation.z = elapsedTime * 0.1;

            knot2.rotation.x = elapsedTime * 0.4 + 1.2;
            knot2.rotation.y = elapsedTime * 0.3 + 0.5;
            knot2.rotation.z = elapsedTime * 0.2;

            // Subtle color shifts on wireframes and points (optional, just for life)
            // Access first child material (wireframe) and second points material (index 1 and 2)
            // knot1 children: [0]=mesh, [1]=wireframe, [2]=points
            if (knot1.children[1] && knot1.children[1].material) {
                // slight hue shift over minutes, barely noticeable but adds organic feel
                const hue = (elapsedTime * 0.02) % 1.0;
                knot1.children[1].material.color.setHSL(hue, 0.9, 0.7); // wireframe
            }
            if (knot1.children[2] && knot1.children[2].material) {
                const hue = (elapsedTime * 0.01 + 0.5) % 1.0;
                knot1.children[2].material.color.setHSL(hue, 1.0, 0.7); // points
            }
            if (knot2.children[1] && knot2.children[1].material) {
                const hue = (elapsedTime * 0.03 + 0.8) % 1.0;
                knot2.children[1].material.color.setHSL(hue, 1.0, 0.6);
            }
            if (knot2.children[2] && knot2.children[2].material) {
                const hue = (elapsedTime * 0.015 + 0.2) % 1.0;
                knot2.children[2].material.color.setHSL(hue, 0.9, 0.7);
            }

            // Dust particles slowly follow
            dustParticles.rotation.y += 0.0005;
            dustParticles.rotation.x += 0.0003;

            // Dynamic camera orbit (more playful than original)
            const radius = 8.0;
            const speed = 0.2;
            const angle = elapsedTime * speed;
            const height = Math.sin(elapsedTime * 0.4) * 1.8;

            camera.position.x = radius * Math.sin(angle);
            camera.position.z = radius * Math.cos(angle);
            camera.position.y = height + 1.0; // offset

            camera.lookAt(0, 0, 0);

            renderer.render(scene, camera);
        }

        animate();

        // --- Handle window resize ---
        window.addEventListener('resize', onWindowResize, false);
        function onWindowResize() {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        }

        // Small console reminder that env map is active
        console.log('✨ Remix: Dual knots with cyberpunk glow & subtle dust. No lights, all env map. (Stars removed)');
    </script>
</body>
</html>