feat(vk): draft the VK community cover

Adds VK's own cover template (1920x768) and a self-contained generator that
renders an Эрудит board in perspective with ГРАМОТНОСТЬ / СОПЕРНИК /
ЭРУДИЦИЯ / УМ already played, in six art-direction variants for review.

The position is checked, not asserted: assertLegal re-reads the finished grid
and fails the build unless the centre is covered, every declared word is on the
board, and no maximal run of two or more letters is a word nobody played. All
four words are in the Эрудит dictionary and tile values come from
scrabble-solver rules.Erudit().

The camera has pitch only, so the perspective warp reduces to an exact
per-scanline remap of a flat texture; tile thickness is filled as 3D quads
between the board plane and the tile plane. skia-canvas is borrowed from the
render sidecar rather than installed a second time for a one-off asset build.

Composition respects the two VK keep-outs measured off the template: the top
130 px some clients crop, and the mobile avatar circle over the bottom-left —
which is why the default word arrangement keeps that corner empty.
This commit is contained in:
Ilia Denisov
2026-07-27 15:21:46 +02:00
parent 9cdd7138e3
commit 92a97c6c93
10 changed files with 894 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
# VK community cover
The cover art for the VK community (`1920×768`). The community avatar — the «Э» tile — is
uploaded separately; this is only the backdrop behind it.
`build-cover.mjs` is the whole thing: a small software renderer that draws an Эрудит board in
perspective with the opening `ГРАМОТНОСТЬ / СОПЕРНИК / ЭРУДИЦИЯ / УМ` already played, and
emits one PNG per art-direction variant plus a contact sheet.
```sh
cd assets/vk/cover
node build-cover.mjs # every variant -> preview/
node build-cover.mjs night-spot # one variant
FAST=1 node build-cover.mjs # 1x instead of 2x supersampling, for quick iteration
ZONES=1 node build-cover.mjs # overlay the VK keep-outs on the render
LAYOUT=cross node build-cover.mjs # the alternative word arrangement
```
It needs no install: skia-canvas comes from the render sidecar's `node_modules`, and the
letters are set in `Spectral-Bold.ttf` from the icon brand folder.
## The position
`ГРАМОТНОСТЬ` opens through the centre square (its `Т` on H8) and the other three words hook
onto a shared letter. `assertLegal` re-reads the finished grid and fails the build if the
centre is uncovered, if a declared word is missing, or if any maximal run of two or more
letters is a word nobody played — so the board on the cover is always a position the engine
would accept. All four words are in the Эрудит dictionary; tile values come from
`scrabble-solver` `rules.Erudit()`.
The default `ladder` arrangement puts both long words horizontally. The `cross` arrangement
hangs `ЭРУДИЦИЯ` down the left instead, which looks more symmetric but collides with the VK
mobile avatar (below).
## VK keep-outs
Measured off VK's own template (`../VK_group_cover_template.fig`), in cover pixels:
| Zone | Area | Why |
|------|------|-----|
| Top crop | `y < 130` | trimmed by some clients — nothing important there |
| Mobile avatar | circle, centre `(283, 818)`, r `283` | the community picture sits over the bottom-left in the VK app |
`ZONES=1` paints both so a composition can be checked against them.
## Rendering technique
The camera has pitch only — no roll, no yaw — so a board-space horizontal line always maps to
a screen-space horizontal line. That makes the perspective warp an exact per-scanline remap of
a flat texture rather than a general homography: the board surface and the tile faces are each
drawn once, flat, at high resolution, then replayed band by band (`warpPlane`). Tile thickness
is filled as real 3D quads between the two planes, so the tiles have visible edges.
Two things that are easy to get wrong here and cost a hundredfold in render time:
`ctx.filter = 'blur(...)'` applies per drawing call, so many blurred shapes must be collected
into one offscreen layer and blurred once (`blurred`); and `drawImage` re-snapshots a live
`Canvas` source on every call, so a texture sampled hundreds of times must be baked into an
`Image` first (`freeze`).