Created
February 19, 2026 12:56
-
-
Save kbouw/bd00ff48718a80550e672b3a2bd87889 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Triplanar Mapping</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { background: #111216; overflow-x: hidden; } | |
| #root { min-height: 100vh; } | |
| </style> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.9/babel.min.js"></script> | |
| </head> | |
| <body> | |
| <div id="root"></div> | |
| <script type="text/babel"> | |
| const { useState, useEffect, useRef, useCallback, useMemo } = React; | |
| // ============================================================ | |
| // CONSTANTS | |
| // ============================================================ | |
| const COLORS = { | |
| bg: "#111216", | |
| text: "#e8e6e3", | |
| textMuted: "#6a6e76", | |
| textDim: "#4a4e56", | |
| accent: "#4ade80", | |
| xAxis: "#f07474", | |
| yAxis: "#4ade80", | |
| zAxis: "#8cb4ff", | |
| warn: "#f0c674", | |
| purple: "#a78bfa", | |
| // Colorblind-safe high-contrast pair (blue/orange) | |
| cbBlue: "#5ba3f5", | |
| cbOrange: "#e8923a", | |
| }; | |
| const FONTS = { | |
| body: "'IBM Plex Sans', -apple-system, sans-serif", | |
| mono: "'JetBrains Mono', monospace", | |
| }; | |
| // ============================================================ | |
| // UTILITY | |
| // ============================================================ | |
| function useAnimVal(target, rate = 0.08) { | |
| const cur = useRef(target); | |
| const [val, setVal] = useState(target); | |
| const frame = useRef(null); | |
| useEffect(() => { | |
| let on = true; | |
| const tick = () => { | |
| if (!on) return; | |
| const d = target - cur.current; | |
| if (Math.abs(d) > 0.001) { | |
| cur.current += d * rate; | |
| setVal(cur.current); | |
| frame.current = requestAnimationFrame(tick); | |
| } else { cur.current = target; setVal(target); } | |
| }; | |
| frame.current = requestAnimationFrame(tick); | |
| return () => { on = false; if (frame.current) cancelAnimationFrame(frame.current); }; | |
| }, [target, rate]); | |
| return val; | |
| } | |
| function InsightCard({ children, style }) { | |
| return ( | |
| <div style={{ | |
| background: "rgba(255,255,255,0.02)", | |
| border: "1px solid rgba(255,255,255,0.04)", | |
| borderRadius: 8, padding: "12px 16px", | |
| fontFamily: FONTS.mono, fontSize: 12, | |
| color: COLORS.textMuted, lineHeight: 1.6, ...style, | |
| }}>{children}</div> | |
| ); | |
| } | |
| // ============================================================ | |
| // TERRAIN GEOMETRY (shared across panels) | |
| // ============================================================ | |
| function generateTerrain(width, segments) { | |
| const points = []; | |
| for (let i = 0; i <= segments; i++) { | |
| const t = i / segments; | |
| const x = t * width; | |
| let y; | |
| if (t < 0.12) y = 0; | |
| else if (t < 0.25) y = Math.sin((t - 0.12) / 0.13 * Math.PI * 0.5) * 30; | |
| else if (t < 0.35) y = 30 + Math.sin((t - 0.25) / 0.10 * Math.PI * 0.5) * 50; | |
| else if (t < 0.42) y = 80 + (t - 0.35) / 0.07 * 90; | |
| else if (t < 0.55) y = 170 + Math.sin((t - 0.42) / 0.13 * Math.PI) * 8; | |
| else if (t < 0.62) y = 170 - (t - 0.55) / 0.07 * 80; | |
| else if (t < 0.72) y = 90 - Math.sin((t - 0.62) / 0.10 * Math.PI * 0.5) * 40; | |
| else if (t < 0.85) y = 50 + Math.sin((t - 0.72) / 0.13 * Math.PI) * 15; | |
| else y = 50 * (1 - (t - 0.85) / 0.15); | |
| points.push({ x, y }); | |
| } | |
| return points; | |
| } | |
| function getSegmentAngle(p1, p2) { | |
| const dx = p2.x - p1.x; | |
| const dy = p2.y - p1.y; | |
| return Math.atan2(Math.abs(dy), Math.abs(dx)) * 180 / Math.PI; | |
| } | |
| // ============================================================ | |
| // PANEL 1: THE PROBLEM — UV Stretching (kept from original) | |
| // ============================================================ | |
| function TheProblem() { | |
| const [angle, setAngle] = useState(45); | |
| const [isDragging, setIsDragging] = useState(false); | |
| const trackRef = useRef(null); | |
| const handleDrag = useCallback((cx) => { | |
| if (!trackRef.current) return; | |
| const r = trackRef.current.getBoundingClientRect(); | |
| const p = Math.max(0, Math.min(1, (cx - r.left) / r.width)); | |
| setAngle(Math.round(p * 90)); | |
| }, []); | |
| useEffect(() => { | |
| if (!isDragging) return; | |
| const mv = (e) => handleDrag(e.touches ? e.touches[0].clientX : e.clientX); | |
| const up = () => setIsDragging(false); | |
| window.addEventListener("mousemove", mv); window.addEventListener("mouseup", up); | |
| window.addEventListener("touchmove", mv); window.addEventListener("touchend", up); | |
| return () => { window.removeEventListener("mousemove", mv); window.removeEventListener("mouseup", up); window.removeEventListener("touchmove", mv); window.removeEventListener("touchend", up); }; | |
| }, [isDragging, handleDrag]); | |
| const stretchFactor = 1 / Math.max(Math.cos(angle * Math.PI / 180), 0.05); | |
| const animStretch = useAnimVal(stretchFactor, 0.12); | |
| const severity = animStretch > 5 ? "severe" : animStretch > 2 ? "moderate" : "minimal"; | |
| const sevColor = animStretch > 5 ? COLORS.xAxis : animStretch > 2 ? COLORS.warn : COLORS.yAxis; | |
| const surfW = 240; | |
| const surfH = 120; | |
| const cellSize = 10; | |
| return ( | |
| <div> | |
| <div style={{ marginBottom: 20 }}> | |
| <h3 style={{ fontFamily: FONTS.body, fontSize: 18, fontWeight: 400, color: COLORS.text, margin: "0 0 6px" }}>UV mapping breaks on angled surfaces</h3> | |
| <p style={{ fontFamily: FONTS.body, fontSize: 14, color: COLORS.textMuted, margin: 0, lineHeight: 1.5 }}> | |
| When a surface tilts away from the projection plane, the texture stretches. Drag the slider to tilt the surface. | |
| </p> | |
| </div> | |
| <div style={{ marginBottom: 24 }}> | |
| <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}> | |
| <span style={{ fontSize: 12, color: COLORS.textMuted, fontFamily: FONTS.mono }}>Surface angle</span> | |
| <span style={{ fontSize: 13, color: COLORS.text, fontFamily: FONTS.mono }}>{angle}°</span> | |
| </div> | |
| <div ref={trackRef} style={{ position: "relative", height: 36, cursor: "pointer", touchAction: "none", userSelect: "none" }} | |
| onMouseDown={(e) => { setIsDragging(true); handleDrag(e.clientX); }} | |
| onTouchStart={(e) => { setIsDragging(true); handleDrag(e.touches[0].clientX); }}> | |
| <div style={{ position: "absolute", left: 0, right: 0, top: 14, height: 8, borderRadius: 4, background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.04)" }} /> | |
| <div style={{ position: "absolute", left: 0, top: 14, width: `${(angle / 90) * 100}%`, height: 8, borderRadius: 4, background: `linear-gradient(90deg, ${COLORS.yAxis}, ${COLORS.warn}, ${COLORS.xAxis})` }} /> | |
| <div style={{ | |
| position: "absolute", left: `${(angle / 90) * 100}%`, top: 8, transform: "translateX(-50%)", | |
| width: 20, height: 20, borderRadius: "50%", background: "#e8e6e3", | |
| border: "2px solid rgba(0,0,0,0.3)", cursor: "grab", | |
| boxShadow: "0 2px 8px rgba(0,0,0,0.3)", | |
| }} /> | |
| <div style={{ position: "absolute", left: 4, top: 28, fontSize: 9, color: "#3a3e46", fontFamily: FONTS.mono }}>0° flat</div> | |
| <div style={{ position: "absolute", right: 4, top: 28, fontSize: 9, color: "#3a3e46", fontFamily: FONTS.mono }}>90° vertical</div> | |
| </div> | |
| </div> | |
| <div style={{ display: "flex", gap: 12, marginBottom: 16 }}> | |
| <div style={{ flex: 1, padding: "12px", background: "rgba(255,255,255,0.02)", borderRadius: 8, border: "1px solid rgba(255,255,255,0.04)" }}> | |
| <div style={{ fontSize: 10, color: COLORS.textDim, textTransform: "uppercase", letterSpacing: "0.08em", fontFamily: FONTS.mono, marginBottom: 10 }}>Standard UV</div> | |
| <svg width="100%" height={surfH + 40} viewBox={`0 0 ${surfW} ${surfH + 40}`} style={{ overflow: "visible" }}> | |
| <defs> | |
| <pattern id="checker-uv" x="0" y="0" width={cellSize * 2} height={cellSize * 2} patternUnits="userSpaceOnUse"> | |
| <rect width={cellSize} height={cellSize} fill="rgba(140,180,255,0.15)" /> | |
| <rect x={cellSize} y={cellSize} width={cellSize} height={cellSize} fill="rgba(140,180,255,0.15)" /> | |
| </pattern> | |
| </defs> | |
| <g transform={`translate(${surfW/2}, ${surfH/2 + 10})`}> | |
| <rect x={-surfW/2 + 20} y={-20} width={surfW - 40} height={40} | |
| fill="url(#checker-uv)" | |
| transform={`rotate(${-angle * 0.4}) scale(1, ${Math.max(0.15, Math.cos(angle * Math.PI / 180))})`} | |
| rx={2} /> | |
| {[...Array(6)].map((_, i) => { | |
| const x = -80 + i * 32; | |
| const scaleY = Math.max(0.15, Math.cos(angle * Math.PI / 180)); | |
| return <line key={i} | |
| x1={x} y1={-20 * scaleY} x2={x} y2={20 * scaleY} | |
| stroke={`rgba(140,180,255,${0.1 + (1 - scaleY) * 0.3})`} | |
| strokeWidth={1} strokeDasharray="2,3" | |
| transform={`rotate(${-angle * 0.4})`} />; | |
| })} | |
| </g> | |
| <text x={surfW/2} y={surfH + 32} textAnchor="middle" fontFamily={FONTS.mono} fontSize={11} fill={sevColor}> | |
| {animStretch.toFixed(1)}× stretch | |
| </text> | |
| </svg> | |
| </div> | |
| <div style={{ flex: 1, padding: "12px", background: "rgba(255,255,255,0.02)", borderRadius: 8, border: `1px solid ${COLORS.yAxis}20` }}> | |
| <div style={{ fontSize: 10, color: COLORS.yAxis, textTransform: "uppercase", letterSpacing: "0.08em", fontFamily: FONTS.mono, marginBottom: 10 }}>Triplanar</div> | |
| <svg width="100%" height={surfH + 40} viewBox={`0 0 ${surfW} ${surfH + 40}`} style={{ overflow: "visible" }}> | |
| <defs> | |
| <pattern id="checker-tri" x="0" y="0" width={cellSize * 2} height={cellSize * 2} patternUnits="userSpaceOnUse"> | |
| <rect width={cellSize} height={cellSize} fill="rgba(74,222,128,0.15)" /> | |
| <rect x={cellSize} y={cellSize} width={cellSize} height={cellSize} fill="rgba(74,222,128,0.15)" /> | |
| </pattern> | |
| </defs> | |
| <g transform={`translate(${surfW/2}, ${surfH/2 + 10})`}> | |
| <rect x={-surfW/2 + 20} y={-20} width={surfW - 40} height={40} | |
| fill="url(#checker-tri)" | |
| transform={`rotate(${-angle * 0.4})`} rx={2} /> | |
| {[...Array(6)].map((_, i) => { | |
| const x = -80 + i * 32; | |
| return <line key={i} | |
| x1={x} y1={-20} x2={x} y2={20} | |
| stroke="rgba(74,222,128,0.1)" | |
| strokeWidth={1} strokeDasharray="2,3" | |
| transform={`rotate(${-angle * 0.4})`} />; | |
| })} | |
| </g> | |
| <text x={surfW/2} y={surfH + 32} textAnchor="middle" fontFamily={FONTS.mono} fontSize={11} fill={COLORS.yAxis}> | |
| 1.0× no stretch | |
| </text> | |
| </svg> | |
| </div> | |
| </div> | |
| <div style={{ display: "flex", gap: 10, marginBottom: 16 }}> | |
| {[ | |
| { label: "Stretch", value: `${animStretch.toFixed(1)}×`, color: sevColor }, | |
| { label: "Severity", value: severity, color: sevColor }, | |
| { label: "UV distortion", value: angle < 10 ? "none" : angle < 60 ? "visible" : "extreme", color: sevColor }, | |
| ].map(s => ( | |
| <div key={s.label} style={{ flex: 1, padding: "10px 12px", background: "rgba(255,255,255,0.02)", borderRadius: 6, border: "1px solid rgba(255,255,255,0.04)" }}> | |
| <div style={{ fontSize: 10, color: COLORS.textDim, textTransform: "uppercase", letterSpacing: "0.08em", fontFamily: FONTS.mono }}>{s.label}</div> | |
| <div style={{ fontSize: 16, fontFamily: FONTS.mono, color: s.color, fontWeight: 300 }}>{s.value}</div> | |
| </div> | |
| ))} | |
| </div> | |
| <InsightCard> | |
| At <span style={{ color: sevColor, fontWeight: 600 }}>{angle}°</span>, a standard planar UV projection stretches the texture by <span style={{ color: sevColor, fontWeight: 600 }}>{animStretch.toFixed(1)}×</span>. | |
| Triplanar mapping eliminates this by blending projections from all three axes, so no single projection is ever stretched beyond recognition. | |
| </InsightCard> | |
| </div> | |
| ); | |
| } | |
| // ============================================================ | |
| // PANEL 2: THE SLIDE PROJECTOR | |
| // ============================================================ | |
| function TheSlideProjector() { | |
| const [projector, setProjector] = useState("top"); | |
| const [animate, setAnimate] = useState(false); | |
| useEffect(() => { | |
| const t = setTimeout(() => setAnimate(true), 150); | |
| return () => clearTimeout(t); | |
| }, []); | |
| const terrainW = 520; | |
| const segments = 80; | |
| const terrain = useMemo(() => generateTerrain(terrainW, segments), []); | |
| const svgH = 280; | |
| const baseY = 230; | |
| const scale = 0.9; | |
| function getSegmentColor(i) { | |
| if (i >= terrain.length - 1) return COLORS.textDim; | |
| const angle = getSegmentAngle(terrain[i], terrain[i + 1]); | |
| const rad = angle * Math.PI / 180; | |
| if (projector === "top") { | |
| const q = Math.cos(rad); | |
| return q > 0.7 ? COLORS.cbOrange : q > 0.3 ? COLORS.warn : COLORS.xAxis; | |
| } else if (projector === "side") { | |
| const q = Math.sin(rad); | |
| return q > 0.7 ? COLORS.cbBlue : q > 0.3 ? COLORS.warn : COLORS.xAxis; | |
| } else { | |
| return angle > 45 ? COLORS.cbBlue : COLORS.cbOrange; | |
| } | |
| } | |
| function getSegmentOpacity(i) { | |
| if (i >= terrain.length - 1) return 0.2; | |
| const angle = getSegmentAngle(terrain[i], terrain[i + 1]); | |
| const rad = angle * Math.PI / 180; | |
| // All modes: keep both good AND bad segments clearly visible | |
| // so the color contrast (green vs red, blue vs red) reads strongly | |
| if (projector === "top") return 0.5 + Math.cos(rad) * 0.5; | |
| if (projector === "side") return 0.5 + Math.sin(rad) * 0.5; | |
| return 0.75 + Math.max(Math.cos(rad), Math.sin(rad)) * 0.25; | |
| } | |
| const projectorConfig = { | |
| top: { label: "From above", color: COLORS.cbOrange, icon: "↓", desc: "Projects texture downward. Clean on flat ground, stretched on cliffs." }, | |
| side: { label: "From the side", color: COLORS.cbBlue, icon: "→", desc: "Projects texture sideways. Clean on cliff faces, stretched on flat ground." }, | |
| both: { label: "Both together", color: COLORS.accent, icon: "↓→", desc: "Each projector covers what the other misses. Steep surfaces use the side, flat surfaces use the top." }, | |
| }; | |
| const cfg = projectorConfig[projector]; | |
| return ( | |
| <div> | |
| <div style={{ marginBottom: 20 }}> | |
| <h3 style={{ fontFamily: FONTS.body, fontSize: 18, fontWeight: 400, color: COLORS.text, margin: "0 0 6px" }}>Think of it like a slide projector</h3> | |
| <p style={{ fontFamily: FONTS.body, fontSize: 14, color: COLORS.textMuted, margin: 0, lineHeight: 1.5 }}> | |
| Imagine shining a texture onto terrain from one direction. Where the surface faces the projector, the image is crisp. Where it turns away, it stretches and breaks down. | |
| </p> | |
| </div> | |
| <div style={{ display: "flex", gap: 8, marginBottom: 20 }}> | |
| {Object.entries(projectorConfig).map(([key, val]) => ( | |
| <button key={key} onClick={() => setProjector(key)} style={{ | |
| flex: 1, padding: "11px 12px", borderRadius: 8, cursor: "pointer", | |
| fontFamily: FONTS.body, fontSize: 13, textAlign: "left", | |
| background: projector === key ? val.color + "10" : "rgba(255,255,255,0.02)", | |
| border: `1px solid ${projector === key ? val.color + "40" : "rgba(255,255,255,0.04)"}`, | |
| color: projector === key ? val.color : COLORS.textDim, | |
| transition: "all 0.25s ease-out", | |
| }}> | |
| <span style={{ fontFamily: FONTS.mono, fontSize: 16, marginRight: 6 }}>{val.icon}</span> | |
| {val.label} | |
| </button> | |
| ))} | |
| </div> | |
| <div style={{ padding: "16px 12px 8px", background: "rgba(255,255,255,0.015)", borderRadius: 10, border: "1px solid rgba(255,255,255,0.04)", marginBottom: 16, overflow: "hidden" }}> | |
| <svg width="100%" height={svgH} viewBox={`-10 0 ${terrainW + 20} ${svgH}`} style={{ overflow: "visible" }}> | |
| {(projector === "top" || projector === "both") && ( | |
| <g opacity={animate ? 1 : 0} style={{ transition: "opacity 0.5s" }}> | |
| {[80, 200, 340, 450].map((x, i) => ( | |
| <line key={`tb${i}`} x1={x} y1={8} x2={x} y2={baseY - 10} | |
| stroke={COLORS.cbOrange} strokeWidth={1} strokeDasharray="4,8" opacity={0.25} /> | |
| ))} | |
| <text x={terrainW / 2} y={16} textAnchor="middle" fontFamily={FONTS.mono} fontSize={10} | |
| fill={COLORS.cbOrange} opacity={0.6}>▼ projecting from above ▼</text> | |
| </g> | |
| )} | |
| {(projector === "side" || projector === "both") && ( | |
| <g opacity={animate ? 1 : 0} style={{ transition: "opacity 0.5s" }}> | |
| {[baseY - 160, baseY - 110, baseY - 60, baseY - 20].map((y, i) => ( | |
| <line key={`sb${i}`} x1={-6} y1={y} x2={20} y2={y} | |
| stroke={COLORS.cbBlue} strokeWidth={1} strokeDasharray="4,8" opacity={0.25} /> | |
| ))} | |
| <text x={-6} y={baseY / 2} fontFamily={FONTS.mono} fontSize={10} | |
| fill={COLORS.cbBlue} opacity={0.6} transform={`rotate(-90, -6, ${baseY/2})`} | |
| textAnchor="middle">◀ from the side</text> | |
| </g> | |
| )} | |
| {terrain.map((p, i) => { | |
| if (i >= terrain.length - 1) return null; | |
| const p2 = terrain[i + 1]; | |
| return ( | |
| <line key={`seg${i}`} | |
| x1={p.x} y1={baseY - p.y * scale} | |
| x2={p2.x} y2={baseY - p2.y * scale} | |
| stroke={getSegmentColor(i)} | |
| strokeWidth={3.5} strokeLinecap="round" | |
| opacity={animate ? getSegmentOpacity(i) : 0} | |
| style={{ transition: `opacity 0.4s ease-out ${i * 0.003}s, stroke 0.35s ease-out` }} | |
| /> | |
| ); | |
| })} | |
| <line x1={0} y1={baseY + 10} x2={terrainW} y2={baseY + 10} | |
| stroke="rgba(255,255,255,0.03)" strokeWidth={1} /> | |
| <text x={30} y={baseY + 26} fontFamily={FONTS.mono} fontSize={9} fill={COLORS.textDim}>flat ground</text> | |
| <text x={190} y={baseY - 80 * scale - 8} fontFamily={FONTS.mono} fontSize={9} fill={COLORS.textDim}>cliff</text> | |
| <text x={245} y={baseY - 170 * scale - 8} fontFamily={FONTS.mono} fontSize={9} fill={COLORS.textDim}>plateau</text> | |
| <text x={360} y={baseY - 105 * scale - 8} fontFamily={FONTS.mono} fontSize={9} fill={COLORS.textDim}>slope</text> | |
| </svg> | |
| <div style={{ display: "flex", gap: 16, justifyContent: "center", paddingTop: 4, paddingBottom: 4 }}> | |
| {projector === "side" ? ( | |
| <> | |
| <span style={{ fontSize: 10, color: COLORS.textDim, fontFamily: FONTS.mono, display: "flex", alignItems: "center", gap: 5 }}> | |
| <span style={{ display: "inline-block", width: 16, height: 3, borderRadius: 2, background: COLORS.cbBlue, opacity: 0.9 }} /> | |
| crisp texture | |
| </span> | |
| <span style={{ fontSize: 10, color: COLORS.textDim, fontFamily: FONTS.mono, display: "flex", alignItems: "center", gap: 5 }}> | |
| <span style={{ display: "inline-block", width: 16, height: 3, borderRadius: 2, background: COLORS.xAxis, opacity: 0.7 }} /> | |
| stretched / poor | |
| </span> | |
| </> | |
| ) : projector === "top" ? ( | |
| <> | |
| <span style={{ fontSize: 10, color: COLORS.textDim, fontFamily: FONTS.mono, display: "flex", alignItems: "center", gap: 5 }}> | |
| <span style={{ display: "inline-block", width: 16, height: 3, borderRadius: 2, background: COLORS.cbOrange, opacity: 0.9 }} /> | |
| crisp texture | |
| </span> | |
| <span style={{ fontSize: 10, color: COLORS.textDim, fontFamily: FONTS.mono, display: "flex", alignItems: "center", gap: 5 }}> | |
| <span style={{ display: "inline-block", width: 16, height: 3, borderRadius: 2, background: COLORS.xAxis, opacity: 0.7 }} /> | |
| stretched / poor | |
| </span> | |
| </> | |
| ) : ( | |
| <> | |
| <span style={{ fontSize: 10, color: COLORS.textDim, fontFamily: FONTS.mono, display: "flex", alignItems: "center", gap: 5 }}> | |
| <span style={{ display: "inline-block", width: 16, height: 3, borderRadius: 2, background: COLORS.cbOrange, opacity: 0.9 }} /> | |
| covered by top ↓ | |
| </span> | |
| <span style={{ fontSize: 10, color: COLORS.textDim, fontFamily: FONTS.mono, display: "flex", alignItems: "center", gap: 5 }}> | |
| <span style={{ display: "inline-block", width: 16, height: 3, borderRadius: 2, background: COLORS.cbBlue, opacity: 0.9 }} /> | |
| covered by side → | |
| </span> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| <div style={{ | |
| padding: "14px 16px", borderRadius: 8, | |
| background: cfg.color + "06", border: `1px solid ${cfg.color}15`, | |
| borderLeft: `3px solid ${cfg.color}40`, | |
| marginBottom: 16, transition: "all 0.3s", | |
| }}> | |
| <p style={{ fontSize: 13, color: "#a0a4ac", lineHeight: 1.6, margin: 0 }}> | |
| {cfg.desc} | |
| {projector === "both" && <> <span style={{ color: COLORS.accent }}>This is the core idea of triplanar mapping</span>: use multiple projectors and let each surface pick the one that works best for its orientation.</>} | |
| </p> | |
| </div> | |
| <InsightCard> | |
| A single projector always fails on some surfaces. Triplanar mapping runs <span style={{ color: COLORS.accent, fontWeight: 600 }}>three projectors at once</span> — one from each axis — and blends the results. | |
| The surface itself determines how much of each projection to use. | |
| </InsightCard> | |
| </div> | |
| ); | |
| } | |
| // ============================================================ | |
| // PANEL 3: THE SURFACE DECIDES | |
| // ============================================================ | |
| function TheSurfaceDecides() { | |
| const [hoverIdx, setHoverIdx] = useState(null); | |
| const [sharpness, setSharpness] = useState(2); | |
| const svgRef = useRef(null); | |
| const terrainW = 520; | |
| const segments = 80; | |
| const terrain = useMemo(() => generateTerrain(terrainW, segments), []); | |
| const svgH = 310; | |
| const baseY = 210; | |
| const scale = 0.85; | |
| function getSegmentInfo(i) { | |
| if (i >= terrain.length - 1) return { angle: 0, topWeight: 1, sideWeight: 0 }; | |
| const angle = getSegmentAngle(terrain[i], terrain[i + 1]); | |
| const rad = angle * Math.PI / 180; | |
| const rawTop = Math.pow(Math.cos(rad), sharpness); | |
| const rawSide = Math.pow(Math.sin(rad), sharpness); | |
| const total = rawTop + rawSide || 1; | |
| return { angle: Math.round(angle), topWeight: rawTop / total, sideWeight: rawSide / total }; | |
| } | |
| function getSegmentBlendColor(i) { | |
| const info = getSegmentInfo(i); | |
| // Orange (top) → Blue (side) — colorblind-safe high contrast | |
| const r = Math.round(232 * info.topWeight + 91 * info.sideWeight); | |
| const g = Math.round(146 * info.topWeight + 163 * info.sideWeight); | |
| const b = Math.round(58 * info.topWeight + 245 * info.sideWeight); | |
| return `rgb(${r},${g},${b})`; | |
| } | |
| const handleMouseMove = useCallback((e) => { | |
| if (!svgRef.current) return; | |
| const rect = svgRef.current.getBoundingClientRect(); | |
| const svgX = ((e.clientX - rect.left) / rect.width) * (terrainW + 20) - 10; | |
| let closest = 0; | |
| let minDist = Infinity; | |
| terrain.forEach((p, i) => { | |
| const d = Math.abs(p.x - svgX); | |
| if (d < minDist) { minDist = d; closest = i; } | |
| }); | |
| setHoverIdx(closest); | |
| }, [terrain]); | |
| const hoverInfo = hoverIdx !== null ? getSegmentInfo(hoverIdx) : null; | |
| const hoverPoint = hoverIdx !== null ? terrain[Math.min(hoverIdx, terrain.length - 1)] : null; | |
| return ( | |
| <div> | |
| <div style={{ marginBottom: 20 }}> | |
| <h3 style={{ fontFamily: FONTS.body, fontSize: 18, fontWeight: 400, color: COLORS.text, margin: "0 0 6px" }}>The surface decides</h3> | |
| <p style={{ fontFamily: FONTS.body, fontSize: 14, color: COLORS.textMuted, margin: 0, lineHeight: 1.5 }}> | |
| Each point on the surface knows which direction it faces. Flat areas take the texture from above. Steep areas take it from the side. Hover over the terrain to see how the blend shifts. | |
| </p> | |
| </div> | |
| <div style={{ padding: "12px 12px 0", background: "rgba(255,255,255,0.015)", borderRadius: 10, border: "1px solid rgba(255,255,255,0.04)", marginBottom: 16, overflow: "hidden" }}> | |
| <svg ref={svgRef} width="100%" height={svgH} viewBox={`-10 0 ${terrainW + 20} ${svgH}`} | |
| style={{ overflow: "visible", cursor: "crosshair" }} | |
| onMouseMove={handleMouseMove} | |
| onMouseLeave={() => setHoverIdx(null)}> | |
| {terrain.map((p, i) => { | |
| if (i >= terrain.length - 1) return null; | |
| const p2 = terrain[i + 1]; | |
| const color = getSegmentBlendColor(i); | |
| const isHovered = hoverIdx !== null && Math.abs(i - hoverIdx) < 3; | |
| return ( | |
| <line key={`seg${i}`} | |
| x1={p.x} y1={baseY - p.y * scale} | |
| x2={p2.x} y2={baseY - p2.y * scale} | |
| stroke={color} strokeWidth={isHovered ? 5 : 3.5} strokeLinecap="round" | |
| opacity={isHovered ? 1 : 0.7} | |
| style={{ transition: "stroke-width 0.15s, opacity 0.15s" }} | |
| /> | |
| ); | |
| })} | |
| {hoverPoint && hoverInfo && (() => { | |
| const idx = Math.min(hoverIdx, terrain.length - 2); | |
| const p1 = terrain[idx]; | |
| const p2 = terrain[idx + 1]; | |
| const dx = p2.x - p1.x; | |
| const dy = -(p2.y - p1.y) * scale; | |
| const len = Math.sqrt(dx * dx + dy * dy) || 1; | |
| const nx = -dy / len; | |
| const ny = -dx / len; | |
| const arrowLen = 30; | |
| const sx = hoverPoint.x; | |
| const sy = baseY - hoverPoint.y * scale; | |
| return ( | |
| <g> | |
| <line x1={sx} y1={sy - 8} x2={sx} y2={sy - 60} | |
| stroke="rgba(255,255,255,0.15)" strokeWidth={1} strokeDasharray="3,4" /> | |
| <line x1={sx} y1={sy} x2={sx + nx * arrowLen} y2={sy + ny * arrowLen} | |
| stroke="rgba(255,255,255,0.4)" strokeWidth={1.5} /> | |
| <circle cx={sx} cy={sy} r={4} fill="#fff" opacity={0.9} /> | |
| </g> | |
| ); | |
| })()} | |
| <line x1={0} y1={baseY + 10} x2={terrainW} y2={baseY + 10} | |
| stroke="rgba(255,255,255,0.03)" strokeWidth={1} /> | |
| <defs> | |
| <linearGradient id="blend-legend" x1="0" y1="0" x2="1" y2="0"> | |
| <stop offset="0%" stopColor={COLORS.cbOrange} /> | |
| <stop offset="100%" stopColor={COLORS.cbBlue} /> | |
| </linearGradient> | |
| </defs> | |
| <rect x={terrainW / 2 - 80} y={svgH - 22} width={160} height={6} rx={3} fill="url(#blend-legend)" opacity={0.5} /> | |
| <text x={terrainW / 2 - 88} y={svgH - 14} fontFamily={FONTS.mono} fontSize={9} fill={COLORS.cbOrange} textAnchor="end">↓ top</text> | |
| <text x={terrainW / 2 + 88} y={svgH - 14} fontFamily={FONTS.mono} fontSize={9} fill={COLORS.cbBlue} textAnchor="start">side →</text> | |
| </svg> | |
| </div> | |
| <div style={{ display: "flex", gap: 12, marginBottom: 16, minHeight: 80 }}> | |
| <div style={{ flex: "1 1 50%", padding: "14px 16px", background: "rgba(255,255,255,0.02)", borderRadius: 8, border: "1px solid rgba(255,255,255,0.04)" }}> | |
| {hoverInfo ? ( | |
| <> | |
| <div style={{ fontSize: 10, color: COLORS.textDim, textTransform: "uppercase", letterSpacing: "0.08em", fontFamily: FONTS.mono, marginBottom: 10 }}> | |
| Blend at this point | |
| </div> | |
| <div style={{ display: "flex", height: 28, borderRadius: 5, overflow: "hidden", marginBottom: 10, border: "1px solid rgba(255,255,255,0.04)" }}> | |
| <div style={{ | |
| width: `${hoverInfo.topWeight * 100}%`, background: COLORS.cbOrange + "60", | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| transition: "width 0.15s", minWidth: hoverInfo.topWeight > 0.05 ? 30 : 0, | |
| }}> | |
| {hoverInfo.topWeight > 0.1 && <span style={{ fontFamily: FONTS.mono, fontSize: 10, color: COLORS.cbOrange }}>{(hoverInfo.topWeight * 100).toFixed(0)}%</span>} | |
| </div> | |
| <div style={{ | |
| width: `${hoverInfo.sideWeight * 100}%`, background: COLORS.cbBlue + "60", | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| transition: "width 0.15s", minWidth: hoverInfo.sideWeight > 0.05 ? 30 : 0, | |
| }}> | |
| {hoverInfo.sideWeight > 0.1 && <span style={{ fontFamily: FONTS.mono, fontSize: 10, color: COLORS.cbBlue }}>{(hoverInfo.sideWeight * 100).toFixed(0)}%</span>} | |
| </div> | |
| </div> | |
| <div style={{ fontSize: 12, color: COLORS.textMuted, lineHeight: 1.5 }}> | |
| Surface angle: <span style={{ color: COLORS.text, fontFamily: FONTS.mono }}>{hoverInfo.angle}°</span> | |
| {hoverInfo.angle < 25 && " — mostly flat, top projection dominates"} | |
| {hoverInfo.angle >= 25 && hoverInfo.angle < 55 && " — transitional, both projections contribute"} | |
| {hoverInfo.angle >= 55 && " — steep face, side projection takes over"} | |
| </div> | |
| </> | |
| ) : ( | |
| <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: COLORS.textDim, fontFamily: FONTS.mono, fontSize: 12 }}> | |
| hover over the terrain ↑ | |
| </div> | |
| )} | |
| </div> | |
| <div style={{ flex: "0 0 140px", padding: "14px 16px", background: "rgba(255,255,255,0.02)", borderRadius: 8, border: "1px solid rgba(255,255,255,0.04)" }}> | |
| <div style={{ fontSize: 10, color: COLORS.textDim, textTransform: "uppercase", letterSpacing: "0.08em", fontFamily: FONTS.mono, marginBottom: 10 }}> | |
| Blend sharpness | |
| </div> | |
| <div style={{ display: "flex", flexDirection: "column", gap: 4 }}> | |
| {[1, 2, 4, 8].map(s => ( | |
| <button key={s} onClick={() => setSharpness(s)} style={{ | |
| padding: "5px 10px", fontSize: 12, borderRadius: 5, cursor: "pointer", | |
| fontFamily: FONTS.mono, textAlign: "left", | |
| background: s === sharpness ? "rgba(255,255,255,0.06)" : "rgba(255,255,255,0.015)", | |
| border: `1px solid ${s === sharpness ? "rgba(255,255,255,0.12)" : "rgba(255,255,255,0.03)"}`, | |
| color: s === sharpness ? COLORS.text : COLORS.textDim, | |
| transition: "all 0.15s", | |
| }}> | |
| {s} {s === 1 ? "soft" : s === 8 ? "sharp" : ""} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| <InsightCard> | |
| <span style={{ color: COLORS.accent, fontWeight: 600 }}>Sharpness</span> controls how quickly the blend transitions between projections. | |
| At <span style={{ fontWeight: 600 }}>1</span>, both projections contribute broadly. | |
| At <span style={{ fontWeight: 600 }}>8</span>, each point is almost entirely one projection or the other — fewer blended pixels, but harder edges between regions. | |
| </InsightCard> | |
| </div> | |
| ); | |
| } | |
| // ============================================================ | |
| // PANEL 4: BEFORE & AFTER | |
| // ============================================================ | |
| function BeforeAfter() { | |
| const [activeCase, setActiveCase] = useState(0); | |
| const [sliderPos, setSliderPos] = useState(0.5); | |
| const [isDragging, setIsDragging] = useState(false); | |
| const containerRef = useRef(null); | |
| const cases = [ | |
| { | |
| id: "terrain", | |
| label: "Terrain", | |
| before: { label: "Planar UV from above", desc: "The texture is projected straight down. Flat areas look fine. Cliff faces get extreme stretching — the texture smears vertically, and any detail like rock grain or cracks becomes an unreadable blur." }, | |
| after: { label: "Triplanar projection", desc: "Cliff faces now pull their texture from a side projection. Flat ground still uses the top projection. The transition is smooth and invisible. Same texture, same geometry, no UV work." }, | |
| }, | |
| { | |
| id: "cave", | |
| label: "Cave / Overhang", | |
| before: { label: "Planar UV from above", desc: "Overhangs and cave ceilings face downward — completely perpendicular to a top-down projection. The texture compresses to near-zero, producing thin horizontal streaks instead of recognizable rock detail." }, | |
| after: { label: "Triplanar projection", desc: "The underside of the overhang is covered by the side and bottom projections. The ceiling texture reads clearly with proper scale. Artists don't need to manually fix these problem areas." }, | |
| }, | |
| { | |
| id: "procedural", | |
| label: "Procedural Mesh", | |
| before: { label: "No UVs at all", desc: "Runtime-generated geometry like voxel terrain, boolean cuts, or deformed meshes has no UV coordinates. Without UVs, there's nothing to map a texture to — you get a flat untextured surface." }, | |
| after: { label: "Triplanar projection", desc: "Every vertex only needs a position and a direction it faces, both of which procedural geometry naturally has. Triplanar mapping gives these meshes production textures without any authoring step." }, | |
| }, | |
| ]; | |
| // --- Unique geometry per scenario --- | |
| const terrainW = 520; | |
| const terrainGeo = useMemo(() => generateTerrain(terrainW, 80), []); | |
| // Cave / overhang: arch shape with overhanging ceiling | |
| const caveGeo = useMemo(() => { | |
| const pts = []; | |
| const segs = 80; | |
| for (let i = 0; i <= segs; i++) { | |
| const t = i / segs; | |
| const x = t * terrainW; | |
| let y; | |
| if (t < 0.1) y = 0; | |
| else if (t < 0.2) y = Math.sin((t - 0.1) / 0.1 * Math.PI * 0.5) * 40; | |
| else if (t < 0.3) y = 40 + (t - 0.2) / 0.1 * 100; // steep wall up | |
| else if (t < 0.42) y = 140 + Math.sin((t - 0.3) / 0.12 * Math.PI) * 30; // top of arch | |
| else if (t < 0.52) y = 140 - (t - 0.42) / 0.1 * 80; // overhang going DOWN | |
| else if (t < 0.58) y = 60 - (t - 0.52) / 0.06 * 20; // underside of overhang | |
| else if (t < 0.65) y = 40 + (t - 0.58) / 0.07 * 30; // back up from cave floor | |
| else if (t < 0.75) y = 70 + (t - 0.65) / 0.1 * 80; // steep wall up again | |
| else if (t < 0.85) y = 150 + Math.sin((t - 0.75) / 0.1 * Math.PI) * 10; // plateau | |
| else y = 150 * (1 - (t - 0.85) / 0.15); // slope down | |
| pts.push({ x, y }); | |
| } | |
| return pts; | |
| }, []); | |
| // Procedural mesh: jagged, blocky voxel-like steps | |
| const proceduralGeo = useMemo(() => { | |
| const pts = []; | |
| const segs = 80; | |
| // Create blocky step pattern | |
| const heights = [0, 0, 25, 25, 60, 60, 45, 45, 90, 90, 130, 130, 130, 110, 110, 80, 80, 50, 50, 50, 70, 70, 100, 100, 140, 140, 160, 160, 120, 120, 85, 85, 40, 40, 40, 15, 15, 0, 0, 0]; | |
| for (let i = 0; i <= segs; i++) { | |
| const t = i / segs; | |
| const x = t * terrainW; | |
| const hi = Math.min(Math.floor(t * heights.length), heights.length - 1); | |
| const y = heights[hi]; | |
| pts.push({ x, y }); | |
| } | |
| return pts; | |
| }, []); | |
| const geometries = [terrainGeo, caveGeo, proceduralGeo]; | |
| const activeGeo = geometries[activeCase]; | |
| const activeC = cases[activeCase]; | |
| const handleDrag = useCallback((cx) => { | |
| if (!containerRef.current) return; | |
| const r = containerRef.current.getBoundingClientRect(); | |
| setSliderPos(Math.max(0.02, Math.min(0.98, (cx - r.left) / r.width))); | |
| }, []); | |
| useEffect(() => { | |
| if (!isDragging) return; | |
| const mv = (e) => handleDrag(e.touches ? e.touches[0].clientX : e.clientX); | |
| const up = () => setIsDragging(false); | |
| window.addEventListener("mousemove", mv); window.addEventListener("mouseup", up); | |
| window.addEventListener("touchmove", mv); window.addEventListener("touchend", up); | |
| return () => { window.removeEventListener("mousemove", mv); window.removeEventListener("mouseup", up); window.removeEventListener("touchmove", mv); window.removeEventListener("touchend", up); }; | |
| }, [isDragging, handleDrag]); | |
| const svgH = 200; | |
| const baseY = 175; | |
| const sc = 0.85; | |
| const splitX = sliderPos * terrainW; | |
| return ( | |
| <div> | |
| <div style={{ marginBottom: 20 }}> | |
| <h3 style={{ fontFamily: FONTS.body, fontSize: 18, fontWeight: 400, color: COLORS.text, margin: "0 0 6px" }}>What changes in practice</h3> | |
| <p style={{ fontFamily: FONTS.body, fontSize: 14, color: COLORS.textMuted, margin: 0, lineHeight: 1.5 }}> | |
| Drag the divider to compare standard UV projection against triplanar mapping on the same geometry. | |
| </p> | |
| </div> | |
| <div style={{ display: "flex", gap: 6, marginBottom: 16 }}> | |
| {cases.map((c, i) => ( | |
| <button key={c.id} onClick={() => { setActiveCase(i); setSliderPos(0.5); }} style={{ | |
| flex: 1, padding: "9px 12px", borderRadius: 7, cursor: "pointer", | |
| fontFamily: FONTS.mono, fontSize: 12, | |
| background: i === activeCase ? "rgba(255,255,255,0.06)" : "rgba(255,255,255,0.02)", | |
| border: `1px solid ${i === activeCase ? "rgba(255,255,255,0.1)" : "rgba(255,255,255,0.04)"}`, | |
| color: i === activeCase ? COLORS.text : COLORS.textDim, | |
| transition: "all 0.2s", | |
| }}>{c.label}</button> | |
| ))} | |
| </div> | |
| <div ref={containerRef} | |
| style={{ | |
| position: "relative", borderRadius: 10, overflow: "hidden", | |
| background: "rgba(255,255,255,0.015)", border: "1px solid rgba(255,255,255,0.04)", | |
| marginBottom: 16, cursor: "ew-resize", touchAction: "none", | |
| }} | |
| onMouseDown={(e) => { setIsDragging(true); handleDrag(e.clientX); }} | |
| onTouchStart={(e) => { setIsDragging(true); handleDrag(e.touches[0].clientX); }}> | |
| <svg width="100%" height={svgH + 20} viewBox={`-10 0 ${terrainW + 20} ${svgH + 20}`} style={{ display: "block" }}> | |
| <defs> | |
| <clipPath id="clip-before"><rect x={-10} y={0} width={splitX + 10} height={svgH + 20} /></clipPath> | |
| <clipPath id="clip-after"><rect x={splitX} y={0} width={terrainW - splitX + 20} height={svgH + 20} /></clipPath> | |
| </defs> | |
| <g clipPath="url(#clip-before)"> | |
| <rect x={-10} y={0} width={terrainW + 20} height={svgH + 20} fill="rgba(240,116,116,0.02)" /> | |
| {activeGeo.map((p, i) => { | |
| if (i >= activeGeo.length - 1) return null; | |
| const p2 = activeGeo[i + 1]; | |
| const angle = getSegmentAngle(p, p2); | |
| const stretch = 1 / Math.max(Math.cos(angle * Math.PI / 180), 0.08); | |
| let color; | |
| if (activeC.id === "procedural") { | |
| color = "rgba(255,255,255,0.08)"; | |
| } else { | |
| const bad = Math.min(1, (stretch - 1) / 8); | |
| color = `rgba(${Math.round(100 + 140 * bad)}, ${Math.round(120 - 60 * bad)}, ${Math.round(120 - 40 * bad)}, ${0.3 + bad * 0.5})`; | |
| } | |
| return <line key={`b${i}`} | |
| x1={p.x} y1={baseY - p.y * sc} | |
| x2={p2.x} y2={baseY - p2.y * sc} | |
| stroke={color} strokeWidth={3.5} strokeLinecap="round" />; | |
| })} | |
| <text x={12} y={22} fontFamily={FONTS.mono} fontSize={10} fill={COLORS.xAxis} opacity={0.7}>BEFORE</text> | |
| </g> | |
| <g clipPath="url(#clip-after)"> | |
| <rect x={-10} y={0} width={terrainW + 20} height={svgH + 20} fill="rgba(74,222,128,0.015)" /> | |
| {activeGeo.map((p, i) => { | |
| if (i >= activeGeo.length - 1) return null; | |
| const p2 = activeGeo[i + 1]; | |
| const angle = getSegmentAngle(p, p2); | |
| const topW = Math.cos(angle * Math.PI / 180); | |
| const sideW = Math.sin(angle * Math.PI / 180); | |
| // Orange (top) → Blue (side) — colorblind-safe | |
| const r = Math.round(232 * topW + 91 * sideW); | |
| const g = Math.round(146 * topW + 163 * sideW); | |
| const b = Math.round(58 * topW + 245 * sideW); | |
| return <line key={`a${i}`} | |
| x1={p.x} y1={baseY - p.y * sc} | |
| x2={p2.x} y2={baseY - p2.y * sc} | |
| stroke={`rgb(${r},${g},${b})`} strokeWidth={3.5} strokeLinecap="round" opacity={0.8} />; | |
| })} | |
| <text x={terrainW - 48} y={22} fontFamily={FONTS.mono} fontSize={10} fill={COLORS.yAxis} opacity={0.7}>AFTER</text> | |
| </g> | |
| <line x1={splitX} y1={0} x2={splitX} y2={svgH + 20} | |
| stroke="rgba(255,255,255,0.5)" strokeWidth={2} /> | |
| <circle cx={splitX} cy={svgH / 2 + 10} r={12} | |
| fill="#222" stroke="rgba(255,255,255,0.6)" strokeWidth={2} /> | |
| <text x={splitX} y={svgH / 2 + 14} textAnchor="middle" | |
| fontFamily={FONTS.mono} fontSize={10} fill="rgba(255,255,255,0.7)">⟷</text> | |
| <line x1={0} y1={baseY + 8} x2={terrainW} y2={baseY + 8} | |
| stroke="rgba(255,255,255,0.03)" strokeWidth={1} /> | |
| </svg> | |
| </div> | |
| <div style={{ display: "flex", gap: 12, marginBottom: 16 }}> | |
| <div style={{ | |
| flex: 1, padding: "14px 16px", borderRadius: 8, | |
| background: COLORS.xAxis + "06", border: `1px solid ${COLORS.xAxis}12`, | |
| borderLeft: `3px solid ${COLORS.xAxis}30`, | |
| }}> | |
| <div style={{ fontFamily: FONTS.mono, fontSize: 10, color: COLORS.xAxis, textTransform: "uppercase", letterSpacing: "0.08em", marginBottom: 6 }}>Before</div> | |
| <p style={{ fontSize: 12, color: COLORS.textMuted, lineHeight: 1.6, margin: 0 }}>{activeC.before.desc}</p> | |
| </div> | |
| <div style={{ | |
| flex: 1, padding: "14px 16px", borderRadius: 8, | |
| background: COLORS.yAxis + "06", border: `1px solid ${COLORS.yAxis}12`, | |
| borderLeft: `3px solid ${COLORS.yAxis}30`, | |
| }}> | |
| <div style={{ fontFamily: FONTS.mono, fontSize: 10, color: COLORS.yAxis, textTransform: "uppercase", letterSpacing: "0.08em", marginBottom: 6 }}>After</div> | |
| <p style={{ fontSize: 12, color: COLORS.textMuted, lineHeight: 1.6, margin: 0 }}>{activeC.after.desc}</p> | |
| </div> | |
| </div> | |
| <InsightCard> | |
| The tradeoff: <span style={{ color: COLORS.warn, fontWeight: 600 }}>3 texture samples per pixel</span> instead of 1. | |
| The payoff: <span style={{ color: COLORS.yAxis, fontWeight: 600 }}>zero UV unwrapping</span>, no stretching on any surface orientation, and it works on geometry that has no UVs at all. | |
| </InsightCard> | |
| </div> | |
| ); | |
| } | |
| // ============================================================ | |
| // MAIN APP | |
| // ============================================================ | |
| function App() { | |
| const [activeVisual, setActiveVisual] = useState(0); | |
| const visuals = [ | |
| { id: "problem", num: "01", title: "The Problem", subtitle: "UV projection stretches textures on angled surfaces. How bad does it get?", component: <TheProblem /> }, | |
| { id: "projector", num: "02", title: "The Idea", subtitle: "What if you projected the texture from more than one direction?", component: <TheSlideProjector /> }, | |
| { id: "decides", num: "03", title: "The Surface Decides", subtitle: "Each point on the surface picks the projection that fits it best.", component: <TheSurfaceDecides /> }, | |
| { id: "before-after", num: "04", title: "Before & After", subtitle: "The same geometry, with and without triplanar mapping.", component: <BeforeAfter /> }, | |
| ]; | |
| return ( | |
| <div style={{ | |
| minHeight: "100vh", background: COLORS.bg, color: COLORS.text, | |
| fontFamily: FONTS.body, padding: "40px 16px", | |
| }}> | |
| <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet" /> | |
| <div style={{ maxWidth: 640, margin: "0 auto 32px" }}> | |
| <div style={{ fontFamily: FONTS.mono, fontSize: 11, color: COLORS.accent, textTransform: "uppercase", letterSpacing: "0.12em", marginBottom: 8 }}> | |
| Interactive Explainer | |
| </div> | |
| <h1 style={{ fontSize: 28, fontWeight: 300, margin: "0 0 8px", lineHeight: 1.3, letterSpacing: "-0.01em" }}> | |
| Triplanar Mapping | |
| </h1> | |
| <p style={{ fontSize: 15, color: COLORS.textMuted, margin: 0, lineHeight: 1.5 }}> | |
| Texturing 3D surfaces without UV coordinates. | |
| </p> | |
| </div> | |
| <div style={{ maxWidth: 640, margin: "0 auto 28px", display: "flex", gap: 4, background: "rgba(255,255,255,0.015)", borderRadius: 10, padding: 4, border: "1px solid rgba(255,255,255,0.04)" }}> | |
| {visuals.map((v, i) => ( | |
| <button key={v.id} onClick={() => setActiveVisual(i)} style={{ | |
| flex: 1, fontFamily: FONTS.mono, fontSize: 12, | |
| fontWeight: activeVisual === i ? 500 : 400, | |
| color: activeVisual === i ? COLORS.text : COLORS.textDim, | |
| background: activeVisual === i ? "rgba(255,255,255,0.05)" : "transparent", | |
| border: "none", borderRadius: 7, padding: "10px 6px", | |
| cursor: "pointer", transition: "all 0.2s ease-out", letterSpacing: "0.01em", | |
| }}> | |
| <span style={{ display: "inline-block", width: 16, fontFamily: FONTS.mono, fontSize: 10, opacity: 0.4, marginRight: 3 }}>{i + 1}</span> | |
| {v.title} | |
| </button> | |
| ))} | |
| </div> | |
| <div style={{ | |
| maxWidth: 640, margin: "0 auto", | |
| padding: "28px 24px", background: "rgba(255,255,255,0.015)", | |
| borderRadius: 12, border: "1px solid rgba(255,255,255,0.04)", minHeight: 420, | |
| }}> | |
| {visuals[activeVisual].component} | |
| </div> | |
| <div style={{ maxWidth: 640, margin: "16px auto 0", display: "flex", justifyContent: "space-between", alignItems: "center" }}> | |
| <button | |
| onClick={() => setActiveVisual(Math.max(0, activeVisual - 1))} | |
| disabled={activeVisual === 0} | |
| style={{ | |
| fontFamily: FONTS.mono, fontSize: 12, | |
| color: activeVisual === 0 ? COLORS.textDim : COLORS.textMuted, | |
| background: "none", | |
| border: `1px solid ${activeVisual === 0 ? "transparent" : "rgba(255,255,255,0.04)"}`, | |
| borderRadius: 6, padding: "8px 16px", | |
| cursor: activeVisual === 0 ? "default" : "pointer", transition: "all 0.2s", | |
| }}>← prev</button> | |
| <span style={{ fontFamily: FONTS.mono, fontSize: 11, color: COLORS.textDim }}> | |
| {activeVisual + 1} / {visuals.length} | |
| </span> | |
| <button | |
| onClick={() => setActiveVisual(Math.min(visuals.length - 1, activeVisual + 1))} | |
| disabled={activeVisual === visuals.length - 1} | |
| style={{ | |
| fontFamily: FONTS.mono, fontSize: 12, | |
| color: activeVisual === visuals.length - 1 ? COLORS.textDim : COLORS.textMuted, | |
| background: "none", | |
| border: `1px solid ${activeVisual === visuals.length - 1 ? "transparent" : "rgba(255,255,255,0.04)"}`, | |
| borderRadius: 6, padding: "8px 16px", | |
| cursor: activeVisual === visuals.length - 1 ? "default" : "pointer", transition: "all 0.2s", | |
| }}>next →</button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| ReactDOM.createRoot(document.getElementById("root")).render(<App />); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment