Load every picture of a project ahead so animated tiles don't flicker
A texture was fetched only when it was drawn for the first time. Until the file arrived, the tilemap skipped the cell for that frame. An animated tile whose next step sat in another picture therefore blinked away once on its first cycle, and sprite animations did the same. - FileLibrary gets preload(files). It requests every file that is not held, loading or known to have failed. Each library reports what it holds through holds(). - src/core/model/projectFiles.ts lists the files a project names per library: uploaded textures, fonts and pixel art cels. - App.ts runs the preload on every project event. That covers opening a project, new assets and edits from a collaborator. - A new test hook __gm.heldPictures() reports which textures of the project are in memory. Not done on purpose: - The tilemap keeps no fallback frame while a step is still loading. - Sounds still load on first play. Not checked: the flicker itself was not reproduced in the browser, the e2e check measures "held in memory". Modules 42, 44 and 47 read textures from the project and were not run locally. Tests: - ./check.sh: 174 files, 2518 tests, CHECK EXIT 0 - e2e 39 + 40: ALL PASSED, 96 checks - 3 new unit tests for preload and the project file list - New e2e check 40k-ii: an unplaced picture is held after a reload - Counter-probe: without the preload 40k-ii fails with "Autotiles: held false, unworn: held false" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6015869297
commit
7e829f737e
11 files changed
+190
-2
No files matched your search
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1422 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1423 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { isFontFile } from './font';
|
||||
import { celFilesOf } from './pixelart';
|
||||
import type { Project } from './Project';
|
||||
import { isTextureFile } from './texture';
|
||||
|
||||
/** The stored files a project draws with, per library – what is loaded before anything asks. */
|
||||
export interface ProjectFiles {
|
||||
textures: string[];
|
||||
fonts: string[];
|
||||
cels: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file an asset names, used or not: which frame an animation or a tile reaches next is only
|
||||
* known while it plays, and a file fetched then shows as a gap. A name that is no stored file –
|
||||
* an upload that failed – is left out, as the libraries would never get it anyway.
|
||||
*/
|
||||
export function projectFiles(project: Project): ProjectFiles {
|
||||
const files: ProjectFiles = { textures: [], fonts: [], cels: [] };
|
||||
for (const asset of project.assets) {
|
||||
if (asset.type === 'texture' && isTextureFile(asset.file)) files.textures.push(asset.file);
|
||||
else if (asset.type === 'font' && isFontFile(asset.file)) files.fonts.push(asset.file);
|
||||
else if (asset.type === 'pixelart') files.cels.push(...celFilesOf(asset));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import { projectFiles } from '../core/model/projectFiles';
|
||||
import type { AuthStore, SignedIn } from '../core/store/AuthStore';
|
||||
import { HttpAuthStore } from '../core/store/HttpAuthStore';
|
||||
import { HttpFileStore } from '../core/store/HttpFileStore';
|
||||
@@ -35,6 +36,13 @@ const FONT_BASE = '/game-maker/data/fonts';
|
||||
const SOUND_BASE = '/game-maker/data/sounds';
|
||||
const PIXEL_BASE = '/game-maker/data/pixels';
|
||||
|
||||
function preloadProjectFiles(state: EditorState): void {
|
||||
const files = projectFiles(state.project);
|
||||
textureLibrary.preload(files.textures);
|
||||
fontLibrary.preload(files.fonts);
|
||||
pixelCels.preload(files.cels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the sign-in screen until somebody is signed in, and answers with their name. Returns
|
||||
* right away when the session from an earlier visit is still good – which is what lets a
|
||||
@@ -97,6 +105,10 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
// Drawn pictures (#185) are looked up in the project this session holds; the next session
|
||||
// installs its own, which is also what lets go of everything the old one had drawn.
|
||||
textureLibrary.setPictureFactory(projectPictureFactory(() => state.project));
|
||||
// Everything the project names is fetched as soon as it is named, not when it is first drawn:
|
||||
// an animation reaching a frame from another sheet would otherwise blink out once. Asked on
|
||||
// every change, which is cheap – a file that is held or on its way is skipped.
|
||||
state.on('project', () => preloadProjectFiles(state));
|
||||
const editor = new Editor(
|
||||
app,
|
||||
root,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FrameRect, resolvePicture } from '../../core/model/texture';
|
||||
import { type FrameRect, isTextureFile, resolvePicture } from '../../core/model/texture';
|
||||
import { effectiveScale, gameClock } from '../../core/time';
|
||||
import { afterFrames, isRendering, renderCount, requestRender } from '../../ui';
|
||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||
@@ -34,6 +34,13 @@ export function renderHooks({ app, state, editor }: HookContext) {
|
||||
for (let i = 0; i < width; i++) row.push(pixelAt(x + i, y));
|
||||
return row;
|
||||
},
|
||||
/** Every uploaded picture the project names and whether it is in memory, drawn anywhere or not. */
|
||||
heldPictures: () =>
|
||||
state.project.assets.flatMap((asset) =>
|
||||
asset.type === 'texture' && isTextureFile(asset.file)
|
||||
? [{ name: asset.name, held: textureLibrary.peek(asset.file) !== null }]
|
||||
: [],
|
||||
),
|
||||
/** The filter and wrap the GPU sampler actually got, next to the model's – only the driver knows the former. */
|
||||
textureFilter: (file: string, rect: FrameRect | null = null) => {
|
||||
const texture = textureLibrary.peek(file, rect);
|
||||
|
||||
@@ -39,11 +39,23 @@ export abstract class FileLibrary<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts loading every file not held yet. Waiting for the first draw left a gap of a frame or
|
||||
* more where the picture should be – an animated tile blinked out the first time it reached a
|
||||
* step from a sheet of its own.
|
||||
*/
|
||||
preload(files: Iterable<string>): void {
|
||||
for (const file of files) if (file && !this.holds(file)) this.startLoading(file);
|
||||
}
|
||||
|
||||
protected abstract fetchFile(url: string, file: string): Promise<T>;
|
||||
|
||||
/** Keeps what arrived; the listeners run right after. */
|
||||
protected abstract keep(file: string, loaded: T): void;
|
||||
|
||||
/** True once the file is in memory, whether it came from the server or was made here. */
|
||||
protected abstract holds(file: string): boolean;
|
||||
|
||||
/** A file that could not be loaded; it is not asked for again. */
|
||||
protected hasFailed(file: string): boolean {
|
||||
return this.failed.has(file);
|
||||
|
||||
@@ -33,6 +33,10 @@ export class FontLibrary extends FileLibrary<FontFace> {
|
||||
document.fonts.add(loaded);
|
||||
this.ready.add(file);
|
||||
}
|
||||
|
||||
protected holds(file: string): boolean {
|
||||
return this.ready.has(file);
|
||||
}
|
||||
}
|
||||
|
||||
export const fontLibrary = new FontLibrary();
|
||||
@@ -83,6 +83,10 @@ export class PixelCels extends FileLibrary<CelPixels> {
|
||||
protected keep(file: string, cel: CelPixels): void {
|
||||
this.cels.set(file, cel);
|
||||
}
|
||||
|
||||
protected holds(file: string): boolean {
|
||||
return this.cels.has(file);
|
||||
}
|
||||
}
|
||||
|
||||
export const pixelCels = new PixelCels();
|
||||
@@ -154,6 +154,10 @@ export class TextureLibrary extends FileLibrary<Texture> {
|
||||
texture.source.style.update();
|
||||
this.loaded.set(file, texture);
|
||||
}
|
||||
|
||||
protected holds(file: string): boolean {
|
||||
return this.loaded.has(file);
|
||||
}
|
||||
}
|
||||
|
||||
function cropKey(file: string, rect: FrameRect): string {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* What the editor loads ahead of the first draw: every stored file a project's assets name, so an
|
||||
* animation or an animated tile does not blink out the first time it reaches a frame of its own.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Asset } from '../../src/core/model/Asset';
|
||||
import { createEmptyProject } from '../../src/core/model/Project';
|
||||
import { projectFiles } from '../../src/core/model/projectFiles';
|
||||
|
||||
const PNG_A = `${'a'.repeat(64)}.png`;
|
||||
const PNG_B = `${'b'.repeat(64)}.webp`;
|
||||
const FONT = `${'c'.repeat(64)}.woff2`;
|
||||
const CEL_A = `${'d'.repeat(64)}.cel`;
|
||||
const CEL_B = `${'e'.repeat(64)}.cel`;
|
||||
|
||||
function projectWith(assets: unknown[]) {
|
||||
const project = createEmptyProject('p', 'P');
|
||||
project.assets.push(...(assets as Asset[]));
|
||||
return project;
|
||||
}
|
||||
|
||||
describe('the files a project names', () => {
|
||||
it('lists every texture, font and cel, drawn somewhere or not', () => {
|
||||
const project = projectWith([
|
||||
{
|
||||
id: 't1',
|
||||
type: 'texture',
|
||||
name: 'Water 1',
|
||||
props: {},
|
||||
file: PNG_A,
|
||||
width: 16,
|
||||
height: 16,
|
||||
frames: [],
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
type: 'texture',
|
||||
name: 'Water 2',
|
||||
props: {},
|
||||
file: PNG_B,
|
||||
width: 16,
|
||||
height: 16,
|
||||
frames: [],
|
||||
},
|
||||
{ id: 'f', type: 'font', name: 'Font', props: {}, file: FONT },
|
||||
{
|
||||
id: 'px',
|
||||
type: 'pixelart',
|
||||
name: 'Px',
|
||||
props: {},
|
||||
width: 8,
|
||||
height: 8,
|
||||
layers: [{ id: 'l', name: 'L', opacity: 1 }],
|
||||
frames: [
|
||||
{ id: 'f1', cels: { l: CEL_A } },
|
||||
{ id: 'f2', cels: { l: CEL_B } },
|
||||
{ id: 'f3', cels: { l: CEL_A } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(projectFiles(project)).toEqual({ textures: [PNG_A, PNG_B], fonts: [FONT], cels: [CEL_A, CEL_B] });
|
||||
});
|
||||
|
||||
it('leaves out a name that is no stored file', () => {
|
||||
const project = projectWith([
|
||||
{ id: 't', type: 'texture', name: 'Failed', props: {}, file: '', width: 0, height: 0, frames: [] },
|
||||
{ id: 'f', type: 'font', name: 'Failed', props: {}, file: 'x.woff2' },
|
||||
]);
|
||||
expect(projectFiles(project)).toEqual({ textures: [], fonts: [], cels: [] });
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,14 @@ class HandLibrary extends FileLibrary<string> {
|
||||
protected keep(file: string, loaded: string): void {
|
||||
this.kept.set(file, loaded);
|
||||
}
|
||||
|
||||
protected holds(file: string): boolean {
|
||||
return this.kept.has(file);
|
||||
}
|
||||
|
||||
get waiting(): number {
|
||||
return this.pending.length;
|
||||
}
|
||||
}
|
||||
|
||||
describe('the file library', () => {
|
||||
@@ -64,4 +72,17 @@ describe('the file library', () => {
|
||||
await library.land();
|
||||
expect(gone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preloads every file once, before anything draws with it', async () => {
|
||||
const library = new HandLibrary();
|
||||
library.preload(['a.png', 'b.png', 'a.png', '']);
|
||||
expect(library.waiting).toBe(2);
|
||||
// Asked again while on its way, and again once it is held: no second fetch either time.
|
||||
library.preload(['a.png', 'b.png']);
|
||||
expect(library.waiting).toBe(2);
|
||||
await library.land();
|
||||
expect([...library.kept.keys()]).toEqual(['a.png', 'b.png']);
|
||||
library.preload(['a.png', 'b.png', 'c.png']);
|
||||
expect(library.waiting).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -962,6 +962,19 @@ export async function tilemap(t) {
|
||||
const inMemory = await json(
|
||||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers.map((l) => l.cells)`,
|
||||
);
|
||||
// For 40k-ii: a picture of its own that nothing wears, so only loading ahead can bring it in.
|
||||
await evaluate(`(async () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 8;
|
||||
const g = canvas.getContext('2d');
|
||||
g.fillStyle = 'rgb(201, 17, 99)';
|
||||
g.fillRect(0, 0, 8, 8);
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([blob], 'unworn.png', { type: 'image/png' }));
|
||||
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
|
||||
})()`);
|
||||
await t.sleep(2500);
|
||||
await saved();
|
||||
const storedMap = (await api(`projects/${projectId}`)).project.assets.find((a) => a.id === made.id);
|
||||
const storedLayers = storedMap?.layers ?? [];
|
||||
@@ -984,6 +997,20 @@ export async function tilemap(t) {
|
||||
JSON.stringify({ reloaded, inMemory }),
|
||||
);
|
||||
|
||||
// 40k-ii. Every picture the project names is loaded right after opening, not on its first draw:
|
||||
// a tile stepping to a picture fetched only then blinked out once. "unworn" was pasted
|
||||
// above and is placed nowhere.
|
||||
let pictures = await json('__gm.heldPictures()');
|
||||
for (let i = 0; i < 20 && !pictures.every((p) => p.held); i++) {
|
||||
await t.sleep(150);
|
||||
pictures = await json('__gm.heldPictures()');
|
||||
}
|
||||
check(
|
||||
'every picture of the project is in memory after opening, drawn anywhere or not',
|
||||
pictures.some((p) => p.name === 'unworn') && pictures.every((p) => p.held),
|
||||
JSON.stringify(pictures),
|
||||
);
|
||||
|
||||
// 40l. Collision (#132). The switch sits on the tile, and the map outlines where it now blocks.
|
||||
// The map holds a 3 × 3 block and, on the second layer, one cell at (4, 0).
|
||||
await openTab(t, 'scene');
|
||||
|
||||
Reference in new issue
Block a user