Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions src/prepare/assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
deterministicAtlasDebugSourceImagesSymbol,
replaceQuakeRenderBundleWorldAtlas,
} from "./deterministicAtlas.mjs";
import { prepareQuakeEffectSprites } from "./effectSprites.mjs";
import { applyQuakeWorldPlanarComponents } from "./worldPlanarComponents.mjs";
import { QUAKE_UNIT_SCALE } from "../quakeScale.js";

Expand Down Expand Up @@ -120,11 +121,13 @@ const weaponModelOutputDir = path.join(quakeOutputDir, "w");
const pickupOutputPath = path.join(quakeOutputDir, "pickups.json");
const progsOutputPath = path.join(quakeOutputDir, "progs.json");
const soundManifestOutputPath = path.join(quakeOutputDir, "sounds.json");
const effectSpritesOutputPath = path.join(quakeOutputDir, "effects.json");
const sourceProgramFactsInputPath = path.join(projectRoot, "src/generated/quakeProgramFacts.json");
const QUAKE_DEFAULT_WEAPON_VIEWMODEL_PATH = "progs/v_shot.mdl";
const sourcePath = path.join(projectRoot, "src/prepare/scene.ts");
const textureOutputDir = path.join(quakeAssetOutputDir, "t");
const soundOutputDir = path.join(quakeOutputDir, "s");
const effectSpritesOutputDir = path.join(quakeAssetOutputDir, "e");
const renderBundleOutputDir = path.join(quakeAssetOutputDir, "b");
const quakeRenderBundleAvifQuality = Number.parseInt(process.env.QUAKE_RENDER_BUNDLE_AVIF_QUALITY ?? "70", 10);
const quakeRenderBundleAvifEffort = Number.parseInt(process.env.QUAKE_RENDER_BUNDLE_AVIF_EFFORT ?? "4", 10);
Expand Down Expand Up @@ -609,6 +612,7 @@ try {
await rm(path.join(quakeOutputDir, "t"), { recursive: true, force: true });
await rm(path.join(quakeOutputDir, "b"), { recursive: true, force: true });
await rm(path.join(quakeOutputDir, "p"), { recursive: true, force: true });
await rm(path.join(quakeOutputDir, "e"), { recursive: true, force: true });
await copyStaticPublicAssets();
}

Expand Down Expand Up @@ -931,12 +935,20 @@ try {
await writeFile(pickupOutputPath, JSON.stringify(pickupModels));
const soundManifest = await runPrepareStep("sounds", () => exportQuakeSounds(pak, parseQuakePakDirectory));
await writeFile(soundManifestOutputPath, JSON.stringify(soundManifest));
const effectSprites = await runPrepareStep("effect sprites", () => prepareQuakeEffectSprites({
outputDir: effectSpritesOutputDir,
pak,
parsePakDirectory: parseQuakePakDirectory,
publicUrlForOutputPath: generatedPublicUrl,
}));
await writeFile(effectSpritesOutputPath, JSON.stringify(effectSprites));
await writeFileAtomic(manifestOutputPath, JSON.stringify(buildQuakeAssetManifest(
preparedMaps,
programMetadata,
pickupModels,
soundManifest,
sourceProgramFacts,
effectSprites,
)));
await pruneUnreferencedTextureFiles([
...preparedMaps.map((item) => item.outputPath),
Expand Down Expand Up @@ -979,6 +991,7 @@ try {
console.log(`Wrote ${path.relative(projectRoot, progsOutputPath)}`);
console.log(`Wrote ${path.relative(projectRoot, pickupOutputPath)}`);
console.log(`Wrote ${path.relative(projectRoot, soundManifestOutputPath)} (${Object.keys(soundManifest.sounds).length} sounds)`);
console.log(`Wrote ${path.relative(projectRoot, effectSpritesOutputPath)} (${Object.keys(effectSprites.sprites).length} sprites)`);
console.log(`Wrote ${path.relative(projectRoot, manifestOutputPath)}`);
}
}
Expand Down Expand Up @@ -2358,7 +2371,14 @@ async function loadQuakeSourceProgramFacts() {
}
}

function buildQuakeAssetManifest(preparedMaps, programMetadata, pickupModels, soundManifest, sourceProgramFacts = null) {
function buildQuakeAssetManifest(
preparedMaps,
programMetadata,
pickupModels,
soundManifest,
sourceProgramFacts = null,
effectSprites = null,
) {
const preparedModelPaths = new Set(Object.keys(pickupModels?.models ?? {}));
const preparedSoundPaths = new Set(Object.keys(soundManifest?.sounds ?? {}));
const maps = preparedMaps.map(({ mapName, mapPath, outputPath, prepared }) => {
Expand All @@ -2384,18 +2404,20 @@ function buildQuakeAssetManifest(preparedMaps, programMetadata, pickupModels, so
};
});
const mapNames = new Set(maps.map((map) => map.mapName));
const assets = {
weaponModelUrl: generatedPublicUrl(weaponOutputPath),
weaponModelUrls: quakeWeaponModelUrlMap(sourceProgramFacts),
pickupModelsUrl: generatedPublicUrl(pickupOutputPath),
programMetadataUrl: generatedPublicUrl(progsOutputPath),
soundManifestUrl: generatedPublicUrl(soundManifestOutputPath),
...(effectSprites ? { effectSpritesUrl: generatedPublicUrl(effectSpritesOutputPath) } : {}),
};
return {
version: 1,
assetRoot: quakePublicPath,
startMap: mapNames.has(quakeStartMap) ? quakeStartMap : maps[0]?.mapName ?? quakeStartMap,
maps,
assets: {
weaponModelUrl: generatedPublicUrl(weaponOutputPath),
weaponModelUrls: quakeWeaponModelUrlMap(sourceProgramFacts),
pickupModelsUrl: generatedPublicUrl(pickupOutputPath),
programMetadataUrl: generatedPublicUrl(progsOutputPath),
soundManifestUrl: generatedPublicUrl(soundManifestOutputPath),
},
assets,
};
}

Expand All @@ -2405,10 +2427,11 @@ function quakeMultiplayerPlayerModelPathsFromPreparedModels(preparedModelPaths)
}

async function writeQuakeAssetManifestFromGeneratedFiles() {
const [programMetadata, pickupModels, soundManifest] = await Promise.all([
const [programMetadata, pickupModels, soundManifest, effectSprites] = await Promise.all([
readQuakeGeneratedJson(progsOutputPath, "program metadata"),
readQuakeGeneratedJson(pickupOutputPath, "pickup models"),
readQuakeGeneratedJson(soundManifestOutputPath, "sound manifest"),
readOptionalQuakeGeneratedJson(effectSpritesOutputPath, "effect sprites"),
]);
const sourceProgramFacts = await loadQuakeSourceProgramFacts();
const preparedMaps = [];
Expand All @@ -2426,10 +2449,20 @@ async function writeQuakeAssetManifestFromGeneratedFiles() {
pickupModels,
soundManifest,
sourceProgramFacts,
effectSprites,
)));
console.log(`Wrote ${path.relative(projectRoot, manifestOutputPath)} from existing generated assets`);
}

async function readOptionalQuakeGeneratedJson(outputPath, label) {
try {
return await readQuakeGeneratedJson(outputPath, label);
} catch (error) {
if (error?.message?.startsWith?.(`Missing generated ${label} `)) return null;
throw error;
}
}

async function readQuakeGeneratedJson(outputPath, label) {
try {
return JSON.parse(await readFile(outputPath, "utf8"));
Expand Down
234 changes: 234 additions & 0 deletions src/prepare/effectSprites.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { createHash } from "node:crypto";
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";

import sharp from "sharp";

const QUAKE_SPRITE_MAGIC = 0x50534449; // IDSP
const QUAKE_SPRITE_VERSION = 1;
const QUAKE_SPRITE_HEADER_BYTES = 36;
const QUAKE_SPRITE_SINGLE_FRAME = 0;
const QUAKE_SPRITE_TRANSPARENT_INDEX = 255;
const QUAKE_PALETTE_PATH = "gfx/palette.lmp";
const QUAKE_EXPLOSION_SPRITE_PATH = "progs/s_explod.spr";
const QUAKE_EXPLOSION_FRAME_DURATION_MS = 100;

export async function prepareQuakeEffectSprites({
outputDir,
pak,
parsePakDirectory,
publicUrlForOutputPath,
} = {}) {
if (!outputDir) throw new Error("Missing Quake effect sprite output directory.");
if (!pak) throw new Error("Missing Quake PAK bytes for effect sprite prepare.");
if (typeof parsePakDirectory !== "function") throw new Error("Missing Quake PAK directory parser.");
if (typeof publicUrlForOutputPath !== "function") throw new Error("Missing Quake public URL mapper.");

await rm(outputDir, { recursive: true, force: true });
await mkdir(outputDir, { recursive: true });

const entriesByName = new Map(parsePakDirectory(pak).map((entry) => [String(entry.name).toLowerCase(), entry]));
const paletteEntry = entriesByName.get(QUAKE_PALETTE_PATH);
if (!paletteEntry) throw new Error(`Quake PAK is missing ${QUAKE_PALETTE_PATH}.`);
const spriteEntry = entriesByName.get(QUAKE_EXPLOSION_SPRITE_PATH);
if (!spriteEntry) throw new Error(`Quake PAK is missing ${QUAKE_EXPLOSION_SPRITE_PATH}.`);

const palette = quakePakEntryBytes(pak, paletteEntry);
const spriteBytes = quakePakEntryBytes(pak, spriteEntry);
const sprite = parseQuakeSprite(spriteBytes, QUAKE_EXPLOSION_SPRITE_PATH);
const sheet = quakeEffectSpriteSheetRgba(sprite, palette);
const sourceHash = createHash("sha256").update(spriteBytes).digest("hex");
const outputPath = path.join(outputDir, `s_explod-${sourceHash.slice(0, 12)}.png`);
await sharp(sheet.rgba, {
raw: {
width: sheet.width,
height: sheet.height,
channels: 4,
},
}).png().toFile(outputPath);

const asset = quakeEffectSpriteAsset({
frameDurationMs: QUAKE_EXPLOSION_FRAME_DURATION_MS,
kind: "explosion",
sourceHash,
sourcePath: QUAKE_EXPLOSION_SPRITE_PATH,
sprite,
texture: {
alphaMode: "quake-sprite-index-255-alpha",
height: sheet.height,
transparentPixels: sheet.transparentPixels,
url: publicUrlForOutputPath(outputPath),
visiblePixels: sheet.visiblePixels,
width: sheet.width,
},
});

return {
version: 1,
schema: "cssquake-effect-sprites@1",
explosionSprite: QUAKE_EXPLOSION_SPRITE_PATH,
sprites: {
[QUAKE_EXPLOSION_SPRITE_PATH]: asset,
},
};
}

export function parseQuakeSprite(bytes, sourcePath = "sprite.spr") {
const data = bytesView(bytes);
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
if (data.byteLength < QUAKE_SPRITE_HEADER_BYTES) {
throw new Error(`${sourcePath} is too small to be a Quake sprite.`);
}
const magic = view.getUint32(0, true);
if (magic !== QUAKE_SPRITE_MAGIC) throw new Error(`${sourcePath} has invalid sprite magic.`);
const version = view.getInt32(4, true);
if (version !== QUAKE_SPRITE_VERSION) throw new Error(`${sourcePath} has unsupported sprite version ${version}.`);

const header = {
type: view.getInt32(8, true),
boundingRadius: view.getFloat32(12, true),
maxWidth: view.getInt32(16, true),
maxHeight: view.getInt32(20, true),
numFrames: view.getInt32(24, true),
beamLength: view.getFloat32(28, true),
syncType: view.getInt32(32, true),
};
if (header.numFrames <= 0 || header.maxWidth <= 0 || header.maxHeight <= 0) {
throw new Error(`${sourcePath} has invalid sprite dimensions or frame count.`);
}

let offset = QUAKE_SPRITE_HEADER_BYTES;
const frames = [];
for (let index = 0; index < header.numFrames; index++) {
requireBytes(data, offset, 4, `${sourcePath} frame ${index} type`);
const frameType = view.getInt32(offset, true);
offset += 4;
if (frameType !== QUAKE_SPRITE_SINGLE_FRAME) {
throw new Error(`${sourcePath} frame ${index} uses unsupported grouped sprite frame type ${frameType}.`);
}
requireBytes(data, offset, 16, `${sourcePath} frame ${index} header`);
const originX = view.getInt32(offset, true);
const originY = view.getInt32(offset + 4, true);
const width = view.getInt32(offset + 8, true);
const height = view.getInt32(offset + 12, true);
offset += 16;
if (width <= 0 || height <= 0) throw new Error(`${sourcePath} frame ${index} has invalid size ${width}x${height}.`);
const pixelCount = width * height;
requireBytes(data, offset, pixelCount, `${sourcePath} frame ${index} pixels`);
frames.push({
index,
originX,
originY,
width,
height,
pixels: data.slice(offset, offset + pixelCount),
});
offset += pixelCount;
}
if (offset !== data.byteLength) {
throw new Error(`${sourcePath} has ${data.byteLength - offset} trailing sprite bytes.`);
}

return {
sourcePath,
header,
frames,
};
}

export function quakeEffectSpriteSheetRgba(sprite, paletteBytes) {
const palette = bytesView(paletteBytes);
if (palette.byteLength < 256 * 3) throw new Error("Quake palette must contain 256 RGB entries.");
const frames = Array.isArray(sprite?.frames) ? sprite.frames : [];
if (!frames.length) throw new Error("Quake sprite has no frames.");
const frameWidth = Math.max(1, sprite.header?.maxWidth ?? Math.max(...frames.map((frame) => frame.width)));
const frameHeight = Math.max(1, sprite.header?.maxHeight ?? Math.max(...frames.map((frame) => frame.height)));
const width = frameWidth * frames.length;
const height = frameHeight;
const rgba = Buffer.alloc(width * height * 4);
let transparentPixels = 0;
let visiblePixels = 0;

for (const frame of frames) {
const frameX = frame.index * frameWidth;
for (let y = 0; y < frame.height; y++) {
for (let x = 0; x < frame.width; x++) {
const paletteIndex = frame.pixels[y * frame.width + x];
const outOffset = ((y * width) + frameX + x) * 4;
if (paletteIndex === QUAKE_SPRITE_TRANSPARENT_INDEX) {
rgba[outOffset + 3] = 0;
transparentPixels++;
continue;
}
const paletteOffset = paletteIndex * 3;
rgba[outOffset] = palette[paletteOffset];
rgba[outOffset + 1] = palette[paletteOffset + 1];
rgba[outOffset + 2] = palette[paletteOffset + 2];
rgba[outOffset + 3] = 255;
visiblePixels++;
}
}
}

return {
frameHeight,
frameWidth,
height,
rgba,
transparentPixels,
visiblePixels,
width,
};
}

export function quakeEffectSpriteAsset({
frameDurationMs,
kind,
sourceHash,
sourcePath,
sprite,
texture,
}) {
const frames = sprite.frames.map((frame) => ({
index: frame.index,
x: frame.index * sprite.header.maxWidth,
y: 0,
width: frame.width,
height: frame.height,
originX: frame.originX,
originY: frame.originY,
xoff: frame.originX,
yoff: -frame.originY,
}));
return {
id: "s_explod",
kind,
sourcePath,
sourceHash,
header: sprite.header,
frameCount: frames.length,
frameDurationMs,
texture,
frames,
};
}

function quakePakEntryBytes(pak, entry) {
const data = bytesView(pak);
const offset = Number(entry.offset);
const size = Number(entry.size);
if (!Number.isInteger(offset) || !Number.isInteger(size) || offset < 0 || size < 0 || offset + size > data.byteLength) {
throw new Error(`Invalid Quake PAK entry bounds for ${entry.name}.`);
}
return data.slice(offset, offset + size);
}

function bytesView(bytes) {
if (bytes instanceof Uint8Array) return bytes;
if (bytes instanceof ArrayBuffer) return new Uint8Array(bytes);
throw new Error("Expected byte buffer.");
}

function requireBytes(bytes, offset, count, label) {
if (offset + count > bytes.byteLength) throw new Error(`${label} extends past end of file.`);
}
7 changes: 5 additions & 2 deletions src/runtime/app/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,14 +316,17 @@ function normalizeQuakeAssetManifestAssets(value: unknown): QuakeAssetManifest["
if (normalizedModelUrl) weaponModelUrls[normalizedModelPath] = normalizedModelUrl;
}
}
return {
const assets: QuakeAssetManifest["assets"] = {
weaponModelUrl,
weaponModelUrls,
pickupModelsUrl: typeof value.pickupModelsUrl === "string" ? value.pickupModelsUrl : fallback.pickupModelsUrl,
programMetadataUrl: typeof value.programMetadataUrl === "string" ? value.programMetadataUrl : fallback.programMetadataUrl,
effectSpritesUrl: typeof value.effectSpritesUrl === "string" ? value.effectSpritesUrl : fallback.effectSpritesUrl,
soundManifestUrl: typeof value.soundManifestUrl === "string" ? value.soundManifestUrl : fallback.soundManifestUrl,
};
if (typeof value.effectSpritesUrl === "string" && value.effectSpritesUrl.trim()) {
assets.effectSpritesUrl = value.effectSpritesUrl;
}
return assets;
}

function isRecord(value: unknown): value is Record<string, unknown> {
Expand Down
Loading
Loading