// Several atlases in ONE layer, and an extruded sheet under a zooming
// camera. The layer declares two sheets - the core logo sliced 2x2, and a
// tileset built in code (four solid 32 px cells with a dark one-texel
// rim) - and its sprites draw from either, interleaved by `orderBy: "y"`
// across both sheets in the one draw: the multi-texture batch of PixiJS
// and Phaser, where Unity and Godot would break the batch on every
// texture change. The tileset is uploaded with `mipmap` for the far zoom,
// so it goes through `extrudeGrid` first: every cell gets a 4-texel gutter
// of its own edge pixels, and mip levels 0 through 2 sample clean at the
// cell edges (zoom out and the rims stay crisp; a plain sheet would smear
// each rim with the neighbouring cell's colour). The returned options
// slice the repacked sheet, so nothing downstream knows it was extruded.
//
// Wheel or pinch to zoom, drag empty space to pan.
//
// Debug commands: `zoom` ({ value, x?, y? }) parks the camera zoom (and
// the world point at its pivot) and returns the pose; `atlases` returns
// the layer's atlas list with each sheet's size and the frame each sprite
// kind draws.
import { createEffect, createInputMap, createPointerFeed, decodeImage, displayScale, onFrame, render, windowSize } from "@solidrt/core"
import type { DecodedImage } from "@solidrt/core"
import { addSprite, camera2dActions, camera2dBindings, createAtlas, createCamera2d, createSpriteLayer, extrudeGrid, feedPointer, fitOversample, grid } from "@solidrt/2d"
import type { Camera2dHandle, ViewHandle } from "@solidrt/2d"
import { registerDebug } from "sol:dev"
import logoBytes from "./logo.png" with { type: "binary" }
const WORLD = { width: 1600, height: 1000 }
const COLUMNS = 8
const ROWS = 5
const SPRITE = 96
// The tileset: four solid cells with a one-texel rim, extruded by this
// gutter (a power of two; the cell size is a multiple of it).
const CELL = 32
const CELLS = 4
const GUTTER = 4
const RIM = 1
const CELL_COLORS: [number, number, number][] = [
[230, 90, 70],
[90, 200, 120],
[80, 140, 240],
[240, 200, 80],
]
const RIM_COLOR: [number, number, number] = [20, 20, 30]
const MIN_ZOOM = 0.05
const MAX_ZOOM = 8
// Largest frame step, seconds: a stall eases on from here.
const MAX_DT = 0.1
let cam!: Camera2dHandle
let world!: ViewHandle
/** The tileset's pixels: `CELLS` solid cells in a row, each rimmed. */
function tileset(): DecodedImage {
let width = CELLS * CELL
let data = new Uint8Array(width * CELL * 4)
for (let cell = 0; cell < CELLS; cell++) {
let color = CELL_COLORS[cell]!
for (let y = 0; y < CELL; y++) {
for (let x = 0; x < CELL; x++) {
let rim = x < RIM || y < RIM || x >= CELL - RIM || y >= CELL - RIM
let [r, g, b] = rim ? RIM_COLOR : color
let i = (y * width + cell * CELL + x) * 4
data[i] = r
data[i + 1] = g
data[i + 2] = b
data[i + 3] = 255
}
}
}
return { data, width, height: CELL }
}
function App() {
let logo = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
let logoFrames = grid(logo, 2, 2)
// The tileset, extruded for its mip chain: the repacked image goes to
// createAtlas, the returned options to grid.
let extruded = extrudeGrid(tileset(), CELLS, 1, GUTTER)
let tiles = createAtlas(extruded.image, { mipmap: true, label: "tiles-atlas" })
let tileFrames = grid(tiles, CELLS, 1, extruded.options)
let win = { width: 1, height: 1 }
// One layer over both sheets; y order interleaves them in the one draw.
let layer = createSpriteLayer([logo, tiles], { capacity: COLUMNS * ROWS, orderBy: "y", label: "atlases" })
world = layer.createView({ width: win.width, height: win.height, clearColor: [0.05, 0.05, 0.09, 1], label: "world" })
for (let i = 0; i < COLUMNS * ROWS; i++) {
let col = i % COLUMNS
let row = Math.floor(i / COLUMNS)
let x = (col + 0.5) * (WORLD.width / COLUMNS)
// Rows lean so that neighbours overlap and the y order shows.
let y = (row + 0.5) * (WORLD.height / ROWS) + (col % 2) * SPRITE * 0.4
let frame = (col + row) % 2 === 0 ? logoFrames[i % 4] : tileFrames[i % CELLS]
addSprite(layer, { x, y, w: SPRITE, h: SPRITE, frame })
}
cam = createCamera2d(world, { world: WORLD, minZoom: MIN_ZOOM, maxZoom: MAX_ZOOM })
let pointer = createPointerFeed()
feedPointer(world, pointer)
let input = createInputMap(camera2dActions)
input.bind(camera2dBindings({ pointer }))
input.drive(cam.axes)
createEffect(
() => ({ size: windowSize(), scale: displayScale() }),
({ size, scale }) => {
win.width = Math.max(1, size.width)
win.height = Math.max(1, size.height)
world.setSize(win.width, win.height)
world.setOversample(fitOversample(scale, win.width, win.height, win.width * win.height * scale * scale))
},
)
let last = 0
onFrame(tick => {
let t = tick / 1000
let dt = Math.min(MAX_DT, Math.max(0, t - last))
last = t
cam.update(dt)
})
registerDebug("atlases", () => ({
atlases: layer.atlases.map(a => ({ texture: a.texture, width: a.width, height: a.height })),
logoFrame: logoFrames[0],
tileFrame: tileFrames[0],
extruded: { width: extruded.image.width, height: extruded.image.height, options: extruded.options },
}))
return (
Atlases
two sheets, one layer, one draw - wheel or pinch to zoom, drag to pan - the rimmed tiles come from an extruded, mipmapped sheet and stay crisp at any zoom
)
}
render(() => )
registerDebug("zoom", (args?: { value?: number; x?: number; y?: number }) => {
let pose: { zoom?: number; x?: number; y?: number } = {}
if (typeof args?.value === "number") pose.zoom = args.value
if (typeof args?.x === "number") pose.x = args.x
if (typeof args?.y === "number") pose.y = args.y
cam.set(pose)
return cam.pose()
})