Direct DOM manipulation via refs. scrollY in a ref, not state. useEffect + requestAnimationFrame loop. React renders once on mount.
Grass moved from DOM (preserve-3d containers with ~200 nodes) to two <canvas> elements with manual 2D perspective projection. Eliminated 2 preserve-3d grass ground planes and all grass DOM nodes.
O(1) low cutoff computation for linearly-spaced billboards. Forward iteration with early break on far cull (perspScale < 0.03). prevLow/prevHigh range tracking for O(1) amortized display: none updates. Only boundary elements toggle each frame.
invTwoR = 1 / (2 * planetRadius)precomputed — multiplication instead of division per elementCOS_A,SIN_Aprecomputed at module level- Grass pre-sorted by
ydescending — enablesbreakinstead ofcontinuefor scrolled-past and back-canvas-done conditions - Viewport culling in grass canvas loop (
screenY - h > HorscreenY < 0)
- Google Fonts loaded with
preconnecttofonts.googleapis.com/fonts.gstatic.comanddisplay=swapin layout (the older non-blockingmedia="print" onloadswap is no longer used) fetchPriority="high"on billboard/star<img>elements (incomponents/path/PathNode.tsx)useEffect(notuseLayoutEffect) withreadystate — static elements (sky, ground, cover) paint immediately- Only 4 dynamic elements gated by
visibility: ready ? 'visible' : 'hidden' - Grass image
decode()does NOT blocksetReady(true)— important for Safari SVG decode performance
will-change: transform on 100+ elements risks excessive GPU memory. contain may conflict with preserve-3d. Decided not worth the risk.
Each billboard is currently 3 nodes (outer div → inner div → img). Could merge inner div's translateY onto img, saving ~120 nodes. Low risk, trivial to implement.
Would require moving elements between preserve-3d containers at the inflection boundary. High flicker risk — this is the original problem the two-scene architecture was designed to solve.
- Eliminate
readyre-render by using a ref + direct style mutation instead ofuseState - Move
billboards/grassarrays to module level (outside component) since they're static - Precompute
g.rotation * Math.PI / 180(rotationRad) at generation time