Move browser field rendering behind plugin adapter
This commit is contained in:
parent
af0e44cfc3
commit
16189638d2
10 changed files with 1826 additions and 1685 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
21
packages/eve-browser-lowering/dist/fields-webgl-renderer.d.ts
vendored
Normal file
21
packages/eve-browser-lowering/dist/fields-webgl-renderer.d.ts
vendored
Normal 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;
|
||||
500
packages/eve-browser-lowering/dist/fields-webgl-renderer.js
vendored
Normal file
500
packages/eve-browser-lowering/dist/fields-webgl-renderer.js
vendored
Normal 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);
|
||||
}`;
|
||||
741
packages/eve-browser-lowering/dist/index.js
vendored
741
packages/eve-browser-lowering/dist/index.js
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
656
packages/eve-browser-lowering/src/fields-webgl-renderer.ts
Normal file
656
packages/eve-browser-lowering/src/fields-webgl-renderer.ts
Normal 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);
|
||||
}`;
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue