mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Add the opt-in Cyber HUD layout with coordinated map and cockpit panel styling, GPU-only contact sonar (rings, range, power, opacity, sector) with native-rendering fallback, and set_cyber_sonar voice controls. Sonar visual style inspired by kk376's tactical naval sonar (#214). Co-authored-by: Sameh Khamis <[email protected]> Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
296 lines
11 KiB
JavaScript
296 lines
11 KiB
JavaScript
import * as Cesium from 'cesium';
|
|
|
|
// Narrow, version-tested engine adapter. Cesium has no public custom shader
|
|
// option for native point/billboard collections. Do not patch its prototypes or
|
|
// access EntityCluster's private collections. An engine upgrade must revalidate
|
|
// this adapter; unsupported versions keep the original commands unchanged.
|
|
export const CYBER_SONAR_CESIUM_VERSION = '1.138.0';
|
|
|
|
// Follow every public DrawCommand setter, including pick/shadow/culling flags.
|
|
// Copying native.dirty would keep derived pipelines dirty forever: the scene
|
|
// consumes our command, so it never clears that flag on the native command.
|
|
const commandProperties = Object.entries(
|
|
Object.getOwnPropertyDescriptors(Cesium.DrawCommand.prototype),
|
|
)
|
|
.filter(
|
|
([key, descriptor]) =>
|
|
descriptor.set && key !== 'shaderProgram' && key !== 'uniformMap',
|
|
)
|
|
.map(([key]) => key);
|
|
|
|
export function syncCyberSonarCommand(
|
|
native,
|
|
record,
|
|
program,
|
|
uniforms,
|
|
labelBackground,
|
|
) {
|
|
if (!record.command) {
|
|
record.command = new Cesium.DrawCommand();
|
|
record.uniformMap = {
|
|
...uniforms,
|
|
u_gevSonarLabelBackground: () => record.labelBackground,
|
|
};
|
|
}
|
|
const command = record.command;
|
|
record.labelBackground = labelBackground;
|
|
// Uniform values (angle/viewport/etc.) change through stable callbacks, not
|
|
// new maps/functions. Owner callback replacement and removal still propagate.
|
|
for (const key in record.uniformMap) {
|
|
if (
|
|
!(key in uniforms) &&
|
|
key !== 'u_gevSonarLabelBackground' &&
|
|
!(key in native.uniformMap)
|
|
) {
|
|
delete record.uniformMap[key];
|
|
command.dirty = true;
|
|
}
|
|
}
|
|
for (const key in native.uniformMap) {
|
|
if (record.uniformMap[key] !== native.uniformMap[key]) {
|
|
record.uniformMap[key] = native.uniformMap[key];
|
|
command.dirty = true;
|
|
}
|
|
}
|
|
for (const key of commandProperties) command[key] = native[key];
|
|
command.shaderProgram = program;
|
|
command.uniformMap = record.uniformMap;
|
|
return command;
|
|
}
|
|
|
|
export function sonarVertexSource(source, kind) {
|
|
const text = source.sources.join('\n');
|
|
const attribute =
|
|
kind === 'point' ? 'positionHighAndSize' : 'positionHighAndScale';
|
|
const lowAttribute =
|
|
kind === 'point' ? 'positionLowAndOutline' : 'positionLowAndRotation';
|
|
if (
|
|
!text.includes(attribute) ||
|
|
!text.includes(lowAttribute) ||
|
|
!text.includes('v_color') ||
|
|
!/void\s+main\s*\(\s*\)/.test(text)
|
|
)
|
|
return null;
|
|
const next = source.clone();
|
|
next.sources = [
|
|
text.replace(/void\s+main\s*\(\s*\)/, 'void gev_sonar_main()'),
|
|
`
|
|
uniform vec4 u_gevSonar; // angle, radius, opacity floor, sweep enabled
|
|
uniform vec2 u_gevSonarViewport;
|
|
uniform float u_gevSonarSector;
|
|
uniform float u_gevSonarLabelBackground;
|
|
void main() {
|
|
gev_sonar_main();
|
|
// All corners/glyphs share the contact anchor, before native screen offsets.
|
|
// Leave gl_Position untouched so appearance never changes geometry or picks.
|
|
vec4 anchor = czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz);
|
|
if (anchor.w <= 0.0) return;
|
|
vec2 pixel = anchor.xy / anchor.w * u_gevSonarViewport * 0.5;
|
|
float floorValue = u_gevSonar.z;
|
|
float factor = floorValue + (1.0 - floorValue) * 0.46;
|
|
if (u_gevSonar.w > 0.5) {
|
|
float distanceSquared = dot(pixel, pixel);
|
|
float radiusSquared = u_gevSonar.y * u_gevSonar.y;
|
|
float delta = mod(degrees(atan(pixel.x, pixel.y)) - u_gevSonar.x + 360.0, 360.0);
|
|
if (delta > 359.9999) delta = 0.0;
|
|
factor = floorValue;
|
|
if (distanceSquared < radiusSquared * 0.000625) factor = 1.0;
|
|
else if (distanceSquared <= radiusSquared && delta <= u_gevSonarSector)
|
|
factor += (1.0 - floorValue) * min(1.0, (u_gevSonarSector - delta) / min(10.0, u_gevSonarSector * 0.42));
|
|
}
|
|
#ifdef SDF
|
|
factor = (1.0 + factor) * 0.5;
|
|
#else
|
|
if (u_gevSonarLabelBackground > 0.5) factor = (1.0 + factor) * 0.5;
|
|
#endif
|
|
float coverage = floorValue >= 0.9999 ? 1.0 : clamp((factor - floorValue) / (1.0 - floorValue), 0.0, 1.0);
|
|
if (u_gevSonarLabelBackground < 0.5)
|
|
v_color.rgb = v_color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72;
|
|
// Apply before native fragment pass classification, never after main().
|
|
v_color.a *= factor;
|
|
${kind === 'point' ? 'v_outlineColor.a *= factor;' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
|
|
}
|
|
`,
|
|
];
|
|
return next;
|
|
}
|
|
|
|
// Use native batch bounds, never individual contacts, for idle render demand.
|
|
// Like Cesium's frustum builder, exclude the far plane (it fits multiple frusta).
|
|
export function isCyberSonarCommandVisible(command, frameState, culling) {
|
|
const bounds = command.boundingVolume;
|
|
if (!bounds || !command.cull) return true;
|
|
culling ??= new Cesium.CullingVolume(
|
|
frameState.cullingVolume.planes.slice(0, 5),
|
|
);
|
|
if (culling.computeVisibility(bounds) === Cesium.Intersect.OUTSIDE)
|
|
return false;
|
|
return !(
|
|
frameState.mode === Cesium.SceneMode.SCENE3D &&
|
|
frameState.occluder &&
|
|
command.occlude &&
|
|
bounds.isOccluded(frameState.occluder)
|
|
);
|
|
}
|
|
|
|
/** Work scales with draw commands, not the number of contacts in a collection.
|
|
* Source programs, colors, pick IDs, render states and other scenes stay owned
|
|
* by Cesium. Only this scene's rendered command slice receives derived shaders.
|
|
*/
|
|
export function createCyberSonarGpu(scene, readFrame) {
|
|
const root = scene.primitives;
|
|
const original = root.update;
|
|
const programs = new Map();
|
|
let commands = new WeakMap();
|
|
let disposed = false;
|
|
const stats = {
|
|
commands: 0,
|
|
visibleCommands: 0,
|
|
programs: 0,
|
|
supported: Cesium.VERSION === CYBER_SONAR_CESIUM_VERSION,
|
|
error: null,
|
|
};
|
|
const uniforms = new Cesium.Cartesian4();
|
|
const viewport = new Cesium.Cartesian2();
|
|
let sector = 24;
|
|
const sonarUniforms = {
|
|
u_gevSonar: () => uniforms,
|
|
u_gevSonarViewport: () => viewport,
|
|
u_gevSonarSector: () => sector,
|
|
};
|
|
const culling = new Cesium.CullingVolume();
|
|
const used = new Set();
|
|
const replaced = [];
|
|
const release = () => {
|
|
for (const { program } of programs.values())
|
|
if (!program.isDestroyed()) program.destroy();
|
|
programs.clear();
|
|
commands = new WeakMap();
|
|
used.clear();
|
|
replaced.length = 0;
|
|
stats.programs = 0;
|
|
};
|
|
const wrapped = function (frameState) {
|
|
const start = frameState.commandList.length;
|
|
original.call(this, frameState);
|
|
stats.commands = 0;
|
|
stats.visibleCommands = 0;
|
|
if (
|
|
disposed ||
|
|
!stats.supported ||
|
|
!frameState.passes.render ||
|
|
frameState.passes.pick
|
|
)
|
|
return;
|
|
const frame = readFrame();
|
|
if (!frame.enabled) {
|
|
if (programs.size) release();
|
|
return;
|
|
}
|
|
viewport.x = scene.drawingBufferWidth;
|
|
viewport.y = scene.drawingBufferHeight;
|
|
uniforms.x = frame.angle;
|
|
uniforms.y =
|
|
(Math.min(viewport.x * 0.4, viewport.y * 0.52) * frame.range) / 100;
|
|
uniforms.z = frame.opacity / 100;
|
|
uniforms.w = frame.active ? 1 : 0;
|
|
sector = frame.sector;
|
|
used.clear();
|
|
replaced.length = 0;
|
|
if (frameState.cullingVolume)
|
|
for (let i = 0; i < 5; i++)
|
|
culling.planes[i] = frameState.cullingVolume.planes[i];
|
|
try {
|
|
for (let i = start; i < frameState.commandList.length; i++) {
|
|
const native = frameState.commandList[i];
|
|
const kind =
|
|
native.owner instanceof Cesium.PointPrimitiveCollection
|
|
? 'point'
|
|
: native.owner instanceof Cesium.BillboardCollection
|
|
? 'billboard'
|
|
: null;
|
|
if (!kind || !native.shaderProgram || !native.uniformMap) continue;
|
|
const source = native.shaderProgram;
|
|
// Label backgrounds are non-SDF billboard batches. The version-tested
|
|
// engine pick association distinguishes them without enumerating labels
|
|
// or reaching into EntityCluster. One representative per draw batch;
|
|
// Cesium's label glyph/background collections are homogeneous.
|
|
const labelBackground =
|
|
kind === 'billboard' &&
|
|
!source.vertexShaderSource.defines.includes('SDF') &&
|
|
native.owner.length > 0 &&
|
|
native.owner.get(0).pickPrimitive instanceof Cesium.Label
|
|
? 1
|
|
: 0;
|
|
let entry = programs.get(source);
|
|
if (!entry) {
|
|
const vertex = sonarVertexSource(source.vertexShaderSource, kind);
|
|
if (!vertex) throw new Error('Unsupported native contact shader');
|
|
const attributes = Object.fromEntries(
|
|
Object.entries(source.vertexAttributes).map(([name, value]) => [
|
|
name,
|
|
value.index,
|
|
]),
|
|
);
|
|
const program = Cesium.ShaderProgram.fromCache({
|
|
context: frameState.context,
|
|
vertexShaderSource: vertex,
|
|
fragmentShaderSource: source.fragmentShaderSource,
|
|
attributeLocations: attributes,
|
|
});
|
|
// Compile while errors can still leave the whole native command slice
|
|
// intact, rather than stopping the viewer's render loop at draw time.
|
|
try {
|
|
void program.allUniforms;
|
|
} catch (error) {
|
|
program.destroy();
|
|
throw error;
|
|
}
|
|
programs.set(source, (entry = { program }));
|
|
}
|
|
used.add(source);
|
|
let record = commands.get(native);
|
|
if (!record) commands.set(native, (record = {}));
|
|
const derived = syncCyberSonarCommand(
|
|
native,
|
|
record,
|
|
entry.program,
|
|
sonarUniforms,
|
|
labelBackground,
|
|
);
|
|
replaced.push(i, native);
|
|
frameState.commandList[i] = derived;
|
|
stats.commands++;
|
|
if (isCyberSonarCommandVisible(native, frameState, culling))
|
|
stats.visibleCommands++;
|
|
}
|
|
for (const [source, entry] of programs)
|
|
if (!used.has(source)) {
|
|
if (!entry.program.isDestroyed()) entry.program.destroy();
|
|
programs.delete(source);
|
|
}
|
|
stats.programs = programs.size;
|
|
} catch (error) {
|
|
// Recover the original commands already replaced in this slice.
|
|
for (let i = 0; i < replaced.length; i += 2)
|
|
frameState.commandList[replaced[i]] = replaced[i + 1];
|
|
stats.supported = false;
|
|
stats.commands = 0;
|
|
stats.visibleCommands = 0;
|
|
stats.error = String(error?.message || error);
|
|
release();
|
|
}
|
|
};
|
|
root.update = wrapped;
|
|
return {
|
|
stats,
|
|
destroy() {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
if (root.update === wrapped && !root.isDestroyed?.())
|
|
root.update = original;
|
|
release();
|
|
},
|
|
};
|
|
}
|