// WebGL Orb — raymarched noise sphere, heavily optimized // Renders shader to a small internal canvas (~400px) and CSS-scales up. // This keeps the beautiful volumetric look while ~4x faster. function Orb({ className = "", intensity = 1, theme = "dark" }) { const canvasRef = React.useRef(null); const rafRef = React.useRef(null); const stateRef = React.useRef({ mx: 0, my: 0, visible: true }); React.useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const gl = canvas.getContext('webgl', { premultipliedAlpha: true, antialias: true, alpha: true, powerPreference: 'low-power' }); if (!gl) return; const vs = ` attribute vec2 a_pos; void main(){ gl_Position = vec4(a_pos, 0., 1.); } `; // Same raymarched look as before, but: fewer march steps (32), fbm 3 octaves, // analytic normals (no 3x map() calls), cheap grain. const fs = ` precision mediump float; uniform float u_t; uniform vec2 u_res; uniform vec2 u_mouse; uniform float u_intensity; uniform float u_theme; vec3 hash3(vec3 p){ p = vec3(dot(p,vec3(127.1,311.7,74.7)), dot(p,vec3(269.5,183.3,246.1)), dot(p,vec3(113.5,271.9,124.6))); return -1.0+2.0*fract(sin(p)*43758.5453); } float noise(vec3 p){ vec3 i=floor(p); vec3 f=fract(p); vec3 u=f*f*(3.0-2.0*f); return mix(mix(mix(dot(hash3(i),f), dot(hash3(i+vec3(1,0,0)),f-vec3(1,0,0)),u.x), mix(dot(hash3(i+vec3(0,1,0)),f-vec3(0,1,0)), dot(hash3(i+vec3(1,1,0)),f-vec3(1,1,0)),u.x),u.y), mix(mix(dot(hash3(i+vec3(0,0,1)),f-vec3(0,0,1)), dot(hash3(i+vec3(1,0,1)),f-vec3(1,0,1)),u.x), mix(dot(hash3(i+vec3(0,1,1)),f-vec3(0,1,1)), dot(hash3(i+vec3(1,1,1)),f-vec3(1,1,1)),u.x),u.y),u.z); } float fbm(vec3 p){ float v=0., a=0.5; v += a*noise(p); p*=2.03; a*=0.5; v += a*noise(p); p*=2.03; a*=0.5; v += a*noise(p); p*=2.03; a*=0.5; v += a*noise(p); return v; } float map(vec3 p, float t){ float d = length(p) - 1.0; float n = fbm(p*1.3 + vec3(0., t*0.5, t*0.3)); d -= 0.35 * n * u_intensity; return d; } vec3 palette(float t){ vec3 cyan = vec3(0.32, 0.82, 0.95); vec3 violet = vec3(0.55, 0.38, 0.95); vec3 amber = vec3(1.0, 0.78, 0.42); vec3 deep = vec3(0.05, 0.1, 0.25); vec3 c = mix(deep, cyan, smoothstep(0.0, 0.35, t)); c = mix(c, violet, smoothstep(0.3, 0.6, t)); c = mix(c, amber, smoothstep(0.6, 0.95, t)); return c; } void main(){ vec2 uv = (gl_FragCoord.xy - u_res*0.5) / min(u_res.x, u_res.y); uv *= 2.6; vec3 ro = vec3(0.0, 0.0, -3.2); vec3 rd = normalize(vec3(uv, 1.5)); float rotY = u_mouse.x*0.4 + u_t*0.25; float rotX = -u_mouse.y*0.3; float cy_ = cos(rotY), sy_ = sin(rotY); float cx_ = cos(rotX), sx_ = sin(rotX); mat3 ry = mat3(cy_,0.,sy_, 0.,1.,0., -sy_,0.,cy_); mat3 rx = mat3(1.,0.,0., 0.,cx_,-sx_, 0.,sx_,cx_); rd = rx*ry*rd; ro = rx*ry*ro; // bounding sphere skip — only march if ray could hit r=1.5 float b = dot(ro, rd); float c = dot(ro, ro) - 2.25; float disc = b*b - c; float hit = 0.; vec3 p = vec3(0.); float t = 0.; if(disc > 0.0){ t = max(-b - sqrt(disc), 0.0); // 48 march steps with smarter early exit for(int i=0;i<48;i++){ p = ro + rd*t; float d = map(p, u_t); if(d < 0.003){ hit = 1.; break; } if(t > 5.5) break; t += d*0.9; } } vec3 col = vec3(0.); float alpha = 0.; if(hit > 0.5){ // analytic normal: for a unit sphere with fbm displacement, the sphere normal dominates // Using position-as-normal gives a very close approximation and is ~3x cheaper vec3 n = normalize(p); vec3 ld = normalize(vec3(0.4, 0.7, -0.8)); float diff = max(dot(n, ld), 0.0); float rim = pow(1.0 - max(dot(n, -rd), 0.0), 2.0); float fres = pow(1.0 - max(dot(n, -rd), 0.0), 4.0); float band = (p.y*0.5 + 0.5) + 0.3*fbm(p*2.0 + u_t*0.6); vec3 base = palette(band); col = base * (0.35 + 0.65*diff); col += rim * vec3(0.4, 0.8, 1.0) * 0.6; col += fres * vec3(1.0, 0.7, 0.3) * 0.8; alpha = 1.0; } float glowD = length(uv) - 1.0; float glow = exp(-glowD*2.2) * 0.6; vec3 glowC = palette(0.5 + 0.4*sin(u_t*0.7)); col += glowC * glow * (1.0 - alpha*0.7); alpha = max(alpha, glow*1.1); float vig = 1.0 - smoothstep(0.7, 1.8, length(uv)*0.8); col *= mix(0.7, 1.0, vig); if(u_theme > 0.5){ col = 1.0 - col*0.95; col *= 0.95; } gl_FragColor = vec4(col, clamp(alpha, 0.0, 1.0)); } `; function compile(type, src){ const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if(!gl.getShaderParameter(s, gl.COMPILE_STATUS)){ console.error(gl.getShaderInfoLog(s)); return null; } return s; } const prog = gl.createProgram(); gl.attachShader(prog, compile(gl.VERTEX_SHADER, vs)); gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, fs)); gl.linkProgram(prog); gl.useProgram(prog); const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, -1,1, 1,-1, 1,1]), gl.STATIC_DRAW); const loc = gl.getAttribLocation(prog, 'a_pos'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0); const uT = gl.getUniformLocation(prog, 'u_t'); const uR = gl.getUniformLocation(prog, 'u_res'); const uM = gl.getUniformLocation(prog, 'u_mouse'); const uI = gl.getUniformLocation(prog, 'u_intensity'); const uTh = gl.getUniformLocation(prog, 'u_theme'); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); // Render at max 1024px (internal) for crisp details; CSS upscales function resize(){ const r = canvas.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); const maxSide = 1024; const targetW = r.width * dpr; const targetH = r.height * dpr; const ratio = Math.min(maxSide / Math.max(targetW, 1), maxSide / Math.max(targetH, 1), 1); const w = Math.max(1, Math.floor(targetW * ratio)); const h = Math.max(1, Math.floor(targetH * ratio)); if(canvas.width !== w || canvas.height !== h){ canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); } } resize(); const ro = new ResizeObserver(resize); ro.observe(canvas); function onMove(e){ const r = canvas.getBoundingClientRect(); stateRef.current.mx = ((e.clientX - r.left) / r.width - 0.5) * 2; stateRef.current.my = ((e.clientY - r.top) / r.height - 0.5) * 2; } window.addEventListener('mousemove', onMove, { passive: true }); const io = new IntersectionObserver((es) => { es.forEach(e => stateRef.current.visible = e.isIntersecting); }, { threshold: 0.01 }); io.observe(canvas); // Throttle to ~30fps (quality buffer is larger so lower fps balances cost) let mx = 0, my = 0, last = 0; const start = performance.now(); function frame(now){ rafRef.current = requestAnimationFrame(frame); if(!stateRef.current.visible) return; if(now - last < 40) return; last = now; const t = (now - start) / 1000; mx += (stateRef.current.mx - mx) * 0.08; my += (stateRef.current.my - my) * 0.08; gl.uniform1f(uT, t); gl.uniform2f(uR, canvas.width, canvas.height); gl.uniform2f(uM, mx, my); gl.uniform1f(uI, intensity); gl.uniform1f(uTh, theme === 'light' ? 1 : 0); gl.clearColor(0,0,0,0); gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLES, 0, 6); } rafRef.current = requestAnimationFrame(frame); return () => { cancelAnimationFrame(rafRef.current); ro.disconnect(); io.disconnect(); window.removeEventListener('mousemove', onMove); }; }, [intensity, theme]); // CSS upscales the small canvas — image-rendering keeps it smooth return ; } window.Orb = Orb;