diff --git a/docs/eve-migration-roadmap.md b/docs/eve-migration-roadmap.md index ea07f3f..534f0fb 100644 --- a/docs/eve-migration-roadmap.md +++ b/docs/eve-migration-roadmap.md @@ -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 diff --git a/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts b/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts index 5d102da..a25d7de 100644 --- a/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts +++ b/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts @@ -44,7 +44,7 @@ export interface FieldsSurfaceDataState { } export interface FieldsRuntimeContext { applyGeneratedLayout(element: HTMLElement, layout: Record, style: Record): void; - drawSurface(canvas: HTMLCanvasElement, props: Record, state: FieldsSurfaceDataState): void; providerId?: string; + resolveAssetUrl(uri: string): string; } export declare function normalizeFieldsDocument(schemaId: string | undefined, value: unknown): unknown; diff --git a/packages/eve-browser-lowering/dist/fields-browser-adapter.js b/packages/eve-browser-lowering/dist/fields-browser-adapter.js index 7c4d136..756a613 100644 --- a/packages/eve-browser-lowering/dist/fields-browser-adapter.js +++ b/packages/eve-browser-lowering/dist/fields-browser-adapter.js @@ -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()); +} diff --git a/packages/eve-browser-lowering/dist/fields-webgl-renderer.d.ts b/packages/eve-browser-lowering/dist/fields-webgl-renderer.d.ts new file mode 100644 index 0000000..2789cd6 --- /dev/null +++ b/packages/eve-browser-lowering/dist/fields-webgl-renderer.d.ts @@ -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, gravityDocument: Record, bodies: GravitySurfaceBody[], bounds: { + minX: number; + minY: number; + maxX: number; + maxY: number; +}, width: number, height: number, renderSplatsDocument?: Record): boolean; diff --git a/packages/eve-browser-lowering/dist/fields-webgl-renderer.js b/packages/eve-browser-lowering/dist/fields-webgl-renderer.js new file mode 100644 index 0000000..823d92a --- /dev/null +++ b/packages/eve-browser-lowering/dist/fields-webgl-renderer.js @@ -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); +}`; diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 5a55cc1..ea85e97 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -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; diff --git a/packages/eve-browser-lowering/src/fields-browser-adapter.ts b/packages/eve-browser-lowering/src/fields-browser-adapter.ts index f14e451..0940a6f 100644 --- a/packages/eve-browser-lowering/src/fields-browser-adapter.ts +++ b/packages/eve-browser-lowering/src/fields-browser-adapter.ts @@ -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, style: Record): void; - drawSurface(canvas: HTMLCanvasElement, props: Record, 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, + 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, 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, + 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(); + +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, + 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, 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, 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 | undefined): GravitySurfaceBody[] | undefined { + const influences = Array.isArray(document?.gravityInfluences) ? document.gravityInfluences as Array> : []; + const bodies = Array.isArray(document?.bodies) ? document.bodies as Array> : []; + 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 as Record).iconAsset) || assetUriFromRef(displayBody?.iconAsset), + iconSize: numberProp(displayBody?.iconSize, numberProp((body as Record).iconSize, 0)), + tint: "", + }; + }); +} + +function gravityObjectsFromDocument(document: Record | undefined): GravitySurfaceObject[] | undefined { + const objects = Array.isArray(document?.objects) ? document.objects as Array> : []; + 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): { 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 { 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()); +} diff --git a/packages/eve-browser-lowering/src/fields-webgl-renderer.ts b/packages/eve-browser-lowering/src/fields-webgl-renderer.ts new file mode 100644 index 0000000..21e8115 --- /dev/null +++ b/packages/eve-browser-lowering/src/fields-webgl-renderer.ts @@ -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(); +const GRAVITY_SPLAT_STRIDE_FLOATS = 20; + +export function drawGravityFieldWebGl( + ctx: CanvasRenderingContext2D, + props: Record, + gravityDocument: Record, + bodies: GravitySurfaceBody[], + bounds: { minX: number; minY: number; maxX: number; maxY: number }, + width: number, + height: number, + renderSplatsDocument?: Record, +): 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 | undefined): Float32Array | undefined { + const splats = objectProps(document?.splats); + const layers = Array.isArray(document?.layers) ? document.layers as Array> : []; + 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, + gravityDocument: Record, + bodies: GravitySurfaceBody[], + bounds: { minX: number; minY: number; maxX: number; maxY: number }, + renderSplatsDocument?: Record, +): { 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, + gravityDocument: Record, + 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, document: Record | undefined): void { + const splats = objectProps(document?.splats); + const layers = Array.isArray(document?.layers) ? document.layers as Array> : []; + 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: 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, + gravityDocument: Record, + 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 { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +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); +}`; + diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index d2568fa..34a2060 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -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, prefix: string): Record 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, 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, 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(); -const GRAVITY_SPLAT_STRIDE_FLOATS = 20; - -function drawGravityFieldWebGl( - ctx: CanvasRenderingContext2D, - props: Record, - gravityDocument: Record, - bodies: GravitySurfaceBody[], - bounds: { minX: number; minY: number; maxX: number; maxY: number }, - width: number, - height: number, - renderSplatsDocument?: Record, -): 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 | undefined): Float32Array | undefined { - const splats = objectProps(document?.splats); - const layers = Array.isArray(document?.layers) ? document.layers as Array> : []; - 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, - gravityDocument: Record, - bodies: GravitySurfaceBody[], - bounds: { minX: number; minY: number; maxX: number; maxY: number }, - renderSplatsDocument?: Record, -): { 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, - gravityDocument: Record, - 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, document: Record | undefined): void { - const splats = objectProps(document?.splats); - const layers = Array.isArray(document?.layers) ? document.layers as Array> : []; - 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: 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, - gravityDocument: Record, - 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, - 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(); - -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, - 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, 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, 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 | undefined): GravitySurfaceBody[] | undefined { - const influences = Array.isArray(document?.gravityInfluences) ? document.gravityInfluences as Array> : []; - const bodies = Array.isArray(document?.bodies) ? document.bodies as Array> : []; - 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 as Record).iconAsset) || assetUriFromRef(displayBody?.iconAsset), - iconSize: numberProp(displayBody?.iconSize, numberProp((body as Record).iconSize, 0)), - tint: "", - }; - }); -} - -function gravityObjectsFromDocument(document: Record | undefined): GravitySurfaceObject[] | undefined { - const objects = Array.isArray(document?.objects) ? document.objects as Array> : []; - 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): { 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; diff --git a/packages/eve-browser-lowering/test/browser-lowering.test.mjs b/packages/eve-browser-lowering/test/browser-lowering.test.mjs index e59de07..d7845ef 100644 --- a/packages/eve-browser-lowering/test/browser-lowering.test.mjs +++ b/packages/eve-browser-lowering/test/browser-lowering.test.mjs @@ -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/); +});