Pure Three.js component — a fixed N×N grid of image tiles, drag (pointer) or scroll (wheel) to browse as if the grid extends infinitely in every direction, using coordinate-wrapping (modulo offset) technique, not continuously spawning/destroying meshes.
WHAT
Purpose and context
- Purpose: build an N×N WebGL image grid browsable infinitely in every direction (drag/scroll) via coordinate-wrapping, prioritizing copying the original as-is in
Rheinmir/uiux-asset. - Trigger (when to use):
- Portfolio/gallery/poster-wall needs a feel of "infinite browsing" in every direction (not ordinary one-directional scrolling).
- User says "infinite image grid", "infinite scroll grid webgl", "draggable image wall", "infinitely scrolling poster wall".
- Non-goals:
- DO NOT use when particles need to converge into ONE specific image by scroll progress (that is
threejs-particle-morph— it has a "reached target" state, whereas this has no target, only continuous browsing). - DO NOT use for a transition effect between 2 images on the same frame (that is
blur).
- DO NOT use when particles need to converge into ONE specific image by scroll progress (that is
Mental model
offset ảo (kéo/cuộn/inertia) → mỗi mesh cố định: wrap(localX - offset.x) → vị trí hiển thị → chỉ số tile ảo đổi → đổi material.map từ pool texture cố định. Camera stays still, no add/remove mesh at runtime (details: Reference — Mechanism).
Input and output contract
| Field | Required? | Meaning | |
|---|---|---|---|
| In | images/content + color | no | if missing → demo draws a placeholder canvas itself, runs offline |
| In | project constraints: whether external libs/files may be pulled in | yes | allowed → original version; not allowed → assets/ vanilla |
| In | gridSize/tileSize/gap/poolSize |
no | initialization parameter InfiniteGrid |
| Out | file .html (+ _assets/) or demo.html/.css/.js adapted in the project |
yes | keep the attribution comment on the first line if it is the original version |
| Out | Playwright verification evidence | yes | before/during/after images when dragging in 4 directions + clean console |
Rules and capabilities
- RULE-01 (MUST): Limit simultaneous tiles/textures: mobile GPUs limit the number of texture units active at once. Keep the actually displayed grid (not counting the wrap buffer) at around ≤ 5×5 tiles at once; the demo defaults to
gridSize=7(5 displayed + 1 buffer ring on each side so tiles jump smoothly, no pop-in). Need more different images than the number of displayed tiles → use texture atlas (merge multiple images into 1 texture, change UV offset instead of changingmaterial.mapeach individual texture) instead of increasingpoolSizeinfinitely — each individual texture still costs 1 texture unit even if not displayed at the same time, if the code keeps a reference. - RULE-02 (MUST): Passive: false is mandatory: listener
wheelandpointermovemust register{ passive: false }then callpreventDefault()in the right place (only when dragging or when inside the canvas) — without this flag, modern browsers default to passive sopreventDefault()is ignored SILENTLY (no error, no warning) and the background page still scrolls/zooms in parallel with the grid, causing jitter/conflict on mobile. CSStouch-action: noneon the container is a necessary accompanying condition, it cannot replacepreventDefault. - RULE-03 (SHOULD): Ortho camera is used for the demo (flat grid, no perspective distortion) — switch to
PerspectiveCameraif a depth effect is needed (distant tiles blurred/shrinking), in which case the wrap formula stays the same, only the way world-units map to pixels changes. - RULE-04 (MUST): Verify with
playwright-verify— capture before/during/after a strong drag (test all 4 directions), read the console (WebGL context errors are usually silent in the canvas, they do not throw a clear JS error). - Capabilities: read/copy files from an external asset repo (or
assets/of the skill); write files into the project; serve over local HTTP; control a headless browser to capture + read the console.
Failure boundaries
- The project is not allowed to pull external libs → partial is valid: use the trimmed version
assets/instead of the original. - Open via
file://→ texture/asset error → blocked until it is run over HTTP. - Console has a WebGL error or the background page scrolls in parallel with the grid → failed verify, fix (passive:false +
touch-action: none) then verify again. - The request is actually for particles converging/transitioning 2 images → clarify, switch
threejs-particle-morph/blur.
HOW
Main workflow
| Step | Type | Inputs | Action | Outputs/exit | Failure/next |
|---|---|---|---|---|---|
| W01 | judgment | request | Confirm the right skill (infinite browse grid, no target) and choose the original version or assets/ |
selected source | not allowed to pull external libs → B01 |
| W02 | effect | original pen | Open link run to see the effect → copy the whole file .html (+ _assets/) into the project, keep the attribution comment |
file in the project | — |
| W03 | judgment | copied file | Only modify content/images/colors | adapted file | — |
| W04 | effect | file | Run locally over HTTP, not file:// |
page runs | asset error → recheck the path |
| W05 | deterministic | page | Verify with playwright-verify: capture before/during/after a strong drag in 4 directions + read the console |
images + clean console | error → fix, repeat W04 |
Step-by-step details (source of truth for W01–W05):
Original version — use FIRST (copy the file as-is, don't rewrite it)
The author's VERBATIM original code (public CodePen = MIT) is in an external repo Rheinmir/uiux-asset — live gallery: https://rheinmir.github.io/uiux-asset/scroll-effects/ .
Process: open the link run to see the exact effect → copy the file as-is .html (+ the folder _assets/ if the file references it) into the project → keep the attribution comment on the first line → only change the content/images/colors. Run locally over HTTP, not file://.
git clone --depth 1 https://github.com/Rheinmir/uiux-asset if you need the whole offline set.
| Original Pen — author | Link |
|---|---|
| Infinite Scrollable and Draggable WebGL Grid — ReGGae | run · code |
| Infinite 3D Poster Scroll Wall — photodow | run · code |
| Infinite Scrolling with Image Cards — jkantner | run · code |
assets/ in this skill is just a self-written vanilla condensed version — use it when the project is NOT allowed to pull external libs; by default use the original version above.
Branches
| ID | Kind | Guard | Behavior | Skip / failure | Rejoin |
|---|---|---|---|---|---|
| B01 | conditional_required | project is NOT allowed to pull external libs | use assets/demo.html + demo.css + demo.js, ADAPT the parameters and replace placeholders with real images (Assets section) |
— | W04 |
| B02 | user_optional | need a depth effect | change Ortho to PerspectiveCamera, keep the wrap formula, only change the world-unit → pixel mapping |
— | W05 |
| B03 | conditional_required | need more different images than the number of visible tiles | use a texture atlas (change the UV offset) instead of increasing poolSize |
— | W05 |
Validation and stopping
Done when Playwright captures enough before/during/after drags in all 4 directions, the grid doesn't pop in, the background page doesn't scroll along, and the console has no errors (WebGL errors are usually silent — you must read the console, not just look at the images).
Examples
- Positive: "make an infinite scrolling poster wall for a portfolio" → copy
infinite-3d-poster-scroll-wall.htmlfromRheinmir/uiux-asset, replace the image, run locally over HTTP, Playwright drags in 4 directions → tiles jump seamlessly, clean console. - Boundary/failure: use
assets/demo.jsbut registerwheeldoes not{ passive: false }→preventDefault()get silently ignored, the background page scrolls along with the grid → verify fails, add the flag +touch-action: nonethen rerun.
Reference — Assets
assets/demo.html + assets/demo.css + assets/demo.js — read then ADAPT (change gridSize/tileSize/gap/poolSize when initializing InfiniteGrid, replace _drawPlaceholder() with a real image via THREE.TextureLoader loading a URL instead of a canvas data URL). The demo draws its own placeholder (canvas numbers + hash colors) so it runs immediately without images/network.
Reference — Mechanism
- Fixed mesh, no extra generation:
gridSize × gridSizemesh (PlaneGeometryshares 1 instance + separate material per mesh) built once at init, NEVER add/remove mesh at runtime — completely different from the naive approach of "spawning a new mesh when dragging into an area that doesn't have one". - Virtual offset, don't move the camera infinitely far away: drag/scroll only accumulates into
offset.x/offset.y(a float, can theoretically increase infinitely, never hits a practical limit). The camera stays still. - Wrap with modulo: each mesh has a fixed local coordinate
localX/localY(position within the original N×N grid). The actual displayed position each frame =wrap(localX - offset.x), withwrap(raw) = ((raw + half) % full + full) % full - half(full = gridSize*pitch,half = full/2) — folds infinite coordinates into the display range around the center, exactly the standard mod formula for this technique. - Texture reuse when tiles jump: each mesh only changes
material.mapwhen its "virtual tile index" changes (computed fromwrapped_position + offset, rounded bypitch) — that is, exactly when the tile leaves the viewport on one side and reappears on the opposite side. Textures come from fixed pool (poolSizetextures loaded once viaTHREE.TextureLoader), do NOT create a new texture on each jump. - Network-independent placeholder: the texture pool is generated from
<canvas>drawing numbers + colors (golden-angle hue spread to distinguish visually),canvas.toDataURL()then still loaded viaTHREE.TextureLoader.load(dataUrl)— the demo runs offline but still follows the real image-loading API path (when swapping in a real image you only change the URL, not the code flow). - Inertia: pointer drag records
velocity(movement delta of the last pointermove); on release, each frameoffset += velocity; velocity *= damping (0.92)until below the threshold — lerps toward 0 gradually, not a real physics simulation. - Cleanup:
destroy()removes pointerdown/pointermove/pointerup/pointercancel/wheel/resize listeners, disposes each material, disposes the shared geometry (once), disposes each texture in the pool (once — do NOT dispose per mesh because textures are shared by many meshes), disposes the renderer, removes the canvas from the DOM.