Move browser field rendering behind plugin adapter

This commit is contained in:
Meta Vens 2026-07-10 15:26:03 +01:00
commit 16189638d2
10 changed files with 1826 additions and 1685 deletions

View file

@ -116,8 +116,9 @@ advertisements and typed command/receipt documents are the boundary.
surfaces that use them. Hermodr carries decoded MessagePack values without
interpreting field tuples; the browser fields adapter owns that conversion.
- The browser fields adapter owns field component matching, canvas lifecycle,
provider-document polling, tuple conversion, and capability-gap reporting.
The general CultUI lowerer supplies one temporary native drawing port.
provider-document polling, tuple conversion, Canvas/WebGL projection, GPU
resources, asset overlays, and capability-gap reporting. The general CultUI
lowerer dispatches plugin component kinds without field-specific branches.
- The TeX owner sidecar serves its Eve ABI over CultNet RUDP operation
envelopes; its stdio protocol is retained only as a local debug adapter.
- Aetheria owns its provider advertisement, world surface, scenario, asset
@ -162,10 +163,9 @@ advertisements and typed command/receipt documents are the boundary.
is its semantic owner. Sai already owns VN/Ink execution and its live witness.
- Keep nested plugin availability explicit and non-transitive: Sai does not own
Norn or TeX.
- Move the remaining browser Canvas/WebGL drawing body through the adapter's
native drawing port, then delete that port from the general component lowerer.
The browser already resolves required adapter capabilities from provider
advertisements and owns field tuple conversion outside Hermodr.
- Add a second independent `fields.surface` runtime adapter and conformance
capture before graduating the plugin from incubation. Keep projection native
to each runtime; do not move Canvas/WebGL implementation into plugin semantics.
### Conformance

View file

@ -44,7 +44,7 @@ export interface FieldsSurfaceDataState {
}
export interface FieldsRuntimeContext {
applyGeneratedLayout(element: HTMLElement, layout: Record<string, unknown>, style: Record<string, unknown>): void;
drawSurface(canvas: HTMLCanvasElement, props: Record<string, unknown>, state: FieldsSurfaceDataState): void;
providerId?: string;
resolveAssetUrl(uri: string): string;
}
export declare function normalizeFieldsDocument(schemaId: string | undefined, value: unknown): unknown;

View file

@ -1,3 +1,4 @@
import { drawGravityFieldWebGl } from "./fields-webgl-renderer.js";
const FIELD_SCHEMAS = new Set([
"gamecult.fields.splats.v1",
"gamecult.fields.gravity.v1",
@ -25,7 +26,7 @@ function renderFieldsComponent(component, props, layout, style, options, context
context.applyGeneratedLayout(canvas, layout, style);
let drawQueued = false;
const state = {};
const draw = () => context.drawSurface(canvas, props, state);
const draw = () => drawGravitySurface(canvas, props, state, context.resolveAssetUrl);
const scheduleDraw = () => {
if (!canvas.isConnected || drawQueued)
return;
@ -145,6 +146,280 @@ export function normalizeFieldsDocument(schemaId, value) {
return normalizeGravity(tuple);
return normalizeObjects(tuple);
}
function drawGravitySurface(canvas, props, state, resolveAssetUrl) {
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width || canvas.clientWidth || 1));
const height = Math.max(1, Math.floor(rect.height || canvas.clientHeight || 1));
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
const pixelWidth = Math.max(1, Math.floor(width * dpr));
const pixelHeight = Math.max(1, Math.floor(height * dpr));
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
const ctx = canvas.getContext("2d");
if (!ctx)
return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const bounds = fitBoundsToAspect(gravityBounds(props), width, height);
const gravityDocument = objectProps(state?.gravity);
const bodies = gravityBodiesFromDocument(state?.gravity) || parseGravityBodies(firstString(props.bodies, ""));
const objects = gravityObjectsFromDocument(state?.objects) || parseGravityObjects(firstString(props.objects, ""));
if (!drawGravityFieldWebGl(ctx, props, gravityDocument, bodies, bounds, width, height, state?.renderSplats)) {
drawFieldLoweringFailure(ctx, props, width, height, "field.surface2d requires WebGL2 float render targets");
}
drawGravityBodies(ctx, bodies, props, bounds, width, height, resolveAssetUrl);
drawGravityObjects(ctx, objects, props, bounds, width, height, resolveAssetUrl);
}
function drawFieldLoweringFailure(ctx, props, width, height, message) {
ctx.save();
ctx.fillStyle = firstString(props.failureBackground, "rgba(0, 0, 0, 0.72)");
ctx.fillRect(0, 0, width, height);
ctx.font = firstString(props.failureFont, "700 13px Cascadia Mono, Consolas, monospace");
ctx.fillStyle = firstString(props.failureColor, "rgba(255, 184, 79, 0.95)");
ctx.fillText(message, 16, 28);
ctx.restore();
}
function drawGravityBodies(ctx, bodies, props, bounds, width, height, resolveAssetUrl) {
for (const body of bodies) {
const screen = worldToSurface(bounds, body.x, body.y, width, height);
const visualRadius = Math.max(18, Math.min(190, body.radius / Math.max(1, bounds.maxX - bounds.minX) * width * 0.32));
const icon = resolveAssetUrl(body.icon);
const fallbackIconSize = body.kind.toLowerCase().includes("sun")
? Math.max(numberProp(props.sunIconMinPx, 34), visualRadius * numberProp(props.sunIconScale, 0.72))
: Math.max(numberProp(props.bodyIconMinPx, 24), visualRadius * numberProp(props.bodyIconScale, 0.48));
const iconSize = body.iconSize > 0 ? body.iconSize : fallbackIconSize;
if (icon)
drawGravityAsset(ctx, icon, screen.x, screen.y, iconSize, 0.92);
if (body.key) {
drawGravityLabel(ctx, props, body.key.replace(/^local\./, ""), screen.x + visualRadius * 0.44, screen.y - visualRadius * 0.48, firstString(props.bodyLabelColor, "rgba(226, 244, 255, 0.82)"));
}
}
}
const gravityImageCache = new Map();
function drawGravityAsset(ctx, src, x, y, size, alpha) {
let image = gravityImageCache.get(src);
if (!image) {
image = new Image();
image.decoding = "async";
image.onload = () => requestAnimationFrame(() => {
const canvas = ctx.canvas;
canvas.dispatchEvent(new CustomEvent("cultui:asset-loaded"));
});
image.src = src;
gravityImageCache.set(src, image);
}
if (!image.complete || image.naturalWidth === 0)
return;
ctx.save();
ctx.globalAlpha = alpha;
ctx.drawImage(image, x - size / 2, y - size / 2, size, size);
ctx.restore();
}
function drawGravityObjects(ctx, objects, props, bounds, width, height, resolveAssetUrl) {
for (const obj of objects) {
const screen = worldToSurface(bounds, obj.x, obj.y, width, height);
if (screen.x < -40 || screen.x > width + 40 || screen.y < -40 || screen.y > height + 40)
continue;
const size = obj.kind.toLowerCase().includes("station")
? numberProp(props.stationIconSizePx, 34)
: obj.controlled ? numberProp(props.shipIconSizePx, 22) : numberProp(props.remoteShipIconSizePx, 18);
const color = factionColor(props, obj.faction, obj.controlled, obj.visibility);
const icon = resolveAssetUrl(obj.icon);
ctx.save();
ctx.globalAlpha = Math.max(0.25, Math.min(1, obj.visibility));
ctx.translate(screen.x, screen.y);
const angle = Math.atan2(obj.directionY, obj.directionX) + Math.PI / 2;
if (obj.kind.toLowerCase().includes("ship"))
ctx.rotate(angle);
if (icon) {
const image = gravityImageCache.get(icon);
if (!image) {
const loading = new Image();
loading.decoding = "async";
loading.onload = () => ctx.canvas.dispatchEvent(new CustomEvent("cultui:asset-loaded"));
loading.src = icon;
gravityImageCache.set(icon, loading);
}
else if (image.complete && image.naturalWidth > 0) {
ctx.drawImage(image, -size / 2, -size / 2, size, size);
}
}
ctx.restore();
drawGravityLabel(ctx, props, obj.label, screen.x + size * 0.55, screen.y - size * 0.45, color);
}
}
function drawGravityLabel(ctx, props, label, x, y, color) {
if (!label)
return;
ctx.save();
ctx.font = firstString(props.objectLabelFont, "700 12px Ubuntu, system-ui, sans-serif");
ctx.textBaseline = "middle";
ctx.lineWidth = numberProp(props.objectLabelStrokeWidth, 3);
ctx.strokeStyle = firstString(props.objectLabelStroke, "rgba(0, 0, 0, 0.72)");
ctx.fillStyle = color;
ctx.strokeText(label, x, y);
ctx.fillText(label, x, y);
ctx.restore();
}
function factionColor(props, faction, controlled, visibility) {
const alpha = Math.max(0.25, Math.min(1, visibility));
if (controlled)
return colorTemplate(firstString(props.objectControlledColor, "rgba(122, 240, 255, {alpha})"), alpha);
const normalized = faction.toLowerCase();
if (normalized.includes("raider"))
return colorTemplate(firstString(props.objectRaiderColor, "rgba(255, 143, 74, {alpha})"), alpha);
if (normalized.includes("neutral"))
return colorTemplate(firstString(props.objectNeutralColor, "rgba(232, 232, 224, {alpha})"), alpha);
return colorTemplate(firstString(props.objectDefaultColor, "rgba(214, 244, 255, {alpha})"), alpha);
}
function colorTemplate(template, alpha) {
return template.replaceAll("{alpha}", String(alpha));
}
function parseGravityBodies(value) {
return value
.split(";")
.map(entry => entry.trim())
.filter(Boolean)
.map(entry => {
const parts = entry.split("|");
return {
key: parts[0] ?? "",
kind: parts[1] ?? "",
x: Number(parts[2]) || 0,
y: Number(parts[3]) || 0,
radius: Math.max(1, Number(parts[4]) || 1),
depth: Number(parts[5]) || 0,
exponent: Number(parts[6]) || 1,
waveRadius: Number(parts[7]) || 0,
waveDepth: Number(parts[8]) || 0,
waveSpeed: Number(parts[9]) || 0,
icon: parts[10] ?? "",
iconSize: Number(parts[12]) || 0,
tint: parts[11] ?? "",
};
});
}
function parseGravityObjects(value) {
return value
.split(";")
.map(entry => entry.trim())
.filter(Boolean)
.map(entry => {
const parts = entry.split("|");
return {
key: parts[0] ?? "",
kind: parts[1] ?? "",
label: parts[2] ?? "",
x: Number(parts[3]) || 0,
y: Number(parts[4]) || 0,
directionX: Number(parts[5]) || 0,
directionY: Number(parts[6]) || 1,
faction: parts[7] ?? "",
controlled: parts[8] === "1" || parts[8] === "true",
visibility: Number(parts[9]) || 1,
icon: parts[10] ?? "",
};
});
}
function gravityBodiesFromDocument(document) {
const influences = Array.isArray(document?.gravityInfluences) ? document.gravityInfluences : [];
const bodies = Array.isArray(document?.bodies) ? document.bodies : [];
if (influences.length === 0 && bodies.length === 0)
return undefined;
const displayBodiesByKey = new Map();
for (const body of bodies) {
const key = firstString(body.bodyKey, body.name, "");
if (key)
displayBodiesByKey.set(key, body);
}
return (influences.length > 0 ? influences : bodies).map((body) => {
const key = firstString(body.bodyKey, body.name, "body");
const displayBody = displayBodiesByKey.get(key);
const kind = firstString(body.kind, "Body");
return {
key,
kind,
x: numberProp(body.x, 0),
y: numberProp(body.y, 0),
radius: Math.max(1, numberProp(body.radius, 80)),
depth: numberProp(body.gravityDepth, 0),
exponent: numberProp(body.gravityDepthExponent, 1),
waveRadius: numberProp(body.waveRadius, 0),
waveDepth: numberProp(body.waveDepth, 0),
waveSpeed: numberProp(body.waveSpeed, 0),
icon: assetUriFromRef(body.iconAsset) || assetUriFromRef(displayBody?.iconAsset),
iconSize: numberProp(displayBody?.iconSize, numberProp(body.iconSize, 0)),
tint: "",
};
});
}
function gravityObjectsFromDocument(document) {
const objects = Array.isArray(document?.objects) ? document.objects : [];
if (objects.length === 0)
return undefined;
return objects.map((obj) => {
const kind = firstString(obj.kind, "object");
return {
key: firstString(obj.entityKey, String(obj.entityIndex ?? ""), "object"),
kind,
label: firstString(obj.displayName, obj.entityKey, "object"),
x: numberProp(obj.x, 0),
y: numberProp(obj.y, 0),
directionX: numberProp(obj.directionX, 1),
directionY: numberProp(obj.directionY, 0),
faction: firstString(obj.factionKey, ""),
controlled: boolProp(obj.controlled),
visibility: numberProp(obj.visibility, 1),
icon: assetUriFromRef(obj.iconAsset),
};
});
}
function assetUriFromRef(value) {
const asset = objectProps(value);
const uri = firstString(asset.uri, "");
return uri;
}
function gravityBounds(props) {
const radius = Math.max(1, numberProp(props.viewRadius, Math.max(1200, numberProp(props.terrainRadius, 1200))));
return {
minX: numberProp(props.minX, -radius),
minY: numberProp(props.minY, -radius),
maxX: numberProp(props.maxX, radius),
maxY: numberProp(props.maxY, radius),
};
}
function fitBoundsToAspect(bounds, width, height) {
const worldWidth = Math.max(0.0001, bounds.maxX - bounds.minX);
const worldHeight = Math.max(0.0001, bounds.maxY - bounds.minY);
const canvasAspect = Math.max(0.0001, width / Math.max(1, height));
const worldAspect = worldWidth / worldHeight;
const centerX = (bounds.minX + bounds.maxX) * 0.5;
const centerY = (bounds.minY + bounds.maxY) * 0.5;
if (canvasAspect > worldAspect) {
const fittedWidth = worldHeight * canvasAspect;
return {
minX: centerX - fittedWidth * 0.5,
maxX: centerX + fittedWidth * 0.5,
minY: bounds.minY,
maxY: bounds.maxY,
};
}
const fittedHeight = worldWidth / canvasAspect;
return {
minX: bounds.minX,
maxX: bounds.maxX,
minY: centerY - fittedHeight * 0.5,
maxY: centerY + fittedHeight * 0.5,
};
}
function worldToSurface(bounds, x, y, width, height) {
return {
x: (x - bounds.minX) / Math.max(1, bounds.maxX - bounds.minX) * width,
y: (y - bounds.minY) / Math.max(1, bounds.maxY - bounds.minY) * height,
};
}
function normalizeSplats(value) {
const splats = array(value[9]);
return {
@ -266,3 +541,12 @@ function numberValue(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
const objectProps = record;
const numberProp = numberValue;
function boolProp(value) {
if (typeof value === "boolean")
return value;
if (typeof value === "number")
return value !== 0;
return typeof value === "string" && ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}

View file

@ -0,0 +1,21 @@
export type GravitySurfaceBody = {
key: string;
kind: string;
x: number;
y: number;
radius: number;
depth: number;
exponent: number;
waveRadius: number;
waveDepth: number;
waveSpeed: number;
icon: string;
iconSize: number;
tint: string;
};
export declare function drawGravityFieldWebGl(ctx: CanvasRenderingContext2D, props: Record<string, unknown>, gravityDocument: Record<string, unknown>, bodies: GravitySurfaceBody[], bounds: {
minX: number;
minY: number;
maxX: number;
maxY: number;
}, width: number, height: number, renderSplatsDocument?: Record<string, unknown>): boolean;

View file

@ -0,0 +1,500 @@
const gravityWebGlRenderers = new WeakMap();
const GRAVITY_SPLAT_STRIDE_FLOATS = 20;
export function drawGravityFieldWebGl(ctx, props, gravityDocument, bodies, bounds, width, height, renderSplatsDocument) {
const renderer = resolveGravityWebGlRenderer(ctx.canvas);
if (!renderer)
return false;
const gl = renderer.gl;
if (!ensureGravityWebGlTarget(renderer, width, height)) {
gravityWebGlRenderers.set(ctx.canvas, null);
return false;
}
const splatBuffers = buildGravitySplatBuffers(props, gravityDocument, bodies, bounds, renderSplatsDocument);
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.framebuffer);
gl.viewport(0, 0, width, height);
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
if (splatBuffers.gravity.length > 0) {
gl.useProgram(renderer.splatProgram);
gl.uniform4f(gl.getUniformLocation(renderer.splatProgram, "u_viewport"), bounds.minX, bounds.minY, bounds.maxX, bounds.maxY);
gl.uniform1f(gl.getUniformLocation(renderer.splatProgram, "u_time"), numberProp(props.simulationTimeSeconds, 0));
gl.bindBuffer(gl.ARRAY_BUFFER, renderer.splatBuffer);
gl.bufferData(gl.ARRAY_BUFFER, splatBuffers.gravity, gl.DYNAMIC_DRAW);
bindGravitySplatAttributes(gl, renderer.splatProgram);
gl.drawArrays(gl.TRIANGLES, 0, splatBuffers.gravity.length / GRAVITY_SPLAT_STRIDE_FLOATS);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.tintFramebuffer);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
if (splatBuffers.tint.length > 0) {
gl.useProgram(renderer.splatProgram);
gl.uniform4f(gl.getUniformLocation(renderer.splatProgram, "u_viewport"), bounds.minX, bounds.minY, bounds.maxX, bounds.maxY);
gl.uniform1f(gl.getUniformLocation(renderer.splatProgram, "u_time"), numberProp(props.simulationTimeSeconds, 0));
gl.bindBuffer(gl.ARRAY_BUFFER, renderer.splatBuffer);
gl.bufferData(gl.ARRAY_BUFFER, splatBuffers.tint, gl.DYNAMIC_DRAW);
bindGravitySplatAttributes(gl, renderer.splatProgram);
gl.drawArrays(gl.TRIANGLES, 0, splatBuffers.tint.length / GRAVITY_SPLAT_STRIDE_FLOATS);
}
gl.disable(gl.BLEND);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, width, height);
gl.useProgram(renderer.shadeProgram);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, renderer.fieldTexture);
gl.uniform1i(gl.getUniformLocation(renderer.shadeProgram, "u_field"), 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, renderer.tintTexture);
gl.uniform1i(gl.getUniformLocation(renderer.shadeProgram, "u_tint"), 1);
gl.uniform2f(gl.getUniformLocation(renderer.shadeProgram, "u_resolution"), width, height);
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_time"), numberProp(gravityDocument.simulationTimeSeconds, numberProp(props.simulationTimeSeconds, 0)));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineInterval"), numberProp(props.lineInterval, numberProp(props.scalarFieldLineInterval, numberProp(props.depthRange, 228.79605) / 20)));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineOffset"), numberProp(props.lineOffset, numberProp(props.startDepth, 2.310401)));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineWidth"), numberProp(props.lineWidth, 0.3));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineFade"), numberProp(props.lineFade, 5));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_angleWidth"), numberProp(props.angleWidth, 0.05));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_angleFade"), numberProp(props.angleFade, 1.15));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_dangerSteepness"), numberProp(props.dangerSteepness, 1.5));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_scale"), numberProp(props.isolineScale, 2.6924083));
uniform3(gl, renderer.shadeProgram, "u_baseColor", vector3Prop(props.scalarFieldBaseColor, [0.002, 0.006, 0.012]));
uniform3(gl, renderer.shadeProgram, "u_fieldGlowColor", vector3Prop(props.scalarFieldGlowColor, [0.018, 0.050, 0.075]));
uniform3(gl, renderer.shadeProgram, "u_lineLowColor", vector3Prop(props.scalarFieldLowLineColor, [0.0, 0.34, 0.52]));
uniform3(gl, renderer.shadeProgram, "u_lineHighColor", vector3Prop(props.scalarFieldHighLineColor, [1.45, 0.30, 0.05]));
uniform3(gl, renderer.shadeProgram, "u_angleLowColor", vector3Prop(props.scalarFieldLowAngleColor, [0.06, 0.16, 0.24]));
uniform3(gl, renderer.shadeProgram, "u_angleHighColor", vector3Prop(props.scalarFieldHighAngleColor, [1.10, 0.24, 0.04]));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_tintScale"), numberProp(props.vectorFieldTintScale, 0.45));
gl.bindBuffer(gl.ARRAY_BUFFER, renderer.quadBuffer);
const position = gl.getAttribLocation(renderer.shadeProgram, "a_position");
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
ctx.drawImage(renderer.canvas, 0, 0, width, height);
return true;
}
function buildGravitySplatsFromDocument(document) {
const splats = objectProps(document?.splats);
const layers = Array.isArray(document?.layers) ? document.layers : [];
const gravityLayerIndex = layers.findIndex(layer => stringProp(layer?.layerKey, "") === "gravity.height");
if (gravityLayerIndex < 0)
return undefined;
const count = Math.max(0, positiveInt(splats.count, 0));
const centerX = numberArray(splats.centerX);
const centerY = numberArray(splats.centerY);
const halfExtentX = numberArray(splats.halfExtentX);
const halfExtentY = numberArray(splats.halfExtentY);
const rotationCos = numberArray(splats.rotationCos);
const rotationSin = numberArray(splats.rotationSin);
const channel = numberArray(splats.channel);
const falloff = numberArray(splats.falloff);
const valueR = numberArray(splats.valueR);
const valueG = numberArray(splats.valueG);
const sourceKind = numberArray(splats.sourceKind);
const frequencyX = numberArray(splats.frequencyX);
const animationSpeed = numberArray(splats.animationSpeed);
const sourceFlags = numberArray(splats.sourceFlags);
const layerIndex = numberArray(splats.layerIndex);
const rows = [];
const corners = [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1];
for (let index = 0; index < count; index += 1) {
if ((layerIndex[index] ?? -1) !== gravityLayerIndex)
continue;
for (let corner = 0; corner < corners.length; corner += 2) {
rows.push(corners[corner], corners[corner + 1], centerX[index] ?? 0, centerY[index] ?? 0, Math.max(0.0001, halfExtentX[index] ?? 1), Math.max(0.0001, halfExtentY[index] ?? 1), rotationCos[index] ?? 1, rotationSin[index] ?? 0, channel[index] ?? 1, falloff[index] ?? 0, valueR[index] ?? 0, 0, sourceKind[index] ?? 0, frequencyX[index] ?? 1, animationSpeed[index] ?? 0, sourceFlags[index] ?? 0);
}
}
return rows.length > 0 ? new Float32Array(rows) : undefined;
}
function buildGravitySplatBuffers(props, gravityDocument, bodies, bounds, renderSplatsDocument) {
const gravityRows = [];
const tintRows = [];
appendUnityGravitySplats(gravityRows, props, gravityDocument, bodies, bounds);
appendTintSplatsFromDocument(tintRows, props, renderSplatsDocument);
return {
gravity: new Float32Array(gravityRows),
tint: new Float32Array(tintRows),
};
}
function appendUnityGravitySplats(rows, props, gravityDocument, bodies, bounds) {
const terrainRadius = numberProp(gravityDocument.terrainRadius, numberProp(props.terrainRadius, 1200));
const terrainDepth = numberProp(gravityDocument.terrainDepth, numberProp(props.terrainDepth, -8));
if (terrainDepth !== 0) {
appendGravitySplat(rows, 0, 0, Math.max(1, terrainRadius * 2), Math.max(1, terrainRadius * 2), -terrainDepth, 0, 0, 1, Math.max(0.0001, numberProp(gravityDocument.terrainDepthExponent, numberProp(props.terrainDepthExponent, 1.2))), 0, 1, 0, 0);
}
for (const body of bodies) {
appendGravitySplat(rows, body.x, body.y, Math.max(1, body.radius), Math.max(1, body.radius), -body.depth, 0, 0, 1, Math.max(0.0001, body.exponent), 0, 1, 0, 0);
if (body.waveRadius > 0 && body.waveDepth !== 0) {
appendGravitySplat(rows, body.x, body.y, body.waveRadius, body.waveRadius, -body.waveDepth, 0, 0, 1, 8.0, 4, numberProp(gravityDocument.terrainWaveFrequency, numberProp(props.terrainWaveFrequency, 0.6)), body.waveSpeed, 0);
}
}
void bounds;
}
function appendTintSplatsFromDocument(rows, props, document) {
const splats = objectProps(document?.splats);
const layers = Array.isArray(document?.layers) ? document.layers : [];
const tintLayerIndices = new Set();
const selectedLayer = firstString(props.vectorFieldLayer, props.tintFieldLayer, "fog.tint");
layers.forEach((layer, index) => {
const layerKey = stringProp(layer?.layerKey, "");
const channel = numberProp(layer?.channel, -1);
if (channel === 4 && layerKey === selectedLayer)
tintLayerIndices.add(index);
});
const count = Math.max(0, positiveInt(splats.count, 0));
if (count <= 0 || tintLayerIndices.size === 0)
return;
const centerX = numberArray(splats.centerX);
const centerY = numberArray(splats.centerY);
const halfExtentX = numberArray(splats.halfExtentX);
const halfExtentY = numberArray(splats.halfExtentY);
const rotationCos = numberArray(splats.rotationCos);
const rotationSin = numberArray(splats.rotationSin);
const channel = numberArray(splats.channel);
const falloff = numberArray(splats.falloff);
const valueR = numberArray(splats.valueR);
const valueG = numberArray(splats.valueG);
const valueB = numberArray(splats.valueB);
const valueA = numberArray(splats.valueA);
const sourceKind = numberArray(splats.sourceKind);
const frequencyX = numberArray(splats.frequencyX);
const animationSpeed = numberArray(splats.animationSpeed);
const sourceFlags = numberArray(splats.sourceFlags);
const layerIndex = numberArray(splats.layerIndex);
for (let index = 0; index < count; index += 1) {
if (!tintLayerIndices.has(layerIndex[index] ?? -1) || (channel[index] ?? -1) !== 4)
continue;
const power = (falloff[index] ?? 0) === 0 ? 0.0001 : 1.25;
appendGravitySplat(rows, centerX[index] ?? 0, centerY[index] ?? 0, Math.max(0.0001, halfExtentX[index] ?? 1), Math.max(0.0001, halfExtentY[index] ?? 1), valueR[index] ?? 0, valueG[index] ?? 0, valueB[index] ?? 0, valueA[index] ?? 1, power, sourceKind[index] ?? 0, frequencyX[index] ?? 1, animationSpeed[index] ?? 0, sourceFlags[index] ?? 0, rotationCos[index] ?? 1, rotationSin[index] ?? 0);
}
}
function resolveGravityWebGlRenderer(canvas) {
if (gravityWebGlRenderers.has(canvas))
return gravityWebGlRenderers.get(canvas) || null;
const output = document.createElement("canvas");
const gl = output.getContext("webgl2", { alpha: false, antialias: false, premultipliedAlpha: false });
if (!gl) {
gravityWebGlRenderers.set(canvas, null);
return null;
}
const colorBufferFloat = gl.getExtension("EXT_color_buffer_float");
if (!colorBufferFloat) {
gravityWebGlRenderers.set(canvas, null);
return null;
}
const splatProgram = createGravityProgram(gl, GRAVITY_SPLAT_VERTEX_SHADER, GRAVITY_SPLAT_FRAGMENT_SHADER);
const shadeProgram = createGravityProgram(gl, GRAVITY_SHADE_VERTEX_SHADER, GRAVITY_SHADE_FRAGMENT_SHADER);
const splatBuffer = gl.createBuffer();
const quadBuffer = gl.createBuffer();
if (!splatProgram || !shadeProgram || !splatBuffer || !quadBuffer) {
gravityWebGlRenderers.set(canvas, null);
return null;
}
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, 1, 1,
-1, -1, 1, 1, -1, 1,
]), gl.STATIC_DRAW);
const renderer = {
canvas: output,
gl,
fieldTexture: null,
tintTexture: null,
framebuffer: null,
tintFramebuffer: null,
splatProgram,
shadeProgram,
splatBuffer,
quadBuffer,
width: 0,
height: 0,
};
gravityWebGlRenderers.set(canvas, renderer);
return renderer;
}
function ensureGravityWebGlTarget(renderer, width, height) {
if (renderer.width === width && renderer.height === height && renderer.fieldTexture && renderer.tintTexture && renderer.framebuffer && renderer.tintFramebuffer)
return true;
const gl = renderer.gl;
renderer.width = width;
renderer.height = height;
renderer.canvas.width = width;
renderer.canvas.height = height;
if (renderer.fieldTexture)
gl.deleteTexture(renderer.fieldTexture);
if (renderer.tintTexture)
gl.deleteTexture(renderer.tintTexture);
if (renderer.framebuffer)
gl.deleteFramebuffer(renderer.framebuffer);
if (renderer.tintFramebuffer)
gl.deleteFramebuffer(renderer.tintFramebuffer);
renderer.fieldTexture = gl.createTexture();
renderer.tintTexture = gl.createTexture();
renderer.framebuffer = gl.createFramebuffer();
renderer.tintFramebuffer = gl.createFramebuffer();
if (!renderer.fieldTexture || !renderer.tintTexture || !renderer.framebuffer || !renderer.tintFramebuffer)
return false;
configureGravityTexture(gl, renderer.fieldTexture, width, height);
configureGravityTexture(gl, renderer.tintTexture, width, height);
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, renderer.fieldTexture, 0);
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE)
return false;
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.tintFramebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, renderer.tintTexture, 0);
return gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
}
function configureGravityTexture(gl, texture, width, height) {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.HALF_FLOAT, null);
}
function buildGravitySplats(props, gravityDocument, bodies, bounds) {
const rows = [];
void bounds;
const terrainRadius = numberProp(gravityDocument.terrainRadius, numberProp(props.terrainRadius, 1200));
const terrainDepth = numberProp(gravityDocument.terrainDepth, numberProp(props.terrainDepth, -8));
if (terrainDepth !== 0) {
const terrainHalfExtent = Math.max(1, terrainRadius * 2);
appendGravitySplat(rows, 0, 0, terrainHalfExtent, terrainHalfExtent, -terrainDepth, 0, 0, 1, Math.max(0.0001, numberProp(gravityDocument.terrainDepthExponent, numberProp(props.terrainDepthExponent, 1.2))), 0, 1, 0, 0);
}
for (const body of bodies) {
appendGravitySplat(rows, body.x, body.y, Math.max(1, body.radius), Math.max(1, body.radius), -body.depth, 0, 0, 1, Math.max(0.0001, body.exponent), 0, 1, 0, 0);
if (body.waveRadius > 0 && body.waveDepth !== 0) {
appendGravitySplat(rows, body.x, body.y, body.waveRadius, body.waveRadius, -body.waveDepth, 0, 0, 1, 8.0, 4, numberProp(gravityDocument.terrainWaveFrequency, numberProp(props.terrainWaveFrequency, 0.6)), body.waveSpeed, 0);
}
}
return new Float32Array(rows);
}
function appendGravitySplat(rows, centerX, centerY, halfX, halfY, valueR, valueG, valueB, valueA, power, sourceKind, frequencyX, animationSpeed, sourceFlags, rotationCos = 1, rotationSin = 0) {
const corners = [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1];
for (let i = 0; i < corners.length; i += 2) {
rows.push(corners[i], corners[i + 1], centerX, centerY, halfX, halfY, rotationCos, rotationSin, power, 0, 0, 0, sourceKind, frequencyX, animationSpeed, sourceFlags, valueR, valueG, valueB, valueA);
}
}
function bindGravitySplatAttributes(gl, program) {
bindGravitySplatAttribute(gl, program, "a_corner", 2, 0);
bindGravitySplatAttribute(gl, program, "a_center", 2, 2);
bindGravitySplatAttribute(gl, program, "a_half", 2, 4);
bindGravitySplatAttribute(gl, program, "a_rot", 2, 6);
bindGravitySplatAttribute(gl, program, "a_meta", 4, 8);
bindGravitySplatAttribute(gl, program, "a_source", 4, 12);
bindGravitySplatAttribute(gl, program, "a_value", 4, 16);
}
function bindGravitySplatAttribute(gl, program, name, size, offsetFloats) {
const location = gl.getAttribLocation(program, name);
if (location < 0)
return;
gl.enableVertexAttribArray(location);
gl.vertexAttribPointer(location, size, gl.FLOAT, false, GRAVITY_SPLAT_STRIDE_FLOATS * 4, offsetFloats * 4);
}
function createGravityProgram(gl, vertexSource, fragmentSource) {
const vertex = compileGravityShader(gl, gl.VERTEX_SHADER, vertexSource);
const fragment = compileGravityShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
if (!vertex || !fragment)
return null;
const program = gl.createProgram();
if (!program)
return null;
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS))
return null;
return program;
}
function compileGravityShader(gl, type, source) {
const shader = gl.createShader(type);
if (!shader)
return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
return null;
return shader;
}
function numberProp(value, fallback) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function vector3Prop(value, fallback) {
if (Array.isArray(value)) {
const values = value.map(entry => Number(entry));
if (values.length >= 3 && values.slice(0, 3).every(Number.isFinite))
return [values[0], values[1], values[2]];
}
if (typeof value === "string") {
const values = value.split(/[,\s]+/).map(entry => Number(entry)).filter(Number.isFinite);
if (values.length >= 3)
return [values[0], values[1], values[2]];
}
return fallback;
}
function uniform3(gl, program, name, value) {
gl.uniform3f(gl.getUniformLocation(program, name), value[0], value[1], value[2]);
}
function numberArray(value) {
if (!Array.isArray(value))
return [];
return value.map(entry => numberProp(entry, 0));
}
function objectProps(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function stringProp(value, fallback) {
return value === null || value === undefined ? fallback : String(value);
}
function firstString(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim())
return value;
if (typeof value === "number" && Number.isFinite(value))
return String(value);
}
return "";
}
function positiveInt(value, fallback) {
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : fallback;
}
const GRAVITY_SPLAT_VERTEX_SHADER = `#version 300 es
in vec2 a_corner;
in vec2 a_center;
in vec2 a_half;
in vec2 a_rot;
in vec4 a_meta;
in vec4 a_source;
in vec4 a_value;
uniform vec4 u_viewport;
out vec2 v_local;
out vec4 v_meta;
out vec4 v_source;
out vec4 v_value;
void main() {
vec2 scaled = a_corner * a_half;
vec2 world = a_center + vec2(
scaled.x * a_rot.x - scaled.y * a_rot.y,
scaled.x * a_rot.y + scaled.y * a_rot.x
);
vec2 clip = vec2(
((world.x - u_viewport.x) / max(0.0001, u_viewport.z - u_viewport.x)) * 2.0 - 1.0,
((world.y - u_viewport.y) / max(0.0001, u_viewport.w - u_viewport.y)) * 2.0 - 1.0
);
gl_Position = vec4(clip, 0.0, 1.0);
v_local = a_corner;
v_meta = a_meta;
v_source = a_source;
v_value = a_value;
}`;
const GRAVITY_SPLAT_FRAGMENT_SHADER = `#version 300 es
precision highp float;
in vec2 v_local;
in vec4 v_meta;
in vec4 v_source;
in vec4 v_value;
uniform float u_time;
out vec4 outColor;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float valueNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) * 2.0 - 1.0;
}
float powerPulse(float x, float power) {
x = clamp(abs(x), 0.0, 1.0);
return pow((x + 1.0) * (1.0 - x), max(0.0001, power));
}
void main() {
float d = length(v_local);
if (d > 1.0) discard;
float alpha = powerPulse(d, v_meta.x);
if (alpha <= 0.0001) discard;
float source = 1.0;
if (v_source.x > 0.5 && v_source.x < 2.5) {
float timeOffset = v_source.x > 1.5 ? u_time * v_source.z : 0.0;
source = valueNoise(v_local * v_source.y + timeOffset);
if (v_source.w != 0.0) source = abs(source);
} else if (v_source.x > 3.5) {
source = cos(pow(d * 2.0, 1.25) * v_source.y + u_time * v_source.z);
}
outColor = v_value * alpha * source;
}`;
const GRAVITY_SHADE_VERTEX_SHADER = `#version 300 es
in vec2 a_position;
out vec2 v_uv;
void main() {
v_uv = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}`;
const GRAVITY_SHADE_FRAGMENT_SHADER = `#version 300 es
precision highp float;
uniform sampler2D u_field;
uniform sampler2D u_tint;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_lineInterval;
uniform float u_lineOffset;
uniform float u_lineWidth;
uniform float u_lineFade;
uniform float u_angleWidth;
uniform float u_angleFade;
uniform float u_dangerSteepness;
uniform float u_scale;
uniform vec3 u_baseColor;
uniform vec3 u_fieldGlowColor;
uniform vec3 u_lineLowColor;
uniform vec3 u_lineHighColor;
uniform vec3 u_angleLowColor;
uniform vec3 u_angleHighColor;
uniform float u_tintScale;
in vec2 v_uv;
out vec4 outColor;
vec2 calcGrad(vec2 uv, float me) {
vec2 texel = 1.0 / max(vec2(1.0), u_resolution);
float n = -texture(u_field, vec2(uv.x, uv.y + texel.y)).r;
float e = -texture(u_field, vec2(uv.x + texel.x, uv.y)).r;
return vec2(e - me, n - me);
}
void main() {
float height = -texture(u_field, v_uv).r;
vec4 tint = texture(u_tint, v_uv);
vec2 plan = calcGrad(v_uv, height);
float slope = max(length(plan), 0.00001);
vec3 color = u_baseColor;
float fieldGlow = smoothstep(0.0, u_lineInterval * 7.0, abs(height));
color += u_fieldGlowColor * fieldGlow;
color += tint.rgb * clamp(tint.a, 0.0, 1.0) * u_tintScale;
float dangerBlend = smoothstep(0.0, u_dangerSteepness, pow(slope / max(0.0001, u_scale), 2.0));
vec3 baseLine = mix(u_lineLowColor, u_lineHighColor, dangerBlend);
vec3 angleLine = mix(u_angleLowColor, u_angleHighColor, dangerBlend) * clamp(slope / max(0.0001, u_scale), 0.0, 1.0);
vec3 lines = vec3(0.0);
float interval = max(0.0001, abs(u_lineInterval));
float nearestLine = abs(fract((height + u_lineOffset) / interval) - 0.5) * interval;
float lineDistance = nearestLine / slope;
float line = 1.0 - smoothstep(u_lineWidth, u_lineWidth * u_lineFade, lineDistance);
lines += line * baseLine;
float angle = atan(plan.y, plan.x) / 3.1415926536 + 1.0;
float nearestAngle = abs(fract(angle * 6.0) - 0.5) / 6.0;
float angleMask = smoothstep(interval * 0.25, interval * 1.5, abs(height));
float angleLineAmount = 1.0 - smoothstep(u_angleWidth, u_angleWidth * u_angleFade, nearestAngle);
lines += angleLineAmount * angleMask * angleLine;
color += clamp(lines, 0.0, 1.7);
outColor = vec4(color, 1.0);
}`;

View file

@ -222,16 +222,13 @@ export function renderEveComponent(node, options = currentOptions) {
if (kind === "inventory.item") {
return renderInventoryItem(node, props, layout, style);
}
if (kind === "field.surface2d" || kind === "gravity.surface") {
const adapter = (options.pluginAdapters || defaultBrowserPluginAdapters)
.find(candidate => candidate.componentKinds.includes(kind));
if (!adapter?.renderComponent) {
throw new Error(`No browser plugin adapter can lower ${kind}.`);
}
const adapter = (options.pluginAdapters || defaultBrowserPluginAdapters)
.find(candidate => candidate.componentKinds.includes(kind));
if (adapter?.renderComponent) {
return adapter.renderComponent(node, props, layout, style, options, {
applyGeneratedLayout,
drawSurface: drawGravitySurface,
providerId: currentSurfaceDocument?.providerId || options.provider?.providerId,
resolveAssetUrl,
});
}
if (kind === "world.scene3d") {
@ -765,736 +762,6 @@ function worldEntityGlyph(kind) {
function cssIdentifier(value) {
return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-");
}
function drawGravitySurface(canvas, props, state) {
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width || canvas.clientWidth || 1));
const height = Math.max(1, Math.floor(rect.height || canvas.clientHeight || 1));
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
const pixelWidth = Math.max(1, Math.floor(width * dpr));
const pixelHeight = Math.max(1, Math.floor(height * dpr));
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
const ctx = canvas.getContext("2d");
if (!ctx)
return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const bounds = fitBoundsToAspect(gravityBounds(props), width, height);
const gravityDocument = objectProps(state?.gravity);
const bodies = gravityBodiesFromDocument(state?.gravity) || parseGravityBodies(firstString(props.bodies, ""));
const objects = gravityObjectsFromDocument(state?.objects) || parseGravityObjects(firstString(props.objects, ""));
if (!drawGravityFieldWebGl(ctx, props, gravityDocument, bodies, bounds, width, height, state?.renderSplats)) {
drawFieldLoweringFailure(ctx, props, width, height, "field.surface2d requires WebGL2 float render targets");
}
drawGravityBodies(ctx, bodies, props, bounds, width, height);
drawGravityObjects(ctx, objects, props, bounds, width, height);
}
function drawFieldLoweringFailure(ctx, props, width, height, message) {
ctx.save();
ctx.fillStyle = firstString(props.failureBackground, "rgba(0, 0, 0, 0.72)");
ctx.fillRect(0, 0, width, height);
ctx.font = firstString(props.failureFont, "700 13px Cascadia Mono, Consolas, monospace");
ctx.fillStyle = firstString(props.failureColor, "rgba(255, 184, 79, 0.95)");
ctx.fillText(message, 16, 28);
ctx.restore();
}
const gravityWebGlRenderers = new WeakMap();
const GRAVITY_SPLAT_STRIDE_FLOATS = 20;
function drawGravityFieldWebGl(ctx, props, gravityDocument, bodies, bounds, width, height, renderSplatsDocument) {
const renderer = resolveGravityWebGlRenderer(ctx.canvas);
if (!renderer)
return false;
const gl = renderer.gl;
if (!ensureGravityWebGlTarget(renderer, width, height)) {
gravityWebGlRenderers.set(ctx.canvas, null);
return false;
}
const splatBuffers = buildGravitySplatBuffers(props, gravityDocument, bodies, bounds, renderSplatsDocument);
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.framebuffer);
gl.viewport(0, 0, width, height);
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
if (splatBuffers.gravity.length > 0) {
gl.useProgram(renderer.splatProgram);
gl.uniform4f(gl.getUniformLocation(renderer.splatProgram, "u_viewport"), bounds.minX, bounds.minY, bounds.maxX, bounds.maxY);
gl.uniform1f(gl.getUniformLocation(renderer.splatProgram, "u_time"), numberProp(props.simulationTimeSeconds, 0));
gl.bindBuffer(gl.ARRAY_BUFFER, renderer.splatBuffer);
gl.bufferData(gl.ARRAY_BUFFER, splatBuffers.gravity, gl.DYNAMIC_DRAW);
bindGravitySplatAttributes(gl, renderer.splatProgram);
gl.drawArrays(gl.TRIANGLES, 0, splatBuffers.gravity.length / GRAVITY_SPLAT_STRIDE_FLOATS);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.tintFramebuffer);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
if (splatBuffers.tint.length > 0) {
gl.useProgram(renderer.splatProgram);
gl.uniform4f(gl.getUniformLocation(renderer.splatProgram, "u_viewport"), bounds.minX, bounds.minY, bounds.maxX, bounds.maxY);
gl.uniform1f(gl.getUniformLocation(renderer.splatProgram, "u_time"), numberProp(props.simulationTimeSeconds, 0));
gl.bindBuffer(gl.ARRAY_BUFFER, renderer.splatBuffer);
gl.bufferData(gl.ARRAY_BUFFER, splatBuffers.tint, gl.DYNAMIC_DRAW);
bindGravitySplatAttributes(gl, renderer.splatProgram);
gl.drawArrays(gl.TRIANGLES, 0, splatBuffers.tint.length / GRAVITY_SPLAT_STRIDE_FLOATS);
}
gl.disable(gl.BLEND);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, width, height);
gl.useProgram(renderer.shadeProgram);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, renderer.fieldTexture);
gl.uniform1i(gl.getUniformLocation(renderer.shadeProgram, "u_field"), 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, renderer.tintTexture);
gl.uniform1i(gl.getUniformLocation(renderer.shadeProgram, "u_tint"), 1);
gl.uniform2f(gl.getUniformLocation(renderer.shadeProgram, "u_resolution"), width, height);
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_time"), numberProp(gravityDocument.simulationTimeSeconds, numberProp(props.simulationTimeSeconds, 0)));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineInterval"), numberProp(props.lineInterval, numberProp(props.scalarFieldLineInterval, numberProp(props.depthRange, 228.79605) / 20)));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineOffset"), numberProp(props.lineOffset, numberProp(props.startDepth, 2.310401)));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineWidth"), numberProp(props.lineWidth, 0.3));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineFade"), numberProp(props.lineFade, 5));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_angleWidth"), numberProp(props.angleWidth, 0.05));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_angleFade"), numberProp(props.angleFade, 1.15));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_dangerSteepness"), numberProp(props.dangerSteepness, 1.5));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_scale"), numberProp(props.isolineScale, 2.6924083));
uniform3(gl, renderer.shadeProgram, "u_baseColor", vector3Prop(props.scalarFieldBaseColor, [0.002, 0.006, 0.012]));
uniform3(gl, renderer.shadeProgram, "u_fieldGlowColor", vector3Prop(props.scalarFieldGlowColor, [0.018, 0.050, 0.075]));
uniform3(gl, renderer.shadeProgram, "u_lineLowColor", vector3Prop(props.scalarFieldLowLineColor, [0.0, 0.34, 0.52]));
uniform3(gl, renderer.shadeProgram, "u_lineHighColor", vector3Prop(props.scalarFieldHighLineColor, [1.45, 0.30, 0.05]));
uniform3(gl, renderer.shadeProgram, "u_angleLowColor", vector3Prop(props.scalarFieldLowAngleColor, [0.06, 0.16, 0.24]));
uniform3(gl, renderer.shadeProgram, "u_angleHighColor", vector3Prop(props.scalarFieldHighAngleColor, [1.10, 0.24, 0.04]));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_tintScale"), numberProp(props.vectorFieldTintScale, 0.45));
gl.bindBuffer(gl.ARRAY_BUFFER, renderer.quadBuffer);
const position = gl.getAttribLocation(renderer.shadeProgram, "a_position");
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
ctx.drawImage(renderer.canvas, 0, 0, width, height);
return true;
}
function buildGravitySplatsFromDocument(document) {
const splats = objectProps(document?.splats);
const layers = Array.isArray(document?.layers) ? document.layers : [];
const gravityLayerIndex = layers.findIndex(layer => stringProp(layer?.layerKey, "") === "gravity.height");
if (gravityLayerIndex < 0)
return undefined;
const count = Math.max(0, positiveInt(splats.count, 0));
const centerX = numberArray(splats.centerX);
const centerY = numberArray(splats.centerY);
const halfExtentX = numberArray(splats.halfExtentX);
const halfExtentY = numberArray(splats.halfExtentY);
const rotationCos = numberArray(splats.rotationCos);
const rotationSin = numberArray(splats.rotationSin);
const channel = numberArray(splats.channel);
const falloff = numberArray(splats.falloff);
const valueR = numberArray(splats.valueR);
const valueG = numberArray(splats.valueG);
const sourceKind = numberArray(splats.sourceKind);
const frequencyX = numberArray(splats.frequencyX);
const animationSpeed = numberArray(splats.animationSpeed);
const sourceFlags = numberArray(splats.sourceFlags);
const layerIndex = numberArray(splats.layerIndex);
const rows = [];
const corners = [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1];
for (let index = 0; index < count; index += 1) {
if ((layerIndex[index] ?? -1) !== gravityLayerIndex)
continue;
for (let corner = 0; corner < corners.length; corner += 2) {
rows.push(corners[corner], corners[corner + 1], centerX[index] ?? 0, centerY[index] ?? 0, Math.max(0.0001, halfExtentX[index] ?? 1), Math.max(0.0001, halfExtentY[index] ?? 1), rotationCos[index] ?? 1, rotationSin[index] ?? 0, channel[index] ?? 1, falloff[index] ?? 0, valueR[index] ?? 0, 0, sourceKind[index] ?? 0, frequencyX[index] ?? 1, animationSpeed[index] ?? 0, sourceFlags[index] ?? 0);
}
}
return rows.length > 0 ? new Float32Array(rows) : undefined;
}
function buildGravitySplatBuffers(props, gravityDocument, bodies, bounds, renderSplatsDocument) {
const gravityRows = [];
const tintRows = [];
appendUnityGravitySplats(gravityRows, props, gravityDocument, bodies, bounds);
appendTintSplatsFromDocument(tintRows, props, renderSplatsDocument);
return {
gravity: new Float32Array(gravityRows),
tint: new Float32Array(tintRows),
};
}
function appendUnityGravitySplats(rows, props, gravityDocument, bodies, bounds) {
const terrainRadius = numberProp(gravityDocument.terrainRadius, numberProp(props.terrainRadius, 1200));
const terrainDepth = numberProp(gravityDocument.terrainDepth, numberProp(props.terrainDepth, -8));
if (terrainDepth !== 0) {
appendGravitySplat(rows, 0, 0, Math.max(1, terrainRadius * 2), Math.max(1, terrainRadius * 2), -terrainDepth, 0, 0, 1, Math.max(0.0001, numberProp(gravityDocument.terrainDepthExponent, numberProp(props.terrainDepthExponent, 1.2))), 0, 1, 0, 0);
}
for (const body of bodies) {
appendGravitySplat(rows, body.x, body.y, Math.max(1, body.radius), Math.max(1, body.radius), -body.depth, 0, 0, 1, Math.max(0.0001, body.exponent), 0, 1, 0, 0);
if (body.waveRadius > 0 && body.waveDepth !== 0) {
appendGravitySplat(rows, body.x, body.y, body.waveRadius, body.waveRadius, -body.waveDepth, 0, 0, 1, 8.0, 4, numberProp(gravityDocument.terrainWaveFrequency, numberProp(props.terrainWaveFrequency, 0.6)), body.waveSpeed, 0);
}
}
void bounds;
}
function appendTintSplatsFromDocument(rows, props, document) {
const splats = objectProps(document?.splats);
const layers = Array.isArray(document?.layers) ? document.layers : [];
const tintLayerIndices = new Set();
const selectedLayer = firstString(props.vectorFieldLayer, props.tintFieldLayer, "fog.tint");
layers.forEach((layer, index) => {
const layerKey = stringProp(layer?.layerKey, "");
const channel = numberProp(layer?.channel, -1);
if (channel === 4 && layerKey === selectedLayer)
tintLayerIndices.add(index);
});
const count = Math.max(0, positiveInt(splats.count, 0));
if (count <= 0 || tintLayerIndices.size === 0)
return;
const centerX = numberArray(splats.centerX);
const centerY = numberArray(splats.centerY);
const halfExtentX = numberArray(splats.halfExtentX);
const halfExtentY = numberArray(splats.halfExtentY);
const rotationCos = numberArray(splats.rotationCos);
const rotationSin = numberArray(splats.rotationSin);
const channel = numberArray(splats.channel);
const falloff = numberArray(splats.falloff);
const valueR = numberArray(splats.valueR);
const valueG = numberArray(splats.valueG);
const valueB = numberArray(splats.valueB);
const valueA = numberArray(splats.valueA);
const sourceKind = numberArray(splats.sourceKind);
const frequencyX = numberArray(splats.frequencyX);
const animationSpeed = numberArray(splats.animationSpeed);
const sourceFlags = numberArray(splats.sourceFlags);
const layerIndex = numberArray(splats.layerIndex);
for (let index = 0; index < count; index += 1) {
if (!tintLayerIndices.has(layerIndex[index] ?? -1) || (channel[index] ?? -1) !== 4)
continue;
const power = (falloff[index] ?? 0) === 0 ? 0.0001 : 1.25;
appendGravitySplat(rows, centerX[index] ?? 0, centerY[index] ?? 0, Math.max(0.0001, halfExtentX[index] ?? 1), Math.max(0.0001, halfExtentY[index] ?? 1), valueR[index] ?? 0, valueG[index] ?? 0, valueB[index] ?? 0, valueA[index] ?? 1, power, sourceKind[index] ?? 0, frequencyX[index] ?? 1, animationSpeed[index] ?? 0, sourceFlags[index] ?? 0, rotationCos[index] ?? 1, rotationSin[index] ?? 0);
}
}
function resolveGravityWebGlRenderer(canvas) {
if (gravityWebGlRenderers.has(canvas))
return gravityWebGlRenderers.get(canvas) || null;
const output = document.createElement("canvas");
const gl = output.getContext("webgl2", { alpha: false, antialias: false, premultipliedAlpha: false });
if (!gl) {
gravityWebGlRenderers.set(canvas, null);
return null;
}
const colorBufferFloat = gl.getExtension("EXT_color_buffer_float");
if (!colorBufferFloat) {
gravityWebGlRenderers.set(canvas, null);
return null;
}
const splatProgram = createGravityProgram(gl, GRAVITY_SPLAT_VERTEX_SHADER, GRAVITY_SPLAT_FRAGMENT_SHADER);
const shadeProgram = createGravityProgram(gl, GRAVITY_SHADE_VERTEX_SHADER, GRAVITY_SHADE_FRAGMENT_SHADER);
const splatBuffer = gl.createBuffer();
const quadBuffer = gl.createBuffer();
if (!splatProgram || !shadeProgram || !splatBuffer || !quadBuffer) {
gravityWebGlRenderers.set(canvas, null);
return null;
}
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, 1, 1,
-1, -1, 1, 1, -1, 1,
]), gl.STATIC_DRAW);
const renderer = {
canvas: output,
gl,
fieldTexture: null,
tintTexture: null,
framebuffer: null,
tintFramebuffer: null,
splatProgram,
shadeProgram,
splatBuffer,
quadBuffer,
width: 0,
height: 0,
};
gravityWebGlRenderers.set(canvas, renderer);
return renderer;
}
function ensureGravityWebGlTarget(renderer, width, height) {
if (renderer.width === width && renderer.height === height && renderer.fieldTexture && renderer.tintTexture && renderer.framebuffer && renderer.tintFramebuffer)
return true;
const gl = renderer.gl;
renderer.width = width;
renderer.height = height;
renderer.canvas.width = width;
renderer.canvas.height = height;
if (renderer.fieldTexture)
gl.deleteTexture(renderer.fieldTexture);
if (renderer.tintTexture)
gl.deleteTexture(renderer.tintTexture);
if (renderer.framebuffer)
gl.deleteFramebuffer(renderer.framebuffer);
if (renderer.tintFramebuffer)
gl.deleteFramebuffer(renderer.tintFramebuffer);
renderer.fieldTexture = gl.createTexture();
renderer.tintTexture = gl.createTexture();
renderer.framebuffer = gl.createFramebuffer();
renderer.tintFramebuffer = gl.createFramebuffer();
if (!renderer.fieldTexture || !renderer.tintTexture || !renderer.framebuffer || !renderer.tintFramebuffer)
return false;
configureGravityTexture(gl, renderer.fieldTexture, width, height);
configureGravityTexture(gl, renderer.tintTexture, width, height);
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, renderer.fieldTexture, 0);
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE)
return false;
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.tintFramebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, renderer.tintTexture, 0);
return gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
}
function configureGravityTexture(gl, texture, width, height) {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.HALF_FLOAT, null);
}
function buildGravitySplats(props, gravityDocument, bodies, bounds) {
const rows = [];
void bounds;
const terrainRadius = numberProp(gravityDocument.terrainRadius, numberProp(props.terrainRadius, 1200));
const terrainDepth = numberProp(gravityDocument.terrainDepth, numberProp(props.terrainDepth, -8));
if (terrainDepth !== 0) {
const terrainHalfExtent = Math.max(1, terrainRadius * 2);
appendGravitySplat(rows, 0, 0, terrainHalfExtent, terrainHalfExtent, -terrainDepth, 0, 0, 1, Math.max(0.0001, numberProp(gravityDocument.terrainDepthExponent, numberProp(props.terrainDepthExponent, 1.2))), 0, 1, 0, 0);
}
for (const body of bodies) {
appendGravitySplat(rows, body.x, body.y, Math.max(1, body.radius), Math.max(1, body.radius), -body.depth, 0, 0, 1, Math.max(0.0001, body.exponent), 0, 1, 0, 0);
if (body.waveRadius > 0 && body.waveDepth !== 0) {
appendGravitySplat(rows, body.x, body.y, body.waveRadius, body.waveRadius, -body.waveDepth, 0, 0, 1, 8.0, 4, numberProp(gravityDocument.terrainWaveFrequency, numberProp(props.terrainWaveFrequency, 0.6)), body.waveSpeed, 0);
}
}
return new Float32Array(rows);
}
function appendGravitySplat(rows, centerX, centerY, halfX, halfY, valueR, valueG, valueB, valueA, power, sourceKind, frequencyX, animationSpeed, sourceFlags, rotationCos = 1, rotationSin = 0) {
const corners = [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1];
for (let i = 0; i < corners.length; i += 2) {
rows.push(corners[i], corners[i + 1], centerX, centerY, halfX, halfY, rotationCos, rotationSin, power, 0, 0, 0, sourceKind, frequencyX, animationSpeed, sourceFlags, valueR, valueG, valueB, valueA);
}
}
function bindGravitySplatAttributes(gl, program) {
bindGravitySplatAttribute(gl, program, "a_corner", 2, 0);
bindGravitySplatAttribute(gl, program, "a_center", 2, 2);
bindGravitySplatAttribute(gl, program, "a_half", 2, 4);
bindGravitySplatAttribute(gl, program, "a_rot", 2, 6);
bindGravitySplatAttribute(gl, program, "a_meta", 4, 8);
bindGravitySplatAttribute(gl, program, "a_source", 4, 12);
bindGravitySplatAttribute(gl, program, "a_value", 4, 16);
}
function bindGravitySplatAttribute(gl, program, name, size, offsetFloats) {
const location = gl.getAttribLocation(program, name);
if (location < 0)
return;
gl.enableVertexAttribArray(location);
gl.vertexAttribPointer(location, size, gl.FLOAT, false, GRAVITY_SPLAT_STRIDE_FLOATS * 4, offsetFloats * 4);
}
function createGravityProgram(gl, vertexSource, fragmentSource) {
const vertex = compileGravityShader(gl, gl.VERTEX_SHADER, vertexSource);
const fragment = compileGravityShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
if (!vertex || !fragment)
return null;
const program = gl.createProgram();
if (!program)
return null;
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS))
return null;
return program;
}
function compileGravityShader(gl, type, source) {
const shader = gl.createShader(type);
if (!shader)
return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
return null;
return shader;
}
const GRAVITY_SPLAT_VERTEX_SHADER = `#version 300 es
in vec2 a_corner;
in vec2 a_center;
in vec2 a_half;
in vec2 a_rot;
in vec4 a_meta;
in vec4 a_source;
in vec4 a_value;
uniform vec4 u_viewport;
out vec2 v_local;
out vec4 v_meta;
out vec4 v_source;
out vec4 v_value;
void main() {
vec2 scaled = a_corner * a_half;
vec2 world = a_center + vec2(
scaled.x * a_rot.x - scaled.y * a_rot.y,
scaled.x * a_rot.y + scaled.y * a_rot.x
);
vec2 clip = vec2(
((world.x - u_viewport.x) / max(0.0001, u_viewport.z - u_viewport.x)) * 2.0 - 1.0,
((world.y - u_viewport.y) / max(0.0001, u_viewport.w - u_viewport.y)) * 2.0 - 1.0
);
gl_Position = vec4(clip, 0.0, 1.0);
v_local = a_corner;
v_meta = a_meta;
v_source = a_source;
v_value = a_value;
}`;
const GRAVITY_SPLAT_FRAGMENT_SHADER = `#version 300 es
precision highp float;
in vec2 v_local;
in vec4 v_meta;
in vec4 v_source;
in vec4 v_value;
uniform float u_time;
out vec4 outColor;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float valueNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) * 2.0 - 1.0;
}
float powerPulse(float x, float power) {
x = clamp(abs(x), 0.0, 1.0);
return pow((x + 1.0) * (1.0 - x), max(0.0001, power));
}
void main() {
float d = length(v_local);
if (d > 1.0) discard;
float alpha = powerPulse(d, v_meta.x);
if (alpha <= 0.0001) discard;
float source = 1.0;
if (v_source.x > 0.5 && v_source.x < 2.5) {
float timeOffset = v_source.x > 1.5 ? u_time * v_source.z : 0.0;
source = valueNoise(v_local * v_source.y + timeOffset);
if (v_source.w != 0.0) source = abs(source);
} else if (v_source.x > 3.5) {
source = cos(pow(d * 2.0, 1.25) * v_source.y + u_time * v_source.z);
}
outColor = v_value * alpha * source;
}`;
const GRAVITY_SHADE_VERTEX_SHADER = `#version 300 es
in vec2 a_position;
out vec2 v_uv;
void main() {
v_uv = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}`;
const GRAVITY_SHADE_FRAGMENT_SHADER = `#version 300 es
precision highp float;
uniform sampler2D u_field;
uniform sampler2D u_tint;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_lineInterval;
uniform float u_lineOffset;
uniform float u_lineWidth;
uniform float u_lineFade;
uniform float u_angleWidth;
uniform float u_angleFade;
uniform float u_dangerSteepness;
uniform float u_scale;
uniform vec3 u_baseColor;
uniform vec3 u_fieldGlowColor;
uniform vec3 u_lineLowColor;
uniform vec3 u_lineHighColor;
uniform vec3 u_angleLowColor;
uniform vec3 u_angleHighColor;
uniform float u_tintScale;
in vec2 v_uv;
out vec4 outColor;
vec2 calcGrad(vec2 uv, float me) {
vec2 texel = 1.0 / max(vec2(1.0), u_resolution);
float n = -texture(u_field, vec2(uv.x, uv.y + texel.y)).r;
float e = -texture(u_field, vec2(uv.x + texel.x, uv.y)).r;
return vec2(e - me, n - me);
}
void main() {
float height = -texture(u_field, v_uv).r;
vec4 tint = texture(u_tint, v_uv);
vec2 plan = calcGrad(v_uv, height);
float slope = max(length(plan), 0.00001);
vec3 color = u_baseColor;
float fieldGlow = smoothstep(0.0, u_lineInterval * 7.0, abs(height));
color += u_fieldGlowColor * fieldGlow;
color += tint.rgb * clamp(tint.a, 0.0, 1.0) * u_tintScale;
float dangerBlend = smoothstep(0.0, u_dangerSteepness, pow(slope / max(0.0001, u_scale), 2.0));
vec3 baseLine = mix(u_lineLowColor, u_lineHighColor, dangerBlend);
vec3 angleLine = mix(u_angleLowColor, u_angleHighColor, dangerBlend) * clamp(slope / max(0.0001, u_scale), 0.0, 1.0);
vec3 lines = vec3(0.0);
float interval = max(0.0001, abs(u_lineInterval));
float nearestLine = abs(fract((height + u_lineOffset) / interval) - 0.5) * interval;
float lineDistance = nearestLine / slope;
float line = 1.0 - smoothstep(u_lineWidth, u_lineWidth * u_lineFade, lineDistance);
lines += line * baseLine;
float angle = atan(plan.y, plan.x) / 3.1415926536 + 1.0;
float nearestAngle = abs(fract(angle * 6.0) - 0.5) / 6.0;
float angleMask = smoothstep(interval * 0.25, interval * 1.5, abs(height));
float angleLineAmount = 1.0 - smoothstep(u_angleWidth, u_angleWidth * u_angleFade, nearestAngle);
lines += angleLineAmount * angleMask * angleLine;
color += clamp(lines, 0.0, 1.7);
outColor = vec4(color, 1.0);
}`;
function drawGravityBodies(ctx, bodies, props, bounds, width, height) {
for (const body of bodies) {
const screen = worldToSurface(bounds, body.x, body.y, width, height);
const visualRadius = Math.max(18, Math.min(190, body.radius / Math.max(1, bounds.maxX - bounds.minX) * width * 0.32));
const icon = resolveAssetUrl(body.icon);
const fallbackIconSize = body.kind.toLowerCase().includes("sun")
? Math.max(numberProp(props.sunIconMinPx, 34), visualRadius * numberProp(props.sunIconScale, 0.72))
: Math.max(numberProp(props.bodyIconMinPx, 24), visualRadius * numberProp(props.bodyIconScale, 0.48));
const iconSize = body.iconSize > 0 ? body.iconSize : fallbackIconSize;
if (icon)
drawGravityAsset(ctx, icon, screen.x, screen.y, iconSize, 0.92);
if (body.key) {
drawGravityLabel(ctx, props, body.key.replace(/^local\./, ""), screen.x + visualRadius * 0.44, screen.y - visualRadius * 0.48, firstString(props.bodyLabelColor, "rgba(226, 244, 255, 0.82)"));
}
}
}
const gravityImageCache = new Map();
function drawGravityAsset(ctx, src, x, y, size, alpha) {
let image = gravityImageCache.get(src);
if (!image) {
image = new Image();
image.decoding = "async";
image.onload = () => requestAnimationFrame(() => {
const canvas = ctx.canvas;
canvas.dispatchEvent(new CustomEvent("cultui:asset-loaded"));
});
image.src = src;
gravityImageCache.set(src, image);
}
if (!image.complete || image.naturalWidth === 0)
return;
ctx.save();
ctx.globalAlpha = alpha;
ctx.drawImage(image, x - size / 2, y - size / 2, size, size);
ctx.restore();
}
function drawGravityObjects(ctx, objects, props, bounds, width, height) {
for (const obj of objects) {
const screen = worldToSurface(bounds, obj.x, obj.y, width, height);
if (screen.x < -40 || screen.x > width + 40 || screen.y < -40 || screen.y > height + 40)
continue;
const size = obj.kind.toLowerCase().includes("station")
? numberProp(props.stationIconSizePx, 34)
: obj.controlled ? numberProp(props.shipIconSizePx, 22) : numberProp(props.remoteShipIconSizePx, 18);
const color = factionColor(props, obj.faction, obj.controlled, obj.visibility);
const icon = resolveAssetUrl(obj.icon);
ctx.save();
ctx.globalAlpha = Math.max(0.25, Math.min(1, obj.visibility));
ctx.translate(screen.x, screen.y);
const angle = Math.atan2(obj.directionY, obj.directionX) + Math.PI / 2;
if (obj.kind.toLowerCase().includes("ship"))
ctx.rotate(angle);
if (icon) {
const image = gravityImageCache.get(icon);
if (!image) {
const loading = new Image();
loading.decoding = "async";
loading.onload = () => ctx.canvas.dispatchEvent(new CustomEvent("cultui:asset-loaded"));
loading.src = icon;
gravityImageCache.set(icon, loading);
}
else if (image.complete && image.naturalWidth > 0) {
ctx.drawImage(image, -size / 2, -size / 2, size, size);
}
}
ctx.restore();
drawGravityLabel(ctx, props, obj.label, screen.x + size * 0.55, screen.y - size * 0.45, color);
}
}
function drawGravityLabel(ctx, props, label, x, y, color) {
if (!label)
return;
ctx.save();
ctx.font = firstString(props.objectLabelFont, "700 12px Ubuntu, system-ui, sans-serif");
ctx.textBaseline = "middle";
ctx.lineWidth = numberProp(props.objectLabelStrokeWidth, 3);
ctx.strokeStyle = firstString(props.objectLabelStroke, "rgba(0, 0, 0, 0.72)");
ctx.fillStyle = color;
ctx.strokeText(label, x, y);
ctx.fillText(label, x, y);
ctx.restore();
}
function factionColor(props, faction, controlled, visibility) {
const alpha = Math.max(0.25, Math.min(1, visibility));
if (controlled)
return colorTemplate(firstString(props.objectControlledColor, "rgba(122, 240, 255, {alpha})"), alpha);
const normalized = faction.toLowerCase();
if (normalized.includes("raider"))
return colorTemplate(firstString(props.objectRaiderColor, "rgba(255, 143, 74, {alpha})"), alpha);
if (normalized.includes("neutral"))
return colorTemplate(firstString(props.objectNeutralColor, "rgba(232, 232, 224, {alpha})"), alpha);
return colorTemplate(firstString(props.objectDefaultColor, "rgba(214, 244, 255, {alpha})"), alpha);
}
function colorTemplate(template, alpha) {
return template.replaceAll("{alpha}", String(alpha));
}
function parseGravityBodies(value) {
return value
.split(";")
.map(entry => entry.trim())
.filter(Boolean)
.map(entry => {
const parts = entry.split("|");
return {
key: parts[0] ?? "",
kind: parts[1] ?? "",
x: Number(parts[2]) || 0,
y: Number(parts[3]) || 0,
radius: Math.max(1, Number(parts[4]) || 1),
depth: Number(parts[5]) || 0,
exponent: Number(parts[6]) || 1,
waveRadius: Number(parts[7]) || 0,
waveDepth: Number(parts[8]) || 0,
waveSpeed: Number(parts[9]) || 0,
icon: parts[10] ?? "",
iconSize: Number(parts[12]) || 0,
tint: parts[11] ?? "",
};
});
}
function parseGravityObjects(value) {
return value
.split(";")
.map(entry => entry.trim())
.filter(Boolean)
.map(entry => {
const parts = entry.split("|");
return {
key: parts[0] ?? "",
kind: parts[1] ?? "",
label: parts[2] ?? "",
x: Number(parts[3]) || 0,
y: Number(parts[4]) || 0,
directionX: Number(parts[5]) || 0,
directionY: Number(parts[6]) || 1,
faction: parts[7] ?? "",
controlled: parts[8] === "1" || parts[8] === "true",
visibility: Number(parts[9]) || 1,
icon: parts[10] ?? "",
};
});
}
function gravityBodiesFromDocument(document) {
const influences = Array.isArray(document?.gravityInfluences) ? document.gravityInfluences : [];
const bodies = Array.isArray(document?.bodies) ? document.bodies : [];
if (influences.length === 0 && bodies.length === 0)
return undefined;
const displayBodiesByKey = new Map();
for (const body of bodies) {
const key = firstString(body.bodyKey, body.name, "");
if (key)
displayBodiesByKey.set(key, body);
}
return (influences.length > 0 ? influences : bodies).map((body) => {
const key = firstString(body.bodyKey, body.name, "body");
const displayBody = displayBodiesByKey.get(key);
const kind = firstString(body.kind, "Body");
return {
key,
kind,
x: numberProp(body.x, 0),
y: numberProp(body.y, 0),
radius: Math.max(1, numberProp(body.radius, 80)),
depth: numberProp(body.gravityDepth, 0),
exponent: numberProp(body.gravityDepthExponent, 1),
waveRadius: numberProp(body.waveRadius, 0),
waveDepth: numberProp(body.waveDepth, 0),
waveSpeed: numberProp(body.waveSpeed, 0),
icon: assetUriFromRef(body.iconAsset) || assetUriFromRef(displayBody?.iconAsset),
iconSize: numberProp(displayBody?.iconSize, numberProp(body.iconSize, 0)),
tint: "",
};
});
}
function gravityObjectsFromDocument(document) {
const objects = Array.isArray(document?.objects) ? document.objects : [];
if (objects.length === 0)
return undefined;
return objects.map((obj) => {
const kind = firstString(obj.kind, "object");
return {
key: firstString(obj.entityKey, String(obj.entityIndex ?? ""), "object"),
kind,
label: firstString(obj.displayName, obj.entityKey, "object"),
x: numberProp(obj.x, 0),
y: numberProp(obj.y, 0),
directionX: numberProp(obj.directionX, 1),
directionY: numberProp(obj.directionY, 0),
faction: firstString(obj.factionKey, ""),
controlled: boolProp(obj.controlled),
visibility: numberProp(obj.visibility, 1),
icon: assetUriFromRef(obj.iconAsset),
};
});
}
function assetUriFromRef(value) {
const asset = objectProps(value);
const uri = firstString(asset.uri, "");
return uri;
}
function gravityBounds(props) {
const radius = Math.max(1, numberProp(props.viewRadius, Math.max(1200, numberProp(props.terrainRadius, 1200))));
return {
minX: numberProp(props.minX, -radius),
minY: numberProp(props.minY, -radius),
maxX: numberProp(props.maxX, radius),
maxY: numberProp(props.maxY, radius),
};
}
function fitBoundsToAspect(bounds, width, height) {
const worldWidth = Math.max(0.0001, bounds.maxX - bounds.minX);
const worldHeight = Math.max(0.0001, bounds.maxY - bounds.minY);
const canvasAspect = Math.max(0.0001, width / Math.max(1, height));
const worldAspect = worldWidth / worldHeight;
const centerX = (bounds.minX + bounds.maxX) * 0.5;
const centerY = (bounds.minY + bounds.maxY) * 0.5;
if (canvasAspect > worldAspect) {
const fittedWidth = worldHeight * canvasAspect;
return {
minX: centerX - fittedWidth * 0.5,
maxX: centerX + fittedWidth * 0.5,
minY: bounds.minY,
maxY: bounds.maxY,
};
}
const fittedHeight = worldWidth / canvasAspect;
return {
minX: bounds.minX,
maxX: bounds.maxX,
minY: centerY - fittedHeight * 0.5,
maxY: centerY + fittedHeight * 0.5,
};
}
function worldToSurface(bounds, x, y, width, height) {
return {
x: (x - bounds.minX) / Math.max(1, bounds.maxX - bounds.minX) * width,
y: (y - bounds.minY) / Math.max(1, bounds.maxY - bounds.minY) * height,
};
}
function numberProp(value, fallback) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;

View file

@ -1,3 +1,5 @@
import { drawGravityFieldWebGl, type GravitySurfaceBody } from "./fields-webgl-renderer.js";
const FIELD_SCHEMAS = new Set([
"gamecult.fields.splats.v1",
"gamecult.fields.gravity.v1",
@ -70,10 +72,24 @@ export interface FieldsSurfaceDataState {
lastLoadedAt?: number;
}
type GravitySurfaceObject = {
key: string;
kind: string;
label: string;
x: number;
y: number;
directionX: number;
directionY: number;
faction: string;
controlled: boolean;
visibility: number;
icon: string;
};
export interface FieldsRuntimeContext {
applyGeneratedLayout(element: HTMLElement, layout: Record<string, unknown>, style: Record<string, unknown>): void;
drawSurface(canvas: HTMLCanvasElement, props: Record<string, unknown>, state: FieldsSurfaceDataState): void;
providerId?: string;
resolveAssetUrl(uri: string): string;
}
function renderFieldsComponent(
@ -97,7 +113,7 @@ function renderFieldsComponent(
let drawQueued = false;
const state: FieldsSurfaceDataState = {};
const draw = () => context.drawSurface(canvas, props, state);
const draw = () => drawGravitySurface(canvas, props, state, context.resolveAssetUrl);
const scheduleDraw = () => {
if (!canvas.isConnected || drawQueued) return;
drawQueued = true;
@ -218,6 +234,321 @@ export function normalizeFieldsDocument(schemaId: string | undefined, value: unk
return normalizeObjects(tuple);
}
function drawGravitySurface(
canvas: HTMLCanvasElement,
props: Record<string, unknown>,
state: FieldsSurfaceDataState | undefined,
resolveAssetUrl: (uri: string) => string,
): void {
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width || canvas.clientWidth || 1));
const height = Math.max(1, Math.floor(rect.height || canvas.clientHeight || 1));
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
const pixelWidth = Math.max(1, Math.floor(width * dpr));
const pixelHeight = Math.max(1, Math.floor(height * dpr));
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const bounds = fitBoundsToAspect(gravityBounds(props), width, height);
const gravityDocument = objectProps(state?.gravity);
const bodies = gravityBodiesFromDocument(state?.gravity) || parseGravityBodies(firstString(props.bodies, ""));
const objects = gravityObjectsFromDocument(state?.objects) || parseGravityObjects(firstString(props.objects, ""));
if (!drawGravityFieldWebGl(ctx, props, gravityDocument, bodies, bounds, width, height, state?.renderSplats)) {
drawFieldLoweringFailure(ctx, props, width, height, "field.surface2d requires WebGL2 float render targets");
}
drawGravityBodies(ctx, bodies, props, bounds, width, height, resolveAssetUrl);
drawGravityObjects(ctx, objects, props, bounds, width, height, resolveAssetUrl);
}
function drawFieldLoweringFailure(ctx: CanvasRenderingContext2D, props: Record<string, unknown>, width: number, height: number, message: string): void {
ctx.save();
ctx.fillStyle = firstString(props.failureBackground, "rgba(0, 0, 0, 0.72)");
ctx.fillRect(0, 0, width, height);
ctx.font = firstString(props.failureFont, "700 13px Cascadia Mono, Consolas, monospace");
ctx.fillStyle = firstString(props.failureColor, "rgba(255, 184, 79, 0.95)");
ctx.fillText(message, 16, 28);
ctx.restore();
}
function drawGravityBodies(
ctx: CanvasRenderingContext2D,
bodies: GravitySurfaceBody[],
props: Record<string, unknown>,
bounds: { minX: number; minY: number; maxX: number; maxY: number },
width: number,
height: number,
resolveAssetUrl: (uri: string) => string,
): void {
for (const body of bodies) {
const screen = worldToSurface(bounds, body.x, body.y, width, height);
const visualRadius = Math.max(18, Math.min(190, body.radius / Math.max(1, bounds.maxX - bounds.minX) * width * 0.32));
const icon = resolveAssetUrl(body.icon);
const fallbackIconSize = body.kind.toLowerCase().includes("sun")
? Math.max(numberProp(props.sunIconMinPx, 34), visualRadius * numberProp(props.sunIconScale, 0.72))
: Math.max(numberProp(props.bodyIconMinPx, 24), visualRadius * numberProp(props.bodyIconScale, 0.48));
const iconSize = body.iconSize > 0 ? body.iconSize : fallbackIconSize;
if (icon) drawGravityAsset(ctx, icon, screen.x, screen.y, iconSize, 0.92);
if (body.key) {
drawGravityLabel(ctx, props, body.key.replace(/^local\./, ""), screen.x + visualRadius * 0.44, screen.y - visualRadius * 0.48, firstString(props.bodyLabelColor, "rgba(226, 244, 255, 0.82)"));
}
}
}
const gravityImageCache = new Map<string, HTMLImageElement>();
function drawGravityAsset(ctx: CanvasRenderingContext2D, src: string, x: number, y: number, size: number, alpha: number): void {
let image = gravityImageCache.get(src);
if (!image) {
image = new Image();
image.decoding = "async";
image.onload = () => requestAnimationFrame(() => {
const canvas = ctx.canvas;
canvas.dispatchEvent(new CustomEvent("cultui:asset-loaded"));
});
image.src = src;
gravityImageCache.set(src, image);
}
if (!image.complete || image.naturalWidth === 0) return;
ctx.save();
ctx.globalAlpha = alpha;
ctx.drawImage(image, x - size / 2, y - size / 2, size, size);
ctx.restore();
}
function drawGravityObjects(
ctx: CanvasRenderingContext2D,
objects: GravitySurfaceObject[],
props: Record<string, unknown>,
bounds: { minX: number; minY: number; maxX: number; maxY: number },
width: number,
height: number,
resolveAssetUrl: (uri: string) => string,
): void {
for (const obj of objects) {
const screen = worldToSurface(bounds, obj.x, obj.y, width, height);
if (screen.x < -40 || screen.x > width + 40 || screen.y < -40 || screen.y > height + 40) continue;
const size = obj.kind.toLowerCase().includes("station")
? numberProp(props.stationIconSizePx, 34)
: obj.controlled ? numberProp(props.shipIconSizePx, 22) : numberProp(props.remoteShipIconSizePx, 18);
const color = factionColor(props, obj.faction, obj.controlled, obj.visibility);
const icon = resolveAssetUrl(obj.icon);
ctx.save();
ctx.globalAlpha = Math.max(0.25, Math.min(1, obj.visibility));
ctx.translate(screen.x, screen.y);
const angle = Math.atan2(obj.directionY, obj.directionX) + Math.PI / 2;
if (obj.kind.toLowerCase().includes("ship")) ctx.rotate(angle);
if (icon) {
const image = gravityImageCache.get(icon);
if (!image) {
const loading = new Image();
loading.decoding = "async";
loading.onload = () => ctx.canvas.dispatchEvent(new CustomEvent("cultui:asset-loaded"));
loading.src = icon;
gravityImageCache.set(icon, loading);
} else if (image.complete && image.naturalWidth > 0) {
ctx.drawImage(image, -size / 2, -size / 2, size, size);
}
}
ctx.restore();
drawGravityLabel(ctx, props, obj.label, screen.x + size * 0.55, screen.y - size * 0.45, color);
}
}
function drawGravityLabel(ctx: CanvasRenderingContext2D, props: Record<string, unknown>, label: string, x: number, y: number, color: string): void {
if (!label) return;
ctx.save();
ctx.font = firstString(props.objectLabelFont, "700 12px Ubuntu, system-ui, sans-serif");
ctx.textBaseline = "middle";
ctx.lineWidth = numberProp(props.objectLabelStrokeWidth, 3);
ctx.strokeStyle = firstString(props.objectLabelStroke, "rgba(0, 0, 0, 0.72)");
ctx.fillStyle = color;
ctx.strokeText(label, x, y);
ctx.fillText(label, x, y);
ctx.restore();
}
function factionColor(props: Record<string, unknown>, faction: string, controlled: boolean, visibility: number): string {
const alpha = Math.max(0.25, Math.min(1, visibility));
if (controlled) return colorTemplate(firstString(props.objectControlledColor, "rgba(122, 240, 255, {alpha})"), alpha);
const normalized = faction.toLowerCase();
if (normalized.includes("raider")) return colorTemplate(firstString(props.objectRaiderColor, "rgba(255, 143, 74, {alpha})"), alpha);
if (normalized.includes("neutral")) return colorTemplate(firstString(props.objectNeutralColor, "rgba(232, 232, 224, {alpha})"), alpha);
return colorTemplate(firstString(props.objectDefaultColor, "rgba(214, 244, 255, {alpha})"), alpha);
}
function colorTemplate(template: string, alpha: number): string {
return template.replaceAll("{alpha}", String(alpha));
}
function parseGravityBodies(value: string): GravitySurfaceBody[] {
return value
.split(";")
.map(entry => entry.trim())
.filter(Boolean)
.map(entry => {
const parts = entry.split("|");
return {
key: parts[0] ?? "",
kind: parts[1] ?? "",
x: Number(parts[2]) || 0,
y: Number(parts[3]) || 0,
radius: Math.max(1, Number(parts[4]) || 1),
depth: Number(parts[5]) || 0,
exponent: Number(parts[6]) || 1,
waveRadius: Number(parts[7]) || 0,
waveDepth: Number(parts[8]) || 0,
waveSpeed: Number(parts[9]) || 0,
icon: parts[10] ?? "",
iconSize: Number(parts[12]) || 0,
tint: parts[11] ?? "",
};
});
}
function parseGravityObjects(value: string): GravitySurfaceObject[] {
return value
.split(";")
.map(entry => entry.trim())
.filter(Boolean)
.map(entry => {
const parts = entry.split("|");
return {
key: parts[0] ?? "",
kind: parts[1] ?? "",
label: parts[2] ?? "",
x: Number(parts[3]) || 0,
y: Number(parts[4]) || 0,
directionX: Number(parts[5]) || 0,
directionY: Number(parts[6]) || 1,
faction: parts[7] ?? "",
controlled: parts[8] === "1" || parts[8] === "true",
visibility: Number(parts[9]) || 1,
icon: parts[10] ?? "",
};
});
}
function gravityBodiesFromDocument(document: Record<string, unknown> | undefined): GravitySurfaceBody[] | undefined {
const influences = Array.isArray(document?.gravityInfluences) ? document.gravityInfluences as Array<Record<string, unknown>> : [];
const bodies = Array.isArray(document?.bodies) ? document.bodies as Array<Record<string, unknown>> : [];
if (influences.length === 0 && bodies.length === 0) return undefined;
const displayBodiesByKey = new Map<string, Record<string, unknown>>();
for (const body of bodies) {
const key = firstString(body.bodyKey, body.name, "");
if (key) displayBodiesByKey.set(key, body);
}
return (influences.length > 0 ? influences : bodies).map((body) => {
const key = firstString(body.bodyKey, body.name, "body");
const displayBody = displayBodiesByKey.get(key);
const kind = firstString(body.kind, "Body");
return {
key,
kind,
x: numberProp(body.x, 0),
y: numberProp(body.y, 0),
radius: Math.max(1, numberProp(body.radius, 80)),
depth: numberProp(body.gravityDepth, 0),
exponent: numberProp(body.gravityDepthExponent, 1),
waveRadius: numberProp(body.waveRadius, 0),
waveDepth: numberProp(body.waveDepth, 0),
waveSpeed: numberProp(body.waveSpeed, 0),
icon: assetUriFromRef((body as Record<string, unknown>).iconAsset) || assetUriFromRef(displayBody?.iconAsset),
iconSize: numberProp(displayBody?.iconSize, numberProp((body as Record<string, unknown>).iconSize, 0)),
tint: "",
};
});
}
function gravityObjectsFromDocument(document: Record<string, unknown> | undefined): GravitySurfaceObject[] | undefined {
const objects = Array.isArray(document?.objects) ? document.objects as Array<Record<string, unknown>> : [];
if (objects.length === 0) return undefined;
return objects.map((obj) => {
const kind = firstString(obj.kind, "object");
return {
key: firstString(obj.entityKey, String(obj.entityIndex ?? ""), "object"),
kind,
label: firstString(obj.displayName, obj.entityKey, "object"),
x: numberProp(obj.x, 0),
y: numberProp(obj.y, 0),
directionX: numberProp(obj.directionX, 1),
directionY: numberProp(obj.directionY, 0),
faction: firstString(obj.factionKey, ""),
controlled: boolProp(obj.controlled),
visibility: numberProp(obj.visibility, 1),
icon: assetUriFromRef(obj.iconAsset),
};
});
}
function assetUriFromRef(value: unknown): string {
const asset = objectProps(value);
const uri = firstString(asset.uri, "");
return uri;
}
function gravityBounds(props: Record<string, unknown>): { minX: number; minY: number; maxX: number; maxY: number } {
const radius = Math.max(1, numberProp(props.viewRadius, Math.max(1200, numberProp(props.terrainRadius, 1200))));
return {
minX: numberProp(props.minX, -radius),
minY: numberProp(props.minY, -radius),
maxX: numberProp(props.maxX, radius),
maxY: numberProp(props.maxY, radius),
};
}
function fitBoundsToAspect(
bounds: { minX: number; minY: number; maxX: number; maxY: number },
width: number,
height: number,
): { minX: number; minY: number; maxX: number; maxY: number } {
const worldWidth = Math.max(0.0001, bounds.maxX - bounds.minX);
const worldHeight = Math.max(0.0001, bounds.maxY - bounds.minY);
const canvasAspect = Math.max(0.0001, width / Math.max(1, height));
const worldAspect = worldWidth / worldHeight;
const centerX = (bounds.minX + bounds.maxX) * 0.5;
const centerY = (bounds.minY + bounds.maxY) * 0.5;
if (canvasAspect > worldAspect) {
const fittedWidth = worldHeight * canvasAspect;
return {
minX: centerX - fittedWidth * 0.5,
maxX: centerX + fittedWidth * 0.5,
minY: bounds.minY,
maxY: bounds.maxY,
};
}
const fittedHeight = worldWidth / canvasAspect;
return {
minX: bounds.minX,
maxX: bounds.maxX,
minY: centerY - fittedHeight * 0.5,
maxY: centerY + fittedHeight * 0.5,
};
}
function worldToSurface(
bounds: { minX: number; minY: number; maxX: number; maxY: number },
x: number,
y: number,
width: number,
height: number,
): { x: number; y: number } {
return {
x: (x - bounds.minX) / Math.max(1, bounds.maxX - bounds.minX) * width,
y: (y - bounds.minY) / Math.max(1, bounds.maxY - bounds.minY) * height,
};
}
function normalizeSplats(value: unknown[]): Record<string, unknown> {
const splats = array(value[9]);
return {
@ -347,3 +678,12 @@ function numberValue(value: unknown, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
const objectProps = record;
const numberProp = numberValue;
function boolProp(value: unknown): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
return typeof value === "string" && ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}

View file

@ -0,0 +1,656 @@
export type GravitySurfaceBody = {
key: string;
kind: string;
x: number;
y: number;
radius: number;
depth: number;
exponent: number;
waveRadius: number;
waveDepth: number;
waveSpeed: number;
icon: string;
iconSize: number;
tint: string;
};
type GravityWebGlRenderer = {
canvas: HTMLCanvasElement;
gl: WebGL2RenderingContext;
fieldTexture: WebGLTexture | null;
tintTexture: WebGLTexture | null;
framebuffer: WebGLFramebuffer | null;
tintFramebuffer: WebGLFramebuffer | null;
splatProgram: WebGLProgram;
shadeProgram: WebGLProgram;
splatBuffer: WebGLBuffer;
quadBuffer: WebGLBuffer;
width: number;
height: number;
};
const gravityWebGlRenderers = new WeakMap<HTMLCanvasElement, GravityWebGlRenderer | null>();
const GRAVITY_SPLAT_STRIDE_FLOATS = 20;
export function drawGravityFieldWebGl(
ctx: CanvasRenderingContext2D,
props: Record<string, unknown>,
gravityDocument: Record<string, unknown>,
bodies: GravitySurfaceBody[],
bounds: { minX: number; minY: number; maxX: number; maxY: number },
width: number,
height: number,
renderSplatsDocument?: Record<string, unknown>,
): boolean {
const renderer = resolveGravityWebGlRenderer(ctx.canvas as HTMLCanvasElement);
if (!renderer) return false;
const gl = renderer.gl;
if (!ensureGravityWebGlTarget(renderer, width, height)) {
gravityWebGlRenderers.set(ctx.canvas as HTMLCanvasElement, null);
return false;
}
const splatBuffers = buildGravitySplatBuffers(props, gravityDocument, bodies, bounds, renderSplatsDocument);
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.framebuffer);
gl.viewport(0, 0, width, height);
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
if (splatBuffers.gravity.length > 0) {
gl.useProgram(renderer.splatProgram);
gl.uniform4f(gl.getUniformLocation(renderer.splatProgram, "u_viewport"), bounds.minX, bounds.minY, bounds.maxX, bounds.maxY);
gl.uniform1f(gl.getUniformLocation(renderer.splatProgram, "u_time"), numberProp(props.simulationTimeSeconds, 0));
gl.bindBuffer(gl.ARRAY_BUFFER, renderer.splatBuffer);
gl.bufferData(gl.ARRAY_BUFFER, splatBuffers.gravity, gl.DYNAMIC_DRAW);
bindGravitySplatAttributes(gl, renderer.splatProgram);
gl.drawArrays(gl.TRIANGLES, 0, splatBuffers.gravity.length / GRAVITY_SPLAT_STRIDE_FLOATS);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.tintFramebuffer);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
if (splatBuffers.tint.length > 0) {
gl.useProgram(renderer.splatProgram);
gl.uniform4f(gl.getUniformLocation(renderer.splatProgram, "u_viewport"), bounds.minX, bounds.minY, bounds.maxX, bounds.maxY);
gl.uniform1f(gl.getUniformLocation(renderer.splatProgram, "u_time"), numberProp(props.simulationTimeSeconds, 0));
gl.bindBuffer(gl.ARRAY_BUFFER, renderer.splatBuffer);
gl.bufferData(gl.ARRAY_BUFFER, splatBuffers.tint, gl.DYNAMIC_DRAW);
bindGravitySplatAttributes(gl, renderer.splatProgram);
gl.drawArrays(gl.TRIANGLES, 0, splatBuffers.tint.length / GRAVITY_SPLAT_STRIDE_FLOATS);
}
gl.disable(gl.BLEND);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, width, height);
gl.useProgram(renderer.shadeProgram);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, renderer.fieldTexture);
gl.uniform1i(gl.getUniformLocation(renderer.shadeProgram, "u_field"), 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, renderer.tintTexture);
gl.uniform1i(gl.getUniformLocation(renderer.shadeProgram, "u_tint"), 1);
gl.uniform2f(gl.getUniformLocation(renderer.shadeProgram, "u_resolution"), width, height);
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_time"), numberProp(gravityDocument.simulationTimeSeconds, numberProp(props.simulationTimeSeconds, 0)));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineInterval"), numberProp(props.lineInterval, numberProp(props.scalarFieldLineInterval, numberProp(props.depthRange, 228.79605) / 20)));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineOffset"), numberProp(props.lineOffset, numberProp(props.startDepth, 2.310401)));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineWidth"), numberProp(props.lineWidth, 0.3));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineFade"), numberProp(props.lineFade, 5));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_angleWidth"), numberProp(props.angleWidth, 0.05));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_angleFade"), numberProp(props.angleFade, 1.15));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_dangerSteepness"), numberProp(props.dangerSteepness, 1.5));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_scale"), numberProp(props.isolineScale, 2.6924083));
uniform3(gl, renderer.shadeProgram, "u_baseColor", vector3Prop(props.scalarFieldBaseColor, [0.002, 0.006, 0.012]));
uniform3(gl, renderer.shadeProgram, "u_fieldGlowColor", vector3Prop(props.scalarFieldGlowColor, [0.018, 0.050, 0.075]));
uniform3(gl, renderer.shadeProgram, "u_lineLowColor", vector3Prop(props.scalarFieldLowLineColor, [0.0, 0.34, 0.52]));
uniform3(gl, renderer.shadeProgram, "u_lineHighColor", vector3Prop(props.scalarFieldHighLineColor, [1.45, 0.30, 0.05]));
uniform3(gl, renderer.shadeProgram, "u_angleLowColor", vector3Prop(props.scalarFieldLowAngleColor, [0.06, 0.16, 0.24]));
uniform3(gl, renderer.shadeProgram, "u_angleHighColor", vector3Prop(props.scalarFieldHighAngleColor, [1.10, 0.24, 0.04]));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_tintScale"), numberProp(props.vectorFieldTintScale, 0.45));
gl.bindBuffer(gl.ARRAY_BUFFER, renderer.quadBuffer);
const position = gl.getAttribLocation(renderer.shadeProgram, "a_position");
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
ctx.drawImage(renderer.canvas, 0, 0, width, height);
return true;
}
function buildGravitySplatsFromDocument(document: Record<string, unknown> | undefined): Float32Array | undefined {
const splats = objectProps(document?.splats);
const layers = Array.isArray(document?.layers) ? document.layers as Array<Record<string, unknown>> : [];
const gravityLayerIndex = layers.findIndex(layer => stringProp(layer?.layerKey, "") === "gravity.height");
if (gravityLayerIndex < 0) return undefined;
const count = Math.max(0, positiveInt(splats.count, 0));
const centerX = numberArray(splats.centerX);
const centerY = numberArray(splats.centerY);
const halfExtentX = numberArray(splats.halfExtentX);
const halfExtentY = numberArray(splats.halfExtentY);
const rotationCos = numberArray(splats.rotationCos);
const rotationSin = numberArray(splats.rotationSin);
const channel = numberArray(splats.channel);
const falloff = numberArray(splats.falloff);
const valueR = numberArray(splats.valueR);
const valueG = numberArray(splats.valueG);
const sourceKind = numberArray(splats.sourceKind);
const frequencyX = numberArray(splats.frequencyX);
const animationSpeed = numberArray(splats.animationSpeed);
const sourceFlags = numberArray(splats.sourceFlags);
const layerIndex = numberArray(splats.layerIndex);
const rows: number[] = [];
const corners = [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1];
for (let index = 0; index < count; index += 1) {
if ((layerIndex[index] ?? -1) !== gravityLayerIndex) continue;
for (let corner = 0; corner < corners.length; corner += 2) {
rows.push(
corners[corner], corners[corner + 1],
centerX[index] ?? 0, centerY[index] ?? 0,
Math.max(0.0001, halfExtentX[index] ?? 1), Math.max(0.0001, halfExtentY[index] ?? 1),
rotationCos[index] ?? 1, rotationSin[index] ?? 0,
channel[index] ?? 1, falloff[index] ?? 0, valueR[index] ?? 0, 0,
sourceKind[index] ?? 0, frequencyX[index] ?? 1, animationSpeed[index] ?? 0, sourceFlags[index] ?? 0,
);
}
}
return rows.length > 0 ? new Float32Array(rows) : undefined;
}
function buildGravitySplatBuffers(
props: Record<string, unknown>,
gravityDocument: Record<string, unknown>,
bodies: GravitySurfaceBody[],
bounds: { minX: number; minY: number; maxX: number; maxY: number },
renderSplatsDocument?: Record<string, unknown>,
): { gravity: Float32Array; tint: Float32Array } {
const gravityRows: number[] = [];
const tintRows: number[] = [];
appendUnityGravitySplats(gravityRows, props, gravityDocument, bodies, bounds);
appendTintSplatsFromDocument(tintRows, props, renderSplatsDocument);
return {
gravity: new Float32Array(gravityRows),
tint: new Float32Array(tintRows),
};
}
function appendUnityGravitySplats(
rows: number[],
props: Record<string, unknown>,
gravityDocument: Record<string, unknown>,
bodies: GravitySurfaceBody[],
bounds: { minX: number; minY: number; maxX: number; maxY: number },
): void {
const terrainRadius = numberProp(gravityDocument.terrainRadius, numberProp(props.terrainRadius, 1200));
const terrainDepth = numberProp(gravityDocument.terrainDepth, numberProp(props.terrainDepth, -8));
if (terrainDepth !== 0) {
appendGravitySplat(
rows,
0,
0,
Math.max(1, terrainRadius * 2),
Math.max(1, terrainRadius * 2),
-terrainDepth,
0,
0,
1,
Math.max(0.0001, numberProp(gravityDocument.terrainDepthExponent, numberProp(props.terrainDepthExponent, 1.2))),
0,
1,
0,
0);
}
for (const body of bodies) {
appendGravitySplat(
rows,
body.x,
body.y,
Math.max(1, body.radius),
Math.max(1, body.radius),
-body.depth,
0,
0,
1,
Math.max(0.0001, body.exponent),
0,
1,
0,
0);
if (body.waveRadius > 0 && body.waveDepth !== 0) {
appendGravitySplat(
rows,
body.x,
body.y,
body.waveRadius,
body.waveRadius,
-body.waveDepth,
0,
0,
1,
8.0,
4,
numberProp(gravityDocument.terrainWaveFrequency, numberProp(props.terrainWaveFrequency, 0.6)),
body.waveSpeed,
0);
}
}
void bounds;
}
function appendTintSplatsFromDocument(rows: number[], props: Record<string, unknown>, document: Record<string, unknown> | undefined): void {
const splats = objectProps(document?.splats);
const layers = Array.isArray(document?.layers) ? document.layers as Array<Record<string, unknown>> : [];
const tintLayerIndices = new Set<number>();
const selectedLayer = firstString(props.vectorFieldLayer, props.tintFieldLayer, "fog.tint");
layers.forEach((layer, index) => {
const layerKey = stringProp(layer?.layerKey, "");
const channel = numberProp(layer?.channel, -1);
if (channel === 4 && layerKey === selectedLayer) tintLayerIndices.add(index);
});
const count = Math.max(0, positiveInt(splats.count, 0));
if (count <= 0 || tintLayerIndices.size === 0) return;
const centerX = numberArray(splats.centerX);
const centerY = numberArray(splats.centerY);
const halfExtentX = numberArray(splats.halfExtentX);
const halfExtentY = numberArray(splats.halfExtentY);
const rotationCos = numberArray(splats.rotationCos);
const rotationSin = numberArray(splats.rotationSin);
const channel = numberArray(splats.channel);
const falloff = numberArray(splats.falloff);
const valueR = numberArray(splats.valueR);
const valueG = numberArray(splats.valueG);
const valueB = numberArray(splats.valueB);
const valueA = numberArray(splats.valueA);
const sourceKind = numberArray(splats.sourceKind);
const frequencyX = numberArray(splats.frequencyX);
const animationSpeed = numberArray(splats.animationSpeed);
const sourceFlags = numberArray(splats.sourceFlags);
const layerIndex = numberArray(splats.layerIndex);
for (let index = 0; index < count; index += 1) {
if (!tintLayerIndices.has(layerIndex[index] ?? -1) || (channel[index] ?? -1) !== 4) continue;
const power = (falloff[index] ?? 0) === 0 ? 0.0001 : 1.25;
appendGravitySplat(
rows,
centerX[index] ?? 0,
centerY[index] ?? 0,
Math.max(0.0001, halfExtentX[index] ?? 1),
Math.max(0.0001, halfExtentY[index] ?? 1),
valueR[index] ?? 0,
valueG[index] ?? 0,
valueB[index] ?? 0,
valueA[index] ?? 1,
power,
sourceKind[index] ?? 0,
frequencyX[index] ?? 1,
animationSpeed[index] ?? 0,
sourceFlags[index] ?? 0,
rotationCos[index] ?? 1,
rotationSin[index] ?? 0);
}
}
function resolveGravityWebGlRenderer(canvas: HTMLCanvasElement): GravityWebGlRenderer | null {
if (gravityWebGlRenderers.has(canvas)) return gravityWebGlRenderers.get(canvas) || null;
const output = document.createElement("canvas");
const gl = output.getContext("webgl2", { alpha: false, antialias: false, premultipliedAlpha: false }) as WebGL2RenderingContext | null;
if (!gl) {
gravityWebGlRenderers.set(canvas, null);
return null;
}
const colorBufferFloat = gl.getExtension("EXT_color_buffer_float");
if (!colorBufferFloat) {
gravityWebGlRenderers.set(canvas, null);
return null;
}
const splatProgram = createGravityProgram(gl, GRAVITY_SPLAT_VERTEX_SHADER, GRAVITY_SPLAT_FRAGMENT_SHADER);
const shadeProgram = createGravityProgram(gl, GRAVITY_SHADE_VERTEX_SHADER, GRAVITY_SHADE_FRAGMENT_SHADER);
const splatBuffer = gl.createBuffer();
const quadBuffer = gl.createBuffer();
if (!splatProgram || !shadeProgram || !splatBuffer || !quadBuffer) {
gravityWebGlRenderers.set(canvas, null);
return null;
}
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, 1, 1,
-1, -1, 1, 1, -1, 1,
]), gl.STATIC_DRAW);
const renderer = {
canvas: output,
gl,
fieldTexture: null,
tintTexture: null,
framebuffer: null,
tintFramebuffer: null,
splatProgram,
shadeProgram,
splatBuffer,
quadBuffer,
width: 0,
height: 0,
};
gravityWebGlRenderers.set(canvas, renderer);
return renderer;
}
function ensureGravityWebGlTarget(renderer: GravityWebGlRenderer, width: number, height: number): boolean {
if (renderer.width === width && renderer.height === height && renderer.fieldTexture && renderer.tintTexture && renderer.framebuffer && renderer.tintFramebuffer) return true;
const gl = renderer.gl;
renderer.width = width;
renderer.height = height;
renderer.canvas.width = width;
renderer.canvas.height = height;
if (renderer.fieldTexture) gl.deleteTexture(renderer.fieldTexture);
if (renderer.tintTexture) gl.deleteTexture(renderer.tintTexture);
if (renderer.framebuffer) gl.deleteFramebuffer(renderer.framebuffer);
if (renderer.tintFramebuffer) gl.deleteFramebuffer(renderer.tintFramebuffer);
renderer.fieldTexture = gl.createTexture();
renderer.tintTexture = gl.createTexture();
renderer.framebuffer = gl.createFramebuffer();
renderer.tintFramebuffer = gl.createFramebuffer();
if (!renderer.fieldTexture || !renderer.tintTexture || !renderer.framebuffer || !renderer.tintFramebuffer) return false;
configureGravityTexture(gl, renderer.fieldTexture, width, height);
configureGravityTexture(gl, renderer.tintTexture, width, height);
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, renderer.fieldTexture, 0);
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) return false;
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.tintFramebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, renderer.tintTexture, 0);
return gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
}
function configureGravityTexture(gl: WebGL2RenderingContext, texture: WebGLTexture, width: number, height: number): void {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.HALF_FLOAT, null);
}
function buildGravitySplats(
props: Record<string, unknown>,
gravityDocument: Record<string, unknown>,
bodies: GravitySurfaceBody[],
bounds: { minX: number; minY: number; maxX: number; maxY: number },
): Float32Array {
const rows: number[] = [];
void bounds;
const terrainRadius = numberProp(gravityDocument.terrainRadius, numberProp(props.terrainRadius, 1200));
const terrainDepth = numberProp(gravityDocument.terrainDepth, numberProp(props.terrainDepth, -8));
if (terrainDepth !== 0) {
const terrainHalfExtent = Math.max(1, terrainRadius * 2);
appendGravitySplat(rows, 0, 0, terrainHalfExtent, terrainHalfExtent, -terrainDepth, 0, 0, 1, Math.max(0.0001, numberProp(gravityDocument.terrainDepthExponent, numberProp(props.terrainDepthExponent, 1.2))), 0, 1, 0, 0);
}
for (const body of bodies) {
appendGravitySplat(rows, body.x, body.y, Math.max(1, body.radius), Math.max(1, body.radius), -body.depth, 0, 0, 1, Math.max(0.0001, body.exponent), 0, 1, 0, 0);
if (body.waveRadius > 0 && body.waveDepth !== 0) {
appendGravitySplat(rows, body.x, body.y, body.waveRadius, body.waveRadius, -body.waveDepth, 0, 0, 1, 8.0, 4, numberProp(gravityDocument.terrainWaveFrequency, numberProp(props.terrainWaveFrequency, 0.6)), body.waveSpeed, 0);
}
}
return new Float32Array(rows);
}
function appendGravitySplat(
rows: number[],
centerX: number,
centerY: number,
halfX: number,
halfY: number,
valueR: number,
valueG: number,
valueB: number,
valueA: number,
power: number,
sourceKind: number,
frequencyX: number,
animationSpeed: number,
sourceFlags: number,
rotationCos = 1,
rotationSin = 0,
): void {
const corners = [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1];
for (let i = 0; i < corners.length; i += 2) {
rows.push(
corners[i], corners[i + 1],
centerX, centerY,
halfX, halfY,
rotationCos, rotationSin,
power, 0, 0, 0,
sourceKind, frequencyX, animationSpeed, sourceFlags,
valueR, valueG, valueB, valueA,
);
}
}
function bindGravitySplatAttributes(gl: WebGL2RenderingContext, program: WebGLProgram): void {
bindGravitySplatAttribute(gl, program, "a_corner", 2, 0);
bindGravitySplatAttribute(gl, program, "a_center", 2, 2);
bindGravitySplatAttribute(gl, program, "a_half", 2, 4);
bindGravitySplatAttribute(gl, program, "a_rot", 2, 6);
bindGravitySplatAttribute(gl, program, "a_meta", 4, 8);
bindGravitySplatAttribute(gl, program, "a_source", 4, 12);
bindGravitySplatAttribute(gl, program, "a_value", 4, 16);
}
function bindGravitySplatAttribute(gl: WebGL2RenderingContext, program: WebGLProgram, name: string, size: number, offsetFloats: number): void {
const location = gl.getAttribLocation(program, name);
if (location < 0) return;
gl.enableVertexAttribArray(location);
gl.vertexAttribPointer(location, size, gl.FLOAT, false, GRAVITY_SPLAT_STRIDE_FLOATS * 4, offsetFloats * 4);
}
function createGravityProgram(gl: WebGL2RenderingContext, vertexSource: string, fragmentSource: string): WebGLProgram | null {
const vertex = compileGravityShader(gl, gl.VERTEX_SHADER, vertexSource);
const fragment = compileGravityShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
if (!vertex || !fragment) return null;
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null;
return program;
}
function compileGravityShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return null;
return shader;
}
function numberProp(value: unknown, fallback: number): number {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function vector3Prop(value: unknown, fallback: [number, number, number]): [number, number, number] {
if (Array.isArray(value)) {
const values = value.map(entry => Number(entry));
if (values.length >= 3 && values.slice(0, 3).every(Number.isFinite)) return [values[0], values[1], values[2]];
}
if (typeof value === "string") {
const values = value.split(/[,\s]+/).map(entry => Number(entry)).filter(Number.isFinite);
if (values.length >= 3) return [values[0], values[1], values[2]];
}
return fallback;
}
function uniform3(gl: WebGL2RenderingContext, program: WebGLProgram, name: string, value: [number, number, number]): void {
gl.uniform3f(gl.getUniformLocation(program, name), value[0], value[1], value[2]);
}
function numberArray(value: unknown): number[] {
if (!Array.isArray(value)) return [];
return value.map(entry => numberProp(entry, 0));
}
function objectProps(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function stringProp(value: unknown, fallback: string): string {
return value === null || value === undefined ? fallback : String(value);
}
function firstString(...values: unknown[]): string {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
}
return "";
}
function positiveInt(value: unknown, fallback: number): number {
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : fallback;
}
const GRAVITY_SPLAT_VERTEX_SHADER = `#version 300 es
in vec2 a_corner;
in vec2 a_center;
in vec2 a_half;
in vec2 a_rot;
in vec4 a_meta;
in vec4 a_source;
in vec4 a_value;
uniform vec4 u_viewport;
out vec2 v_local;
out vec4 v_meta;
out vec4 v_source;
out vec4 v_value;
void main() {
vec2 scaled = a_corner * a_half;
vec2 world = a_center + vec2(
scaled.x * a_rot.x - scaled.y * a_rot.y,
scaled.x * a_rot.y + scaled.y * a_rot.x
);
vec2 clip = vec2(
((world.x - u_viewport.x) / max(0.0001, u_viewport.z - u_viewport.x)) * 2.0 - 1.0,
((world.y - u_viewport.y) / max(0.0001, u_viewport.w - u_viewport.y)) * 2.0 - 1.0
);
gl_Position = vec4(clip, 0.0, 1.0);
v_local = a_corner;
v_meta = a_meta;
v_source = a_source;
v_value = a_value;
}`;
const GRAVITY_SPLAT_FRAGMENT_SHADER = `#version 300 es
precision highp float;
in vec2 v_local;
in vec4 v_meta;
in vec4 v_source;
in vec4 v_value;
uniform float u_time;
out vec4 outColor;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float valueNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) * 2.0 - 1.0;
}
float powerPulse(float x, float power) {
x = clamp(abs(x), 0.0, 1.0);
return pow((x + 1.0) * (1.0 - x), max(0.0001, power));
}
void main() {
float d = length(v_local);
if (d > 1.0) discard;
float alpha = powerPulse(d, v_meta.x);
if (alpha <= 0.0001) discard;
float source = 1.0;
if (v_source.x > 0.5 && v_source.x < 2.5) {
float timeOffset = v_source.x > 1.5 ? u_time * v_source.z : 0.0;
source = valueNoise(v_local * v_source.y + timeOffset);
if (v_source.w != 0.0) source = abs(source);
} else if (v_source.x > 3.5) {
source = cos(pow(d * 2.0, 1.25) * v_source.y + u_time * v_source.z);
}
outColor = v_value * alpha * source;
}`;
const GRAVITY_SHADE_VERTEX_SHADER = `#version 300 es
in vec2 a_position;
out vec2 v_uv;
void main() {
v_uv = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}`;
const GRAVITY_SHADE_FRAGMENT_SHADER = `#version 300 es
precision highp float;
uniform sampler2D u_field;
uniform sampler2D u_tint;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_lineInterval;
uniform float u_lineOffset;
uniform float u_lineWidth;
uniform float u_lineFade;
uniform float u_angleWidth;
uniform float u_angleFade;
uniform float u_dangerSteepness;
uniform float u_scale;
uniform vec3 u_baseColor;
uniform vec3 u_fieldGlowColor;
uniform vec3 u_lineLowColor;
uniform vec3 u_lineHighColor;
uniform vec3 u_angleLowColor;
uniform vec3 u_angleHighColor;
uniform float u_tintScale;
in vec2 v_uv;
out vec4 outColor;
vec2 calcGrad(vec2 uv, float me) {
vec2 texel = 1.0 / max(vec2(1.0), u_resolution);
float n = -texture(u_field, vec2(uv.x, uv.y + texel.y)).r;
float e = -texture(u_field, vec2(uv.x + texel.x, uv.y)).r;
return vec2(e - me, n - me);
}
void main() {
float height = -texture(u_field, v_uv).r;
vec4 tint = texture(u_tint, v_uv);
vec2 plan = calcGrad(v_uv, height);
float slope = max(length(plan), 0.00001);
vec3 color = u_baseColor;
float fieldGlow = smoothstep(0.0, u_lineInterval * 7.0, abs(height));
color += u_fieldGlowColor * fieldGlow;
color += tint.rgb * clamp(tint.a, 0.0, 1.0) * u_tintScale;
float dangerBlend = smoothstep(0.0, u_dangerSteepness, pow(slope / max(0.0001, u_scale), 2.0));
vec3 baseLine = mix(u_lineLowColor, u_lineHighColor, dangerBlend);
vec3 angleLine = mix(u_angleLowColor, u_angleHighColor, dangerBlend) * clamp(slope / max(0.0001, u_scale), 0.0, 1.0);
vec3 lines = vec3(0.0);
float interval = max(0.0001, abs(u_lineInterval));
float nearestLine = abs(fract((height + u_lineOffset) / interval) - 0.5) * interval;
float lineDistance = nearestLine / slope;
float line = 1.0 - smoothstep(u_lineWidth, u_lineWidth * u_lineFade, lineDistance);
lines += line * baseLine;
float angle = atan(plan.y, plan.x) / 3.1415926536 + 1.0;
float nearestAngle = abs(fract(angle * 6.0) - 0.5) / 6.0;
float angleMask = smoothstep(interval * 0.25, interval * 1.5, abs(height));
float angleLineAmount = 1.0 - smoothstep(u_angleWidth, u_angleWidth * u_angleFade, nearestAngle);
lines += angleLineAmount * angleMask * angleLine;
color += clamp(lines, 0.0, 1.7);
outColor = vec4(color, 1.0);
}`;

View file

@ -1,4 +1,4 @@
import { fieldsBrowserAdapter, type EveBrowserPluginAdapter, type FieldsSurfaceDataState } from "./fields-browser-adapter.js";
import { fieldsBrowserAdapter, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js";
export { fieldsBrowserAdapter, normalizeFieldsDocument, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js";
@ -381,16 +381,13 @@ export function renderEveComponent(
return renderInventoryItem(node, props, layout, style);
}
if (kind === "field.surface2d" || kind === "gravity.surface") {
const adapter = (options.pluginAdapters || defaultBrowserPluginAdapters)
.find(candidate => candidate.componentKinds.includes(kind));
if (!adapter?.renderComponent) {
throw new Error(`No browser plugin adapter can lower ${kind}.`);
}
const adapter = (options.pluginAdapters || defaultBrowserPluginAdapters)
.find(candidate => candidate.componentKinds.includes(kind));
if (adapter?.renderComponent) {
return adapter.renderComponent(node, props, layout, style, options, {
applyGeneratedLayout,
drawSurface: drawGravitySurface,
providerId: currentSurfaceDocument?.providerId || options.provider?.providerId,
resolveAssetUrl,
});
}
@ -776,36 +773,6 @@ function prefixedProps(props: Record<string, unknown>, prefix: string): Record<s
return result;
}
type GravitySurfaceBody = {
key: string;
kind: string;
x: number;
y: number;
radius: number;
depth: number;
exponent: number;
waveRadius: number;
waveDepth: number;
waveSpeed: number;
icon: string;
iconSize: number;
tint: string;
};
type GravitySurfaceObject = {
key: string;
kind: string;
label: string;
x: number;
y: number;
directionX: number;
directionY: number;
faction: string;
controlled: boolean;
visibility: number;
icon: string;
};
export function projectWorldScene(node: EveSurfaceComponent): EveProjectedWorldEntity[] {
const entities = (node.children || []).filter(child => child.kind === "world.entity3d");
const positions = entities.map(entity => vector3Prop(objectProps(entity.props).position, [0, 0, 0]));
@ -948,906 +915,6 @@ function cssIdentifier(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-");
}
function drawGravitySurface(canvas: HTMLCanvasElement, props: Record<string, unknown>, state?: FieldsSurfaceDataState): void {
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width || canvas.clientWidth || 1));
const height = Math.max(1, Math.floor(rect.height || canvas.clientHeight || 1));
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
const pixelWidth = Math.max(1, Math.floor(width * dpr));
const pixelHeight = Math.max(1, Math.floor(height * dpr));
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const bounds = fitBoundsToAspect(gravityBounds(props), width, height);
const gravityDocument = objectProps(state?.gravity);
const bodies = gravityBodiesFromDocument(state?.gravity) || parseGravityBodies(firstString(props.bodies, ""));
const objects = gravityObjectsFromDocument(state?.objects) || parseGravityObjects(firstString(props.objects, ""));
if (!drawGravityFieldWebGl(ctx, props, gravityDocument, bodies, bounds, width, height, state?.renderSplats)) {
drawFieldLoweringFailure(ctx, props, width, height, "field.surface2d requires WebGL2 float render targets");
}
drawGravityBodies(ctx, bodies, props, bounds, width, height);
drawGravityObjects(ctx, objects, props, bounds, width, height);
}
function drawFieldLoweringFailure(ctx: CanvasRenderingContext2D, props: Record<string, unknown>, width: number, height: number, message: string): void {
ctx.save();
ctx.fillStyle = firstString(props.failureBackground, "rgba(0, 0, 0, 0.72)");
ctx.fillRect(0, 0, width, height);
ctx.font = firstString(props.failureFont, "700 13px Cascadia Mono, Consolas, monospace");
ctx.fillStyle = firstString(props.failureColor, "rgba(255, 184, 79, 0.95)");
ctx.fillText(message, 16, 28);
ctx.restore();
}
type GravityWebGlRenderer = {
canvas: HTMLCanvasElement;
gl: WebGL2RenderingContext;
fieldTexture: WebGLTexture | null;
tintTexture: WebGLTexture | null;
framebuffer: WebGLFramebuffer | null;
tintFramebuffer: WebGLFramebuffer | null;
splatProgram: WebGLProgram;
shadeProgram: WebGLProgram;
splatBuffer: WebGLBuffer;
quadBuffer: WebGLBuffer;
width: number;
height: number;
};
const gravityWebGlRenderers = new WeakMap<HTMLCanvasElement, GravityWebGlRenderer | null>();
const GRAVITY_SPLAT_STRIDE_FLOATS = 20;
function drawGravityFieldWebGl(
ctx: CanvasRenderingContext2D,
props: Record<string, unknown>,
gravityDocument: Record<string, unknown>,
bodies: GravitySurfaceBody[],
bounds: { minX: number; minY: number; maxX: number; maxY: number },
width: number,
height: number,
renderSplatsDocument?: Record<string, unknown>,
): boolean {
const renderer = resolveGravityWebGlRenderer(ctx.canvas as HTMLCanvasElement);
if (!renderer) return false;
const gl = renderer.gl;
if (!ensureGravityWebGlTarget(renderer, width, height)) {
gravityWebGlRenderers.set(ctx.canvas as HTMLCanvasElement, null);
return false;
}
const splatBuffers = buildGravitySplatBuffers(props, gravityDocument, bodies, bounds, renderSplatsDocument);
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.framebuffer);
gl.viewport(0, 0, width, height);
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
if (splatBuffers.gravity.length > 0) {
gl.useProgram(renderer.splatProgram);
gl.uniform4f(gl.getUniformLocation(renderer.splatProgram, "u_viewport"), bounds.minX, bounds.minY, bounds.maxX, bounds.maxY);
gl.uniform1f(gl.getUniformLocation(renderer.splatProgram, "u_time"), numberProp(props.simulationTimeSeconds, 0));
gl.bindBuffer(gl.ARRAY_BUFFER, renderer.splatBuffer);
gl.bufferData(gl.ARRAY_BUFFER, splatBuffers.gravity, gl.DYNAMIC_DRAW);
bindGravitySplatAttributes(gl, renderer.splatProgram);
gl.drawArrays(gl.TRIANGLES, 0, splatBuffers.gravity.length / GRAVITY_SPLAT_STRIDE_FLOATS);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.tintFramebuffer);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
if (splatBuffers.tint.length > 0) {
gl.useProgram(renderer.splatProgram);
gl.uniform4f(gl.getUniformLocation(renderer.splatProgram, "u_viewport"), bounds.minX, bounds.minY, bounds.maxX, bounds.maxY);
gl.uniform1f(gl.getUniformLocation(renderer.splatProgram, "u_time"), numberProp(props.simulationTimeSeconds, 0));
gl.bindBuffer(gl.ARRAY_BUFFER, renderer.splatBuffer);
gl.bufferData(gl.ARRAY_BUFFER, splatBuffers.tint, gl.DYNAMIC_DRAW);
bindGravitySplatAttributes(gl, renderer.splatProgram);
gl.drawArrays(gl.TRIANGLES, 0, splatBuffers.tint.length / GRAVITY_SPLAT_STRIDE_FLOATS);
}
gl.disable(gl.BLEND);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, width, height);
gl.useProgram(renderer.shadeProgram);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, renderer.fieldTexture);
gl.uniform1i(gl.getUniformLocation(renderer.shadeProgram, "u_field"), 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, renderer.tintTexture);
gl.uniform1i(gl.getUniformLocation(renderer.shadeProgram, "u_tint"), 1);
gl.uniform2f(gl.getUniformLocation(renderer.shadeProgram, "u_resolution"), width, height);
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_time"), numberProp(gravityDocument.simulationTimeSeconds, numberProp(props.simulationTimeSeconds, 0)));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineInterval"), numberProp(props.lineInterval, numberProp(props.scalarFieldLineInterval, numberProp(props.depthRange, 228.79605) / 20)));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineOffset"), numberProp(props.lineOffset, numberProp(props.startDepth, 2.310401)));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineWidth"), numberProp(props.lineWidth, 0.3));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_lineFade"), numberProp(props.lineFade, 5));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_angleWidth"), numberProp(props.angleWidth, 0.05));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_angleFade"), numberProp(props.angleFade, 1.15));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_dangerSteepness"), numberProp(props.dangerSteepness, 1.5));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_scale"), numberProp(props.isolineScale, 2.6924083));
uniform3(gl, renderer.shadeProgram, "u_baseColor", vector3Prop(props.scalarFieldBaseColor, [0.002, 0.006, 0.012]));
uniform3(gl, renderer.shadeProgram, "u_fieldGlowColor", vector3Prop(props.scalarFieldGlowColor, [0.018, 0.050, 0.075]));
uniform3(gl, renderer.shadeProgram, "u_lineLowColor", vector3Prop(props.scalarFieldLowLineColor, [0.0, 0.34, 0.52]));
uniform3(gl, renderer.shadeProgram, "u_lineHighColor", vector3Prop(props.scalarFieldHighLineColor, [1.45, 0.30, 0.05]));
uniform3(gl, renderer.shadeProgram, "u_angleLowColor", vector3Prop(props.scalarFieldLowAngleColor, [0.06, 0.16, 0.24]));
uniform3(gl, renderer.shadeProgram, "u_angleHighColor", vector3Prop(props.scalarFieldHighAngleColor, [1.10, 0.24, 0.04]));
gl.uniform1f(gl.getUniformLocation(renderer.shadeProgram, "u_tintScale"), numberProp(props.vectorFieldTintScale, 0.45));
gl.bindBuffer(gl.ARRAY_BUFFER, renderer.quadBuffer);
const position = gl.getAttribLocation(renderer.shadeProgram, "a_position");
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
ctx.drawImage(renderer.canvas, 0, 0, width, height);
return true;
}
function buildGravitySplatsFromDocument(document: Record<string, unknown> | undefined): Float32Array | undefined {
const splats = objectProps(document?.splats);
const layers = Array.isArray(document?.layers) ? document.layers as Array<Record<string, unknown>> : [];
const gravityLayerIndex = layers.findIndex(layer => stringProp(layer?.layerKey, "") === "gravity.height");
if (gravityLayerIndex < 0) return undefined;
const count = Math.max(0, positiveInt(splats.count, 0));
const centerX = numberArray(splats.centerX);
const centerY = numberArray(splats.centerY);
const halfExtentX = numberArray(splats.halfExtentX);
const halfExtentY = numberArray(splats.halfExtentY);
const rotationCos = numberArray(splats.rotationCos);
const rotationSin = numberArray(splats.rotationSin);
const channel = numberArray(splats.channel);
const falloff = numberArray(splats.falloff);
const valueR = numberArray(splats.valueR);
const valueG = numberArray(splats.valueG);
const sourceKind = numberArray(splats.sourceKind);
const frequencyX = numberArray(splats.frequencyX);
const animationSpeed = numberArray(splats.animationSpeed);
const sourceFlags = numberArray(splats.sourceFlags);
const layerIndex = numberArray(splats.layerIndex);
const rows: number[] = [];
const corners = [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1];
for (let index = 0; index < count; index += 1) {
if ((layerIndex[index] ?? -1) !== gravityLayerIndex) continue;
for (let corner = 0; corner < corners.length; corner += 2) {
rows.push(
corners[corner], corners[corner + 1],
centerX[index] ?? 0, centerY[index] ?? 0,
Math.max(0.0001, halfExtentX[index] ?? 1), Math.max(0.0001, halfExtentY[index] ?? 1),
rotationCos[index] ?? 1, rotationSin[index] ?? 0,
channel[index] ?? 1, falloff[index] ?? 0, valueR[index] ?? 0, 0,
sourceKind[index] ?? 0, frequencyX[index] ?? 1, animationSpeed[index] ?? 0, sourceFlags[index] ?? 0,
);
}
}
return rows.length > 0 ? new Float32Array(rows) : undefined;
}
function buildGravitySplatBuffers(
props: Record<string, unknown>,
gravityDocument: Record<string, unknown>,
bodies: GravitySurfaceBody[],
bounds: { minX: number; minY: number; maxX: number; maxY: number },
renderSplatsDocument?: Record<string, unknown>,
): { gravity: Float32Array; tint: Float32Array } {
const gravityRows: number[] = [];
const tintRows: number[] = [];
appendUnityGravitySplats(gravityRows, props, gravityDocument, bodies, bounds);
appendTintSplatsFromDocument(tintRows, props, renderSplatsDocument);
return {
gravity: new Float32Array(gravityRows),
tint: new Float32Array(tintRows),
};
}
function appendUnityGravitySplats(
rows: number[],
props: Record<string, unknown>,
gravityDocument: Record<string, unknown>,
bodies: GravitySurfaceBody[],
bounds: { minX: number; minY: number; maxX: number; maxY: number },
): void {
const terrainRadius = numberProp(gravityDocument.terrainRadius, numberProp(props.terrainRadius, 1200));
const terrainDepth = numberProp(gravityDocument.terrainDepth, numberProp(props.terrainDepth, -8));
if (terrainDepth !== 0) {
appendGravitySplat(
rows,
0,
0,
Math.max(1, terrainRadius * 2),
Math.max(1, terrainRadius * 2),
-terrainDepth,
0,
0,
1,
Math.max(0.0001, numberProp(gravityDocument.terrainDepthExponent, numberProp(props.terrainDepthExponent, 1.2))),
0,
1,
0,
0);
}
for (const body of bodies) {
appendGravitySplat(
rows,
body.x,
body.y,
Math.max(1, body.radius),
Math.max(1, body.radius),
-body.depth,
0,
0,
1,
Math.max(0.0001, body.exponent),
0,
1,
0,
0);
if (body.waveRadius > 0 && body.waveDepth !== 0) {
appendGravitySplat(
rows,
body.x,
body.y,
body.waveRadius,
body.waveRadius,
-body.waveDepth,
0,
0,
1,
8.0,
4,
numberProp(gravityDocument.terrainWaveFrequency, numberProp(props.terrainWaveFrequency, 0.6)),
body.waveSpeed,
0);
}
}
void bounds;
}
function appendTintSplatsFromDocument(rows: number[], props: Record<string, unknown>, document: Record<string, unknown> | undefined): void {
const splats = objectProps(document?.splats);
const layers = Array.isArray(document?.layers) ? document.layers as Array<Record<string, unknown>> : [];
const tintLayerIndices = new Set<number>();
const selectedLayer = firstString(props.vectorFieldLayer, props.tintFieldLayer, "fog.tint");
layers.forEach((layer, index) => {
const layerKey = stringProp(layer?.layerKey, "");
const channel = numberProp(layer?.channel, -1);
if (channel === 4 && layerKey === selectedLayer) tintLayerIndices.add(index);
});
const count = Math.max(0, positiveInt(splats.count, 0));
if (count <= 0 || tintLayerIndices.size === 0) return;
const centerX = numberArray(splats.centerX);
const centerY = numberArray(splats.centerY);
const halfExtentX = numberArray(splats.halfExtentX);
const halfExtentY = numberArray(splats.halfExtentY);
const rotationCos = numberArray(splats.rotationCos);
const rotationSin = numberArray(splats.rotationSin);
const channel = numberArray(splats.channel);
const falloff = numberArray(splats.falloff);
const valueR = numberArray(splats.valueR);
const valueG = numberArray(splats.valueG);
const valueB = numberArray(splats.valueB);
const valueA = numberArray(splats.valueA);
const sourceKind = numberArray(splats.sourceKind);
const frequencyX = numberArray(splats.frequencyX);
const animationSpeed = numberArray(splats.animationSpeed);
const sourceFlags = numberArray(splats.sourceFlags);
const layerIndex = numberArray(splats.layerIndex);
for (let index = 0; index < count; index += 1) {
if (!tintLayerIndices.has(layerIndex[index] ?? -1) || (channel[index] ?? -1) !== 4) continue;
const power = (falloff[index] ?? 0) === 0 ? 0.0001 : 1.25;
appendGravitySplat(
rows,
centerX[index] ?? 0,
centerY[index] ?? 0,
Math.max(0.0001, halfExtentX[index] ?? 1),
Math.max(0.0001, halfExtentY[index] ?? 1),
valueR[index] ?? 0,
valueG[index] ?? 0,
valueB[index] ?? 0,
valueA[index] ?? 1,
power,
sourceKind[index] ?? 0,
frequencyX[index] ?? 1,
animationSpeed[index] ?? 0,
sourceFlags[index] ?? 0,
rotationCos[index] ?? 1,
rotationSin[index] ?? 0);
}
}
function resolveGravityWebGlRenderer(canvas: HTMLCanvasElement): GravityWebGlRenderer | null {
if (gravityWebGlRenderers.has(canvas)) return gravityWebGlRenderers.get(canvas) || null;
const output = document.createElement("canvas");
const gl = output.getContext("webgl2", { alpha: false, antialias: false, premultipliedAlpha: false }) as WebGL2RenderingContext | null;
if (!gl) {
gravityWebGlRenderers.set(canvas, null);
return null;
}
const colorBufferFloat = gl.getExtension("EXT_color_buffer_float");
if (!colorBufferFloat) {
gravityWebGlRenderers.set(canvas, null);
return null;
}
const splatProgram = createGravityProgram(gl, GRAVITY_SPLAT_VERTEX_SHADER, GRAVITY_SPLAT_FRAGMENT_SHADER);
const shadeProgram = createGravityProgram(gl, GRAVITY_SHADE_VERTEX_SHADER, GRAVITY_SHADE_FRAGMENT_SHADER);
const splatBuffer = gl.createBuffer();
const quadBuffer = gl.createBuffer();
if (!splatProgram || !shadeProgram || !splatBuffer || !quadBuffer) {
gravityWebGlRenderers.set(canvas, null);
return null;
}
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, 1, 1,
-1, -1, 1, 1, -1, 1,
]), gl.STATIC_DRAW);
const renderer = {
canvas: output,
gl,
fieldTexture: null,
tintTexture: null,
framebuffer: null,
tintFramebuffer: null,
splatProgram,
shadeProgram,
splatBuffer,
quadBuffer,
width: 0,
height: 0,
};
gravityWebGlRenderers.set(canvas, renderer);
return renderer;
}
function ensureGravityWebGlTarget(renderer: GravityWebGlRenderer, width: number, height: number): boolean {
if (renderer.width === width && renderer.height === height && renderer.fieldTexture && renderer.tintTexture && renderer.framebuffer && renderer.tintFramebuffer) return true;
const gl = renderer.gl;
renderer.width = width;
renderer.height = height;
renderer.canvas.width = width;
renderer.canvas.height = height;
if (renderer.fieldTexture) gl.deleteTexture(renderer.fieldTexture);
if (renderer.tintTexture) gl.deleteTexture(renderer.tintTexture);
if (renderer.framebuffer) gl.deleteFramebuffer(renderer.framebuffer);
if (renderer.tintFramebuffer) gl.deleteFramebuffer(renderer.tintFramebuffer);
renderer.fieldTexture = gl.createTexture();
renderer.tintTexture = gl.createTexture();
renderer.framebuffer = gl.createFramebuffer();
renderer.tintFramebuffer = gl.createFramebuffer();
if (!renderer.fieldTexture || !renderer.tintTexture || !renderer.framebuffer || !renderer.tintFramebuffer) return false;
configureGravityTexture(gl, renderer.fieldTexture, width, height);
configureGravityTexture(gl, renderer.tintTexture, width, height);
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, renderer.fieldTexture, 0);
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) return false;
gl.bindFramebuffer(gl.FRAMEBUFFER, renderer.tintFramebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, renderer.tintTexture, 0);
return gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
}
function configureGravityTexture(gl: WebGL2RenderingContext, texture: WebGLTexture, width: number, height: number): void {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.HALF_FLOAT, null);
}
function buildGravitySplats(
props: Record<string, unknown>,
gravityDocument: Record<string, unknown>,
bodies: GravitySurfaceBody[],
bounds: { minX: number; minY: number; maxX: number; maxY: number },
): Float32Array {
const rows: number[] = [];
void bounds;
const terrainRadius = numberProp(gravityDocument.terrainRadius, numberProp(props.terrainRadius, 1200));
const terrainDepth = numberProp(gravityDocument.terrainDepth, numberProp(props.terrainDepth, -8));
if (terrainDepth !== 0) {
const terrainHalfExtent = Math.max(1, terrainRadius * 2);
appendGravitySplat(rows, 0, 0, terrainHalfExtent, terrainHalfExtent, -terrainDepth, 0, 0, 1, Math.max(0.0001, numberProp(gravityDocument.terrainDepthExponent, numberProp(props.terrainDepthExponent, 1.2))), 0, 1, 0, 0);
}
for (const body of bodies) {
appendGravitySplat(rows, body.x, body.y, Math.max(1, body.radius), Math.max(1, body.radius), -body.depth, 0, 0, 1, Math.max(0.0001, body.exponent), 0, 1, 0, 0);
if (body.waveRadius > 0 && body.waveDepth !== 0) {
appendGravitySplat(rows, body.x, body.y, body.waveRadius, body.waveRadius, -body.waveDepth, 0, 0, 1, 8.0, 4, numberProp(gravityDocument.terrainWaveFrequency, numberProp(props.terrainWaveFrequency, 0.6)), body.waveSpeed, 0);
}
}
return new Float32Array(rows);
}
function appendGravitySplat(
rows: number[],
centerX: number,
centerY: number,
halfX: number,
halfY: number,
valueR: number,
valueG: number,
valueB: number,
valueA: number,
power: number,
sourceKind: number,
frequencyX: number,
animationSpeed: number,
sourceFlags: number,
rotationCos = 1,
rotationSin = 0,
): void {
const corners = [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1];
for (let i = 0; i < corners.length; i += 2) {
rows.push(
corners[i], corners[i + 1],
centerX, centerY,
halfX, halfY,
rotationCos, rotationSin,
power, 0, 0, 0,
sourceKind, frequencyX, animationSpeed, sourceFlags,
valueR, valueG, valueB, valueA,
);
}
}
function bindGravitySplatAttributes(gl: WebGL2RenderingContext, program: WebGLProgram): void {
bindGravitySplatAttribute(gl, program, "a_corner", 2, 0);
bindGravitySplatAttribute(gl, program, "a_center", 2, 2);
bindGravitySplatAttribute(gl, program, "a_half", 2, 4);
bindGravitySplatAttribute(gl, program, "a_rot", 2, 6);
bindGravitySplatAttribute(gl, program, "a_meta", 4, 8);
bindGravitySplatAttribute(gl, program, "a_source", 4, 12);
bindGravitySplatAttribute(gl, program, "a_value", 4, 16);
}
function bindGravitySplatAttribute(gl: WebGL2RenderingContext, program: WebGLProgram, name: string, size: number, offsetFloats: number): void {
const location = gl.getAttribLocation(program, name);
if (location < 0) return;
gl.enableVertexAttribArray(location);
gl.vertexAttribPointer(location, size, gl.FLOAT, false, GRAVITY_SPLAT_STRIDE_FLOATS * 4, offsetFloats * 4);
}
function createGravityProgram(gl: WebGL2RenderingContext, vertexSource: string, fragmentSource: string): WebGLProgram | null {
const vertex = compileGravityShader(gl, gl.VERTEX_SHADER, vertexSource);
const fragment = compileGravityShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
if (!vertex || !fragment) return null;
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null;
return program;
}
function compileGravityShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return null;
return shader;
}
const GRAVITY_SPLAT_VERTEX_SHADER = `#version 300 es
in vec2 a_corner;
in vec2 a_center;
in vec2 a_half;
in vec2 a_rot;
in vec4 a_meta;
in vec4 a_source;
in vec4 a_value;
uniform vec4 u_viewport;
out vec2 v_local;
out vec4 v_meta;
out vec4 v_source;
out vec4 v_value;
void main() {
vec2 scaled = a_corner * a_half;
vec2 world = a_center + vec2(
scaled.x * a_rot.x - scaled.y * a_rot.y,
scaled.x * a_rot.y + scaled.y * a_rot.x
);
vec2 clip = vec2(
((world.x - u_viewport.x) / max(0.0001, u_viewport.z - u_viewport.x)) * 2.0 - 1.0,
((world.y - u_viewport.y) / max(0.0001, u_viewport.w - u_viewport.y)) * 2.0 - 1.0
);
gl_Position = vec4(clip, 0.0, 1.0);
v_local = a_corner;
v_meta = a_meta;
v_source = a_source;
v_value = a_value;
}`;
const GRAVITY_SPLAT_FRAGMENT_SHADER = `#version 300 es
precision highp float;
in vec2 v_local;
in vec4 v_meta;
in vec4 v_source;
in vec4 v_value;
uniform float u_time;
out vec4 outColor;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float valueNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) * 2.0 - 1.0;
}
float powerPulse(float x, float power) {
x = clamp(abs(x), 0.0, 1.0);
return pow((x + 1.0) * (1.0 - x), max(0.0001, power));
}
void main() {
float d = length(v_local);
if (d > 1.0) discard;
float alpha = powerPulse(d, v_meta.x);
if (alpha <= 0.0001) discard;
float source = 1.0;
if (v_source.x > 0.5 && v_source.x < 2.5) {
float timeOffset = v_source.x > 1.5 ? u_time * v_source.z : 0.0;
source = valueNoise(v_local * v_source.y + timeOffset);
if (v_source.w != 0.0) source = abs(source);
} else if (v_source.x > 3.5) {
source = cos(pow(d * 2.0, 1.25) * v_source.y + u_time * v_source.z);
}
outColor = v_value * alpha * source;
}`;
const GRAVITY_SHADE_VERTEX_SHADER = `#version 300 es
in vec2 a_position;
out vec2 v_uv;
void main() {
v_uv = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}`;
const GRAVITY_SHADE_FRAGMENT_SHADER = `#version 300 es
precision highp float;
uniform sampler2D u_field;
uniform sampler2D u_tint;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_lineInterval;
uniform float u_lineOffset;
uniform float u_lineWidth;
uniform float u_lineFade;
uniform float u_angleWidth;
uniform float u_angleFade;
uniform float u_dangerSteepness;
uniform float u_scale;
uniform vec3 u_baseColor;
uniform vec3 u_fieldGlowColor;
uniform vec3 u_lineLowColor;
uniform vec3 u_lineHighColor;
uniform vec3 u_angleLowColor;
uniform vec3 u_angleHighColor;
uniform float u_tintScale;
in vec2 v_uv;
out vec4 outColor;
vec2 calcGrad(vec2 uv, float me) {
vec2 texel = 1.0 / max(vec2(1.0), u_resolution);
float n = -texture(u_field, vec2(uv.x, uv.y + texel.y)).r;
float e = -texture(u_field, vec2(uv.x + texel.x, uv.y)).r;
return vec2(e - me, n - me);
}
void main() {
float height = -texture(u_field, v_uv).r;
vec4 tint = texture(u_tint, v_uv);
vec2 plan = calcGrad(v_uv, height);
float slope = max(length(plan), 0.00001);
vec3 color = u_baseColor;
float fieldGlow = smoothstep(0.0, u_lineInterval * 7.0, abs(height));
color += u_fieldGlowColor * fieldGlow;
color += tint.rgb * clamp(tint.a, 0.0, 1.0) * u_tintScale;
float dangerBlend = smoothstep(0.0, u_dangerSteepness, pow(slope / max(0.0001, u_scale), 2.0));
vec3 baseLine = mix(u_lineLowColor, u_lineHighColor, dangerBlend);
vec3 angleLine = mix(u_angleLowColor, u_angleHighColor, dangerBlend) * clamp(slope / max(0.0001, u_scale), 0.0, 1.0);
vec3 lines = vec3(0.0);
float interval = max(0.0001, abs(u_lineInterval));
float nearestLine = abs(fract((height + u_lineOffset) / interval) - 0.5) * interval;
float lineDistance = nearestLine / slope;
float line = 1.0 - smoothstep(u_lineWidth, u_lineWidth * u_lineFade, lineDistance);
lines += line * baseLine;
float angle = atan(plan.y, plan.x) / 3.1415926536 + 1.0;
float nearestAngle = abs(fract(angle * 6.0) - 0.5) / 6.0;
float angleMask = smoothstep(interval * 0.25, interval * 1.5, abs(height));
float angleLineAmount = 1.0 - smoothstep(u_angleWidth, u_angleWidth * u_angleFade, nearestAngle);
lines += angleLineAmount * angleMask * angleLine;
color += clamp(lines, 0.0, 1.7);
outColor = vec4(color, 1.0);
}`;
function drawGravityBodies(
ctx: CanvasRenderingContext2D,
bodies: GravitySurfaceBody[],
props: Record<string, unknown>,
bounds: { minX: number; minY: number; maxX: number; maxY: number },
width: number,
height: number,
): void {
for (const body of bodies) {
const screen = worldToSurface(bounds, body.x, body.y, width, height);
const visualRadius = Math.max(18, Math.min(190, body.radius / Math.max(1, bounds.maxX - bounds.minX) * width * 0.32));
const icon = resolveAssetUrl(body.icon);
const fallbackIconSize = body.kind.toLowerCase().includes("sun")
? Math.max(numberProp(props.sunIconMinPx, 34), visualRadius * numberProp(props.sunIconScale, 0.72))
: Math.max(numberProp(props.bodyIconMinPx, 24), visualRadius * numberProp(props.bodyIconScale, 0.48));
const iconSize = body.iconSize > 0 ? body.iconSize : fallbackIconSize;
if (icon) drawGravityAsset(ctx, icon, screen.x, screen.y, iconSize, 0.92);
if (body.key) {
drawGravityLabel(ctx, props, body.key.replace(/^local\./, ""), screen.x + visualRadius * 0.44, screen.y - visualRadius * 0.48, firstString(props.bodyLabelColor, "rgba(226, 244, 255, 0.82)"));
}
}
}
const gravityImageCache = new Map<string, HTMLImageElement>();
function drawGravityAsset(ctx: CanvasRenderingContext2D, src: string, x: number, y: number, size: number, alpha: number): void {
let image = gravityImageCache.get(src);
if (!image) {
image = new Image();
image.decoding = "async";
image.onload = () => requestAnimationFrame(() => {
const canvas = ctx.canvas;
canvas.dispatchEvent(new CustomEvent("cultui:asset-loaded"));
});
image.src = src;
gravityImageCache.set(src, image);
}
if (!image.complete || image.naturalWidth === 0) return;
ctx.save();
ctx.globalAlpha = alpha;
ctx.drawImage(image, x - size / 2, y - size / 2, size, size);
ctx.restore();
}
function drawGravityObjects(
ctx: CanvasRenderingContext2D,
objects: GravitySurfaceObject[],
props: Record<string, unknown>,
bounds: { minX: number; minY: number; maxX: number; maxY: number },
width: number,
height: number,
): void {
for (const obj of objects) {
const screen = worldToSurface(bounds, obj.x, obj.y, width, height);
if (screen.x < -40 || screen.x > width + 40 || screen.y < -40 || screen.y > height + 40) continue;
const size = obj.kind.toLowerCase().includes("station")
? numberProp(props.stationIconSizePx, 34)
: obj.controlled ? numberProp(props.shipIconSizePx, 22) : numberProp(props.remoteShipIconSizePx, 18);
const color = factionColor(props, obj.faction, obj.controlled, obj.visibility);
const icon = resolveAssetUrl(obj.icon);
ctx.save();
ctx.globalAlpha = Math.max(0.25, Math.min(1, obj.visibility));
ctx.translate(screen.x, screen.y);
const angle = Math.atan2(obj.directionY, obj.directionX) + Math.PI / 2;
if (obj.kind.toLowerCase().includes("ship")) ctx.rotate(angle);
if (icon) {
const image = gravityImageCache.get(icon);
if (!image) {
const loading = new Image();
loading.decoding = "async";
loading.onload = () => ctx.canvas.dispatchEvent(new CustomEvent("cultui:asset-loaded"));
loading.src = icon;
gravityImageCache.set(icon, loading);
} else if (image.complete && image.naturalWidth > 0) {
ctx.drawImage(image, -size / 2, -size / 2, size, size);
}
}
ctx.restore();
drawGravityLabel(ctx, props, obj.label, screen.x + size * 0.55, screen.y - size * 0.45, color);
}
}
function drawGravityLabel(ctx: CanvasRenderingContext2D, props: Record<string, unknown>, label: string, x: number, y: number, color: string): void {
if (!label) return;
ctx.save();
ctx.font = firstString(props.objectLabelFont, "700 12px Ubuntu, system-ui, sans-serif");
ctx.textBaseline = "middle";
ctx.lineWidth = numberProp(props.objectLabelStrokeWidth, 3);
ctx.strokeStyle = firstString(props.objectLabelStroke, "rgba(0, 0, 0, 0.72)");
ctx.fillStyle = color;
ctx.strokeText(label, x, y);
ctx.fillText(label, x, y);
ctx.restore();
}
function factionColor(props: Record<string, unknown>, faction: string, controlled: boolean, visibility: number): string {
const alpha = Math.max(0.25, Math.min(1, visibility));
if (controlled) return colorTemplate(firstString(props.objectControlledColor, "rgba(122, 240, 255, {alpha})"), alpha);
const normalized = faction.toLowerCase();
if (normalized.includes("raider")) return colorTemplate(firstString(props.objectRaiderColor, "rgba(255, 143, 74, {alpha})"), alpha);
if (normalized.includes("neutral")) return colorTemplate(firstString(props.objectNeutralColor, "rgba(232, 232, 224, {alpha})"), alpha);
return colorTemplate(firstString(props.objectDefaultColor, "rgba(214, 244, 255, {alpha})"), alpha);
}
function colorTemplate(template: string, alpha: number): string {
return template.replaceAll("{alpha}", String(alpha));
}
function parseGravityBodies(value: string): GravitySurfaceBody[] {
return value
.split(";")
.map(entry => entry.trim())
.filter(Boolean)
.map(entry => {
const parts = entry.split("|");
return {
key: parts[0] ?? "",
kind: parts[1] ?? "",
x: Number(parts[2]) || 0,
y: Number(parts[3]) || 0,
radius: Math.max(1, Number(parts[4]) || 1),
depth: Number(parts[5]) || 0,
exponent: Number(parts[6]) || 1,
waveRadius: Number(parts[7]) || 0,
waveDepth: Number(parts[8]) || 0,
waveSpeed: Number(parts[9]) || 0,
icon: parts[10] ?? "",
iconSize: Number(parts[12]) || 0,
tint: parts[11] ?? "",
};
});
}
function parseGravityObjects(value: string): GravitySurfaceObject[] {
return value
.split(";")
.map(entry => entry.trim())
.filter(Boolean)
.map(entry => {
const parts = entry.split("|");
return {
key: parts[0] ?? "",
kind: parts[1] ?? "",
label: parts[2] ?? "",
x: Number(parts[3]) || 0,
y: Number(parts[4]) || 0,
directionX: Number(parts[5]) || 0,
directionY: Number(parts[6]) || 1,
faction: parts[7] ?? "",
controlled: parts[8] === "1" || parts[8] === "true",
visibility: Number(parts[9]) || 1,
icon: parts[10] ?? "",
};
});
}
function gravityBodiesFromDocument(document: Record<string, unknown> | undefined): GravitySurfaceBody[] | undefined {
const influences = Array.isArray(document?.gravityInfluences) ? document.gravityInfluences as Array<Record<string, unknown>> : [];
const bodies = Array.isArray(document?.bodies) ? document.bodies as Array<Record<string, unknown>> : [];
if (influences.length === 0 && bodies.length === 0) return undefined;
const displayBodiesByKey = new Map<string, Record<string, unknown>>();
for (const body of bodies) {
const key = firstString(body.bodyKey, body.name, "");
if (key) displayBodiesByKey.set(key, body);
}
return (influences.length > 0 ? influences : bodies).map((body) => {
const key = firstString(body.bodyKey, body.name, "body");
const displayBody = displayBodiesByKey.get(key);
const kind = firstString(body.kind, "Body");
return {
key,
kind,
x: numberProp(body.x, 0),
y: numberProp(body.y, 0),
radius: Math.max(1, numberProp(body.radius, 80)),
depth: numberProp(body.gravityDepth, 0),
exponent: numberProp(body.gravityDepthExponent, 1),
waveRadius: numberProp(body.waveRadius, 0),
waveDepth: numberProp(body.waveDepth, 0),
waveSpeed: numberProp(body.waveSpeed, 0),
icon: assetUriFromRef((body as Record<string, unknown>).iconAsset) || assetUriFromRef(displayBody?.iconAsset),
iconSize: numberProp(displayBody?.iconSize, numberProp((body as Record<string, unknown>).iconSize, 0)),
tint: "",
};
});
}
function gravityObjectsFromDocument(document: Record<string, unknown> | undefined): GravitySurfaceObject[] | undefined {
const objects = Array.isArray(document?.objects) ? document.objects as Array<Record<string, unknown>> : [];
if (objects.length === 0) return undefined;
return objects.map((obj) => {
const kind = firstString(obj.kind, "object");
return {
key: firstString(obj.entityKey, String(obj.entityIndex ?? ""), "object"),
kind,
label: firstString(obj.displayName, obj.entityKey, "object"),
x: numberProp(obj.x, 0),
y: numberProp(obj.y, 0),
directionX: numberProp(obj.directionX, 1),
directionY: numberProp(obj.directionY, 0),
faction: firstString(obj.factionKey, ""),
controlled: boolProp(obj.controlled),
visibility: numberProp(obj.visibility, 1),
icon: assetUriFromRef(obj.iconAsset),
};
});
}
function assetUriFromRef(value: unknown): string {
const asset = objectProps(value);
const uri = firstString(asset.uri, "");
return uri;
}
function gravityBounds(props: Record<string, unknown>): { minX: number; minY: number; maxX: number; maxY: number } {
const radius = Math.max(1, numberProp(props.viewRadius, Math.max(1200, numberProp(props.terrainRadius, 1200))));
return {
minX: numberProp(props.minX, -radius),
minY: numberProp(props.minY, -radius),
maxX: numberProp(props.maxX, radius),
maxY: numberProp(props.maxY, radius),
};
}
function fitBoundsToAspect(
bounds: { minX: number; minY: number; maxX: number; maxY: number },
width: number,
height: number,
): { minX: number; minY: number; maxX: number; maxY: number } {
const worldWidth = Math.max(0.0001, bounds.maxX - bounds.minX);
const worldHeight = Math.max(0.0001, bounds.maxY - bounds.minY);
const canvasAspect = Math.max(0.0001, width / Math.max(1, height));
const worldAspect = worldWidth / worldHeight;
const centerX = (bounds.minX + bounds.maxX) * 0.5;
const centerY = (bounds.minY + bounds.maxY) * 0.5;
if (canvasAspect > worldAspect) {
const fittedWidth = worldHeight * canvasAspect;
return {
minX: centerX - fittedWidth * 0.5,
maxX: centerX + fittedWidth * 0.5,
minY: bounds.minY,
maxY: bounds.maxY,
};
}
const fittedHeight = worldWidth / canvasAspect;
return {
minX: bounds.minX,
maxX: bounds.maxX,
minY: centerY - fittedHeight * 0.5,
maxY: centerY + fittedHeight * 0.5,
};
}
function worldToSurface(
bounds: { minX: number; minY: number; maxX: number; maxY: number },
x: number,
y: number,
width: number,
height: number,
): { x: number; y: number } {
return {
x: (x - bounds.minX) / Math.max(1, bounds.maxX - bounds.minX) * width,
y: (y - bounds.minY) / Math.max(1, bounds.maxY - bounds.minY) * height,
};
}
function numberProp(value: unknown, fallback: number): number {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;

View file

@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createEveCommandIntent, createWorldActionIntent, normalizeFieldsDocument, projectWorldScene, resolveRequiredPluginAdapters, selectAdvertisedSurface } from "../dist/index.js";
@ -135,3 +136,8 @@ test("required plugin advertisements resolve runtime adapters and report capabil
/lacks required capabilities: gravity\.surface/,
);
});
test("the generic component lowerer contains no fields plugin authority", () => {
const source = readFileSync(new URL("../src/index.ts", import.meta.url), "utf8");
assert.doesNotMatch(source, /gamecult\.fields|field\.surface2d|gravity\.surface|drawGravity|renderSplats/);
});