mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Settle leases, styles, credits and controls in the renderer contract
- claimPointer takes a grab filter, asked on each down: a lease takes
only the presses it grabs, and wheel, pinch and every other press stay
with the camera and everyone else. Each event of a press goes where its
down went, per button, so a claim never takes a press under way.
- Core owns the styles: effects.defineStyle passes a GLSL (and optionally
WGSL) full-screen pass, with the shader interface written out.
- Credits are plain data ({ text, href, logoUrl, inline }) that Core
renders; CreateOptions.creditContainer goes.
- controls.coast { drag, zoom } in seconds replaces the inertia trio; the
stub's defaults are the default renderer's, converted.
- The frame order is the default renderer's own: a move made by follow or
in preUpdate reaches changed on the next frame.
This commit is contained in:
1 parent
14f06df735
commit
e9c0dc5405
12 files changed
+591
-34
No files matched your search
@@ -53,7 +53,11 @@ The rules that hold everywhere:
|
|||||||
listeners, and the frame, still run.
|
listeners, and the frame, still run.
|
||||||
- **The frame order is fixed:** camera flight → input → camera `changed` →
|
- **The frame order is fixed:** camera flight → input → camera `changed` →
|
||||||
follow → `preUpdate` → move detection → `preRender` → render →
|
follow → `preUpdate` → move detection → `preRender` → render →
|
||||||
`postRender`.
|
`postRender`, the default renderer's own. A camera move made by follow or
|
||||||
|
in `preUpdate` is reported by `changed` on the next frame.
|
||||||
|
- **Leases:** a tool takes the pointer with `claimPointer`. With `grab`, it
|
||||||
|
takes only the presses it grabs on `down`; the rest, and wheel and pinch,
|
||||||
|
stay with the camera and everyone else.
|
||||||
|
|
||||||
`src/render/contract/index.js` lists every member of every port in `PORTS`,
|
`src/render/contract/index.js` lists every member of every port in `PORTS`,
|
||||||
along with `CAPABILITIES`, `FRAME_STEPS` and `QUALITY_KEYS`.
|
along with `CAPABILITIES`, `FRAME_STEPS` and `QUALITY_KEYS`.
|
||||||
|
|||||||
@@ -242,6 +242,26 @@ export function cameraChecks() {
|
|||||||
[-1.2, -0.1],
|
[-1.2, -0.1],
|
||||||
'pitchRange is settable',
|
'pitchRange is settable',
|
||||||
);
|
);
|
||||||
|
const { coast } = r.camera.controls;
|
||||||
|
for (const key of ['drag', 'zoom'])
|
||||||
|
ok(
|
||||||
|
Number.isFinite(coast[key]) && coast[key] >= 0,
|
||||||
|
`coast.${key} is seconds`,
|
||||||
|
);
|
||||||
|
r.camera.controls.coast = { drag: 0.25, zoom: 0 };
|
||||||
|
// Within rounding: an engine may keep it as a decay coefficient.
|
||||||
|
near(
|
||||||
|
r.camera.controls.coast.drag,
|
||||||
|
0.25,
|
||||||
|
1e-9,
|
||||||
|
'coast.drag is settable',
|
||||||
|
);
|
||||||
|
near(
|
||||||
|
r.camera.controls.coast.zoom,
|
||||||
|
0,
|
||||||
|
1e-9,
|
||||||
|
'a coast of 0 stops at once',
|
||||||
|
);
|
||||||
r.destroy();
|
r.destroy();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ export function portChecks() {
|
|||||||
'credits.on(change)',
|
'credits.on(change)',
|
||||||
(cb) => r.credits.on('change', cb),
|
(cb) => r.credits.on('change', cb),
|
||||||
() => {
|
() => {
|
||||||
credits.push(r.credits.addStatic({ html: 'Conformance data' }));
|
credits.push(r.credits.addStatic({ text: 'Conformance data' }));
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -1363,6 +1363,251 @@ export function portChecks() {
|
|||||||
deepEqual(seen, ['ambient'], 'ambient again');
|
deepEqual(seen, ['ambient'], 'ambient again');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
check(
|
||||||
|
'input.lease.grab',
|
||||||
|
'a lease with grab takes only the presses it grabs; wheel and the rest stay free',
|
||||||
|
async (h, r) => {
|
||||||
|
if (!h.pointer) return { skip: 'no pointer in this harness' };
|
||||||
|
const heard = [];
|
||||||
|
for (const type of POINTER_TYPES) {
|
||||||
|
r.input.on(type, () => heard.push(`ambient:${type}`));
|
||||||
|
}
|
||||||
|
const errors = [];
|
||||||
|
r.on('error', (e) => errors.push(e));
|
||||||
|
let throwing = false;
|
||||||
|
const lease = r.input.claimPointer('draw', {
|
||||||
|
grab: (down) => {
|
||||||
|
if (throwing) throw new Error('conformance grab');
|
||||||
|
return down.type === 'down' && down.position.x < 640;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const type of POINTER_TYPES)
|
||||||
|
r.input.on(type, () => heard.push(`tool:${type}`), { lease });
|
||||||
|
const press = async (x) => {
|
||||||
|
heard.length = 0;
|
||||||
|
for (const type of ['down', 'move', 'up', 'click'])
|
||||||
|
await h.pointer(r, type, x, 360);
|
||||||
|
return heard.slice();
|
||||||
|
};
|
||||||
|
deepEqual(
|
||||||
|
await press(100),
|
||||||
|
[
|
||||||
|
'tool:down',
|
||||||
|
'ambient:move',
|
||||||
|
'tool:move',
|
||||||
|
'ambient:up',
|
||||||
|
'tool:up',
|
||||||
|
'tool:click',
|
||||||
|
],
|
||||||
|
'a grabbed press is the tool’s; move and up go to both',
|
||||||
|
);
|
||||||
|
heard.length = 0;
|
||||||
|
await h.pointer(r, 'wheel', 100, 360, { delta: 1 });
|
||||||
|
deepEqual(
|
||||||
|
heard,
|
||||||
|
['ambient:wheel'],
|
||||||
|
'wheel is never grabbed, even after a grabbed press',
|
||||||
|
);
|
||||||
|
heard.length = 0;
|
||||||
|
await h.pointer(r, 'pinch', 100, 360, { delta: 1.1 });
|
||||||
|
deepEqual(heard, ['ambient:pinch'], 'nor is pinch');
|
||||||
|
deepEqual(
|
||||||
|
await press(1000),
|
||||||
|
[
|
||||||
|
'ambient:down',
|
||||||
|
'ambient:move',
|
||||||
|
'tool:move',
|
||||||
|
'ambient:up',
|
||||||
|
'tool:up',
|
||||||
|
'ambient:click',
|
||||||
|
],
|
||||||
|
'a press left alone goes to the others',
|
||||||
|
);
|
||||||
|
ok(lease.current, 'the lease stays out');
|
||||||
|
ok(!r.input.isPointerFree(), 'the pointer is not free');
|
||||||
|
throwing = true;
|
||||||
|
deepEqual(
|
||||||
|
(await press(100)).filter((x) => x.endsWith(':down')),
|
||||||
|
['ambient:down'],
|
||||||
|
'a throwing grab leaves the press to the others',
|
||||||
|
);
|
||||||
|
equal(errors.length, 1, 'and is reported');
|
||||||
|
lease.release();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
'input.lease.press',
|
||||||
|
'every event of a press goes where its down went, whatever the lease does meanwhile',
|
||||||
|
async (h, r) => {
|
||||||
|
if (!h.pointer) return { skip: 'no pointer in this harness' };
|
||||||
|
const heard = [];
|
||||||
|
for (const type of ['down', 'click', 'dblclick'])
|
||||||
|
r.input.on(type, () => heard.push(`ambient:${type}`));
|
||||||
|
const left = (down) => down.type === 'down' && down.position.x < 640;
|
||||||
|
const listen = (lease, name) => {
|
||||||
|
for (const type of ['down', 'up', 'click', 'dblclick'])
|
||||||
|
r.input.on(type, () => heard.push(`${name}:${type}`), { lease });
|
||||||
|
};
|
||||||
|
const at = async (x, ...types) => {
|
||||||
|
for (const type of types) await h.pointer(r, type, x, 360);
|
||||||
|
};
|
||||||
|
|
||||||
|
let lease = r.input.claimPointer('a', { grab: left });
|
||||||
|
listen(lease, 'a');
|
||||||
|
r.input.on('up', () => lease.release(), { lease });
|
||||||
|
heard.length = 0;
|
||||||
|
await at(100, 'down', 'up', 'click');
|
||||||
|
deepEqual(
|
||||||
|
heard,
|
||||||
|
['a:down', 'a:up'],
|
||||||
|
'a lease that ends on up takes the click with it: no one hears it',
|
||||||
|
);
|
||||||
|
|
||||||
|
heard.length = 0;
|
||||||
|
await at(1000, 'down');
|
||||||
|
lease = r.input.claimPointer('b', { grab: () => true });
|
||||||
|
listen(lease, 'b');
|
||||||
|
await at(1000, 'up', 'click');
|
||||||
|
deepEqual(
|
||||||
|
heard,
|
||||||
|
['ambient:down', 'b:up', 'ambient:click'],
|
||||||
|
'a press left to the others stays theirs when a lease is claimed mid-press (up goes to both)',
|
||||||
|
);
|
||||||
|
|
||||||
|
heard.length = 0;
|
||||||
|
await at(100, 'down', 'up', 'click', 'down', 'up', 'click', 'dblclick');
|
||||||
|
deepEqual(
|
||||||
|
heard.filter((x) => !x.endsWith(':up')),
|
||||||
|
['b:down', 'b:click', 'b:down', 'b:click', 'b:dblclick'],
|
||||||
|
'a grabbed double-click is the lease’s',
|
||||||
|
);
|
||||||
|
lease.release();
|
||||||
|
|
||||||
|
lease = r.input.claimPointer('c', { grab: left });
|
||||||
|
listen(lease, 'c');
|
||||||
|
heard.length = 0;
|
||||||
|
await at(100, 'down', 'up', 'click');
|
||||||
|
await at(1000, 'click');
|
||||||
|
deepEqual(
|
||||||
|
heard.filter((x) => !x.endsWith(':up')),
|
||||||
|
['c:down', 'c:click', 'ambient:click'],
|
||||||
|
'a click with no press of its own is decided afresh',
|
||||||
|
);
|
||||||
|
lease.release();
|
||||||
|
|
||||||
|
let latest = r.input.claimPointer('d', { grab: () => true });
|
||||||
|
r.input.on(
|
||||||
|
'down',
|
||||||
|
() => {
|
||||||
|
heard.push('d:down');
|
||||||
|
latest = r.input.claimPointer('e', { grab: () => false });
|
||||||
|
},
|
||||||
|
{ lease: latest },
|
||||||
|
);
|
||||||
|
r.input.on('down', () => heard.push('latest:down'), {
|
||||||
|
lease: () => latest,
|
||||||
|
});
|
||||||
|
heard.length = 0;
|
||||||
|
await at(100, 'down');
|
||||||
|
deepEqual(
|
||||||
|
heard,
|
||||||
|
['d:down'],
|
||||||
|
'a lease claimed during delivery does not inherit the press',
|
||||||
|
);
|
||||||
|
latest.release();
|
||||||
|
|
||||||
|
let replacement = null;
|
||||||
|
const first = r.input.claimPointer('f', {
|
||||||
|
grab: () => {
|
||||||
|
replacement = r.input.claimPointer('g');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
listen(first, 'f');
|
||||||
|
heard.length = 0;
|
||||||
|
await at(100, 'down');
|
||||||
|
listen(replacement, 'g');
|
||||||
|
await at(100, 'up', 'click');
|
||||||
|
deepEqual(
|
||||||
|
heard.filter((x) => !x.endsWith(':up')),
|
||||||
|
[],
|
||||||
|
'a press grabbed by a lease its grab replaced belongs to that ended lease: no one',
|
||||||
|
);
|
||||||
|
replacement.release();
|
||||||
|
|
||||||
|
const downs = [];
|
||||||
|
lease = r.input.claimPointer('h', {
|
||||||
|
grab: (down) => {
|
||||||
|
downs.push(down);
|
||||||
|
return down.button === 0 && down.position.x < 640;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
listen(lease, 'h');
|
||||||
|
const step = (type, x, button = 0) =>
|
||||||
|
h.pointer(r, type, x, 360, { button });
|
||||||
|
heard.length = 0;
|
||||||
|
await step('down', 100);
|
||||||
|
await step('move', 300);
|
||||||
|
await step('up', 300);
|
||||||
|
await step('move', 1000);
|
||||||
|
await step('click', 1000);
|
||||||
|
deepEqual(
|
||||||
|
heard.filter((x) => !x.endsWith(':up')),
|
||||||
|
['h:down', 'ambient:click'],
|
||||||
|
'after a drag, a later click is a press of its own',
|
||||||
|
);
|
||||||
|
heard.length = 0;
|
||||||
|
await step('down', 100);
|
||||||
|
await step('move', 300);
|
||||||
|
await step('up', 300);
|
||||||
|
await step('click', 1000);
|
||||||
|
deepEqual(
|
||||||
|
heard.filter((x) => !x.endsWith(':up')),
|
||||||
|
['h:down', 'ambient:click'],
|
||||||
|
'a click straight after a drag, elsewhere, is a press of its own',
|
||||||
|
);
|
||||||
|
heard.length = 0;
|
||||||
|
await step('down', 100, 0);
|
||||||
|
await step('down', 100, 2);
|
||||||
|
await step('up', 100, 0);
|
||||||
|
await step('click', 100, 0);
|
||||||
|
await step('up', 100, 2);
|
||||||
|
await step('click', 100, 2);
|
||||||
|
deepEqual(
|
||||||
|
heard.filter((x) => !x.endsWith(':up')),
|
||||||
|
['h:down', 'ambient:down', 'h:click', 'ambient:click'],
|
||||||
|
'each button’s press is its own',
|
||||||
|
);
|
||||||
|
heard.length = 0;
|
||||||
|
await at(100, 'down', 'up', 'click');
|
||||||
|
await at(1000, 'dblclick');
|
||||||
|
deepEqual(
|
||||||
|
heard.filter((x) => !x.endsWith(':up')),
|
||||||
|
['h:down', 'h:click', 'ambient:dblclick'],
|
||||||
|
'a double-click elsewhere is not the click’s',
|
||||||
|
);
|
||||||
|
heard.length = 0;
|
||||||
|
await at(100, 'down');
|
||||||
|
await at(1000, 'dblclick');
|
||||||
|
await at(100, 'up', 'click');
|
||||||
|
deepEqual(
|
||||||
|
heard.filter((x) => !x.endsWith(':up')),
|
||||||
|
['h:down', 'ambient:dblclick', 'h:click'],
|
||||||
|
'a stray double-click during a press leaves the press its owner\u2019s',
|
||||||
|
);
|
||||||
|
downs.length = 0;
|
||||||
|
await at(100, 'click');
|
||||||
|
equal(downs.length, 1, 'an isolated click asks grab');
|
||||||
|
equal(downs[0]?.type, 'down', 'as a down');
|
||||||
|
ok(
|
||||||
|
!('pick' in downs[0]) &&
|
||||||
|
!('drill' in downs[0]) &&
|
||||||
|
!('ground' in downs[0]),
|
||||||
|
'with no pick',
|
||||||
|
);
|
||||||
|
lease.release();
|
||||||
|
},
|
||||||
|
),
|
||||||
check(
|
check(
|
||||||
'input.clickPick',
|
'input.clickPick',
|
||||||
'a click carries one pick shared by every subscriber',
|
'a click carries one pick shared by every subscriber',
|
||||||
@@ -1476,6 +1721,64 @@ export function portChecks() {
|
|||||||
},
|
},
|
||||||
{ capability: 'styles' },
|
{ capability: 'styles' },
|
||||||
),
|
),
|
||||||
|
check(
|
||||||
|
'effects.defineStyle',
|
||||||
|
'Core defines a style: listed, set, replaced and removed',
|
||||||
|
(h, r) => {
|
||||||
|
const definition = {
|
||||||
|
id: 'conf:tint',
|
||||||
|
params: [{ key: 'amount', min: 0, max: 1, default: 0.5 }],
|
||||||
|
animated: false,
|
||||||
|
shader: {
|
||||||
|
glsl: `uniform sampler2D colorTexture;
|
||||||
|
uniform float intensity;
|
||||||
|
uniform float amount;
|
||||||
|
in vec2 v_textureCoordinates;
|
||||||
|
void main() {
|
||||||
|
vec4 c = texture(colorTexture, v_textureCoordinates);
|
||||||
|
out_FragColor = vec4(mix(c.rgb, c.rgb * vec3(1.0, amount, amount), intensity), 1.0);
|
||||||
|
}`,
|
||||||
|
wgsl: `@fragment
|
||||||
|
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
|
||||||
|
let c = textureSample(colorTexture, colorSampler, uv);
|
||||||
|
let tinted = c.rgb * vec3f(1.0, params.amount, params.amount);
|
||||||
|
return vec4f(mix(c.rgb, tinted, params.intensity), 1.0);
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const off = r.effects.defineStyle(definition);
|
||||||
|
equal(typeof off, 'function', 'an Off, with a GLSL and a WGSL source');
|
||||||
|
const listed = r.effects.styles().filter((s) => s.id === 'conf:tint');
|
||||||
|
equal(listed.length, 1, 'listed once');
|
||||||
|
deepEqual(listed[0].params, definition.params, 'with its parameters');
|
||||||
|
r.effects.setStyle('conf:tint', { amount: 0.2 });
|
||||||
|
equal(r.effects.diagnostics().style, 'conf:tint', 'set');
|
||||||
|
const offAgain = r.effects.defineStyle({
|
||||||
|
...definition,
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
equal(
|
||||||
|
r.effects.styles().filter((s) => s.id === 'conf:tint').length,
|
||||||
|
1,
|
||||||
|
'a second definition replaces the first',
|
||||||
|
);
|
||||||
|
equal(
|
||||||
|
r.effects.diagnostics().style,
|
||||||
|
'conf:tint',
|
||||||
|
'the style stays on through its replacement',
|
||||||
|
);
|
||||||
|
off();
|
||||||
|
ok(
|
||||||
|
r.effects.styles().some((s) => s.id === 'conf:tint'),
|
||||||
|
'the replaced definition’s Off leaves the new one',
|
||||||
|
);
|
||||||
|
offAgain();
|
||||||
|
ok(!r.effects.styles().some((s) => s.id === 'conf:tint'), 'removed');
|
||||||
|
equal(r.effects.diagnostics().style, null, 'and turned off');
|
||||||
|
offAgain();
|
||||||
|
},
|
||||||
|
{ capability: 'styles' },
|
||||||
|
),
|
||||||
check(
|
check(
|
||||||
'effects.withoutStyles',
|
'effects.withoutStyles',
|
||||||
'without styles, styles() is empty',
|
'without styles, styles() is empty',
|
||||||
@@ -1487,6 +1790,16 @@ export function portChecks() {
|
|||||||
if (!r) return { skip: 'this harness cannot drop capabilities' };
|
if (!r) return { skip: 'this harness cannot drop capabilities' };
|
||||||
try {
|
try {
|
||||||
deepEqual(r.effects.styles(), [], 'styles()');
|
deepEqual(r.effects.styles(), [], 'styles()');
|
||||||
|
equal(
|
||||||
|
r.effects.defineStyle({
|
||||||
|
id: 'x',
|
||||||
|
params: [],
|
||||||
|
animated: false,
|
||||||
|
shader: { glsl: 'void main() {}' },
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
'defineStyle() answers null',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
r.destroy();
|
r.destroy();
|
||||||
}
|
}
|
||||||
@@ -1499,10 +1812,17 @@ export function portChecks() {
|
|||||||
const events = [];
|
const events = [];
|
||||||
r.credits.on('change', (list) => events.push(list.length));
|
r.credits.on('change', (list) => events.push(list.length));
|
||||||
const base = r.credits.list().length;
|
const base = r.credits.list().length;
|
||||||
const off = r.credits.addStatic({ html: 'Conformance data' });
|
const credit = {
|
||||||
ok(
|
text: 'Conformance <data>',
|
||||||
r.credits.list().some((c) => c.html === 'Conformance data'),
|
href: 'https://example.invalid/data',
|
||||||
'listed',
|
logoUrl: 'https://example.invalid/logo.png',
|
||||||
|
inline: true,
|
||||||
|
};
|
||||||
|
const off = r.credits.addStatic(credit);
|
||||||
|
deepEqual(
|
||||||
|
r.credits.list().find((c) => c.text === credit.text),
|
||||||
|
credit,
|
||||||
|
'listed as given, text left as text',
|
||||||
);
|
);
|
||||||
off();
|
off();
|
||||||
equal(r.credits.list().length, base, 'removed');
|
equal(r.credits.list().length, base, 'removed');
|
||||||
|
|||||||
Vendored
+7
-3
@@ -172,9 +172,13 @@ export interface CameraControls {
|
|||||||
enableZoom: boolean;
|
enableZoom: boolean;
|
||||||
minDistance: number;
|
minDistance: number;
|
||||||
maxDistance: number;
|
maxDistance: number;
|
||||||
inertiaSpin: number;
|
/**
|
||||||
inertiaTranslate: number;
|
* How long a motion the user lets go of coasts: seconds for its speed to
|
||||||
inertiaZoom: number;
|
* fall to 1/e. `drag` is moving the globe under the pointer and tilting
|
||||||
|
* (one motion in the default renderer), `zoom` is zooming; 0 stops at
|
||||||
|
* once. Set the whole object to change it.
|
||||||
|
*/
|
||||||
|
coast: { drag: number; zoom: number };
|
||||||
/** What the eye cannot pass below. */
|
/** What the eye cannot pass below. */
|
||||||
collision: 'none' | 'terrain' | 'tiles' | 'both';
|
collision: 'none' | 'terrain' | 'tiles' | 'both';
|
||||||
zoomFactor: number;
|
zoomFactor: number;
|
||||||
|
|||||||
Vendored
+7
-2
@@ -5,9 +5,14 @@
|
|||||||
*/
|
*/
|
||||||
import type { Off } from './common';
|
import type { Off } from './common';
|
||||||
|
|
||||||
|
/** One credit, as plain data: Core renders and escapes it. */
|
||||||
export interface Credit {
|
export interface Credit {
|
||||||
/** Sanitised HTML: text, links and images only. */
|
/** Plain text, never markup. */
|
||||||
html: string;
|
text: string;
|
||||||
|
/** Where the credit links to. */
|
||||||
|
href?: string;
|
||||||
|
/** A logo drawn before the text. */
|
||||||
|
logoUrl?: string;
|
||||||
/** Shown on the map itself, rather than in the expanded list. */
|
/** Shown on the map itself, rather than in the expanded list. */
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+36
-3
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Full-screen effects: visual styles, bloom, sharpen and screen modulation.
|
* Full-screen effects: visual styles, bloom, sharpen and screen modulation.
|
||||||
* Each style is a plain full-screen pass over the frame's colour, so every
|
* Each style is a plain full-screen pass over the frame's colour. Core owns
|
||||||
* renderer can provide the same set.
|
* the styles and defines them on every renderer (`defineStyle`), so each
|
||||||
|
* renderer runs the same set; a renderer's own styles are extra ones.
|
||||||
*/
|
*/
|
||||||
import type { Off } from './common';
|
import type { Off } from './common';
|
||||||
|
|
||||||
@@ -13,6 +14,32 @@ export interface StyleDescriptor {
|
|||||||
animated: boolean;
|
animated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A style's shader. `glsl` is a GLSL ES 3.00 fragment shader with its
|
||||||
|
* `main()`, as GEV's styles are written, without `#version`, `precision`
|
||||||
|
* or an `out` declaration: the renderer declares `out_FragColor`, which it
|
||||||
|
* writes. It declares what it reads of `uniform sampler2D colorTexture`,
|
||||||
|
* `uniform vec2 colorTextureDimensions` (pixels),
|
||||||
|
* `in vec2 v_textureCoordinates`, `uniform float intensity` (0..1),
|
||||||
|
* `uniform float time` (seconds, for an animated style) and one
|
||||||
|
* `uniform float` per parameter under its key. Parameter keys are GLSL
|
||||||
|
* identifiers and never one of those names.
|
||||||
|
* `wgsl`, for a renderer that runs WGSL, is the same pass: a fragment entry
|
||||||
|
* `fs_main` taking `@location(0) uv: vec2f` and returning the colour at
|
||||||
|
* `@location(0)`. Group 0 binds `colorTexture` (`texture_2d<f32>`) at 0,
|
||||||
|
* `colorSampler` at 1 and, at 2, a uniform struct the shader declares as
|
||||||
|
* `params`: `intensity: f32`, `time: f32`, `dimensions: vec2f`, then one
|
||||||
|
* `f32` per parameter in the order `params` lists them.
|
||||||
|
*/
|
||||||
|
export interface StyleShader {
|
||||||
|
glsl: string;
|
||||||
|
wgsl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StyleDefinition extends StyleDescriptor {
|
||||||
|
shader: StyleShader;
|
||||||
|
}
|
||||||
|
|
||||||
/** A sweeping sector drawn over the frame (a sonar band), or null. */
|
/** A sweeping sector drawn over the frame (a sonar band), or null. */
|
||||||
export interface ModulationSector {
|
export interface ModulationSector {
|
||||||
/** Centre of the sweep, CSS pixels. */
|
/** Centre of the sweep, CSS pixels. */
|
||||||
@@ -32,8 +59,14 @@ export interface EffectsDiagnostics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface EffectsPort {
|
export interface EffectsPort {
|
||||||
/** `styles`. */
|
/** `styles`: every style, defined by Core or the renderer's own. */
|
||||||
styles(): StyleDescriptor[];
|
styles(): StyleDescriptor[];
|
||||||
|
/**
|
||||||
|
* `styles`: add a style, replacing one with the same id. Null without
|
||||||
|
* the capability, or when the renderer runs none of the shader's
|
||||||
|
* languages; the Off removes it (turning it off when it is the style).
|
||||||
|
*/
|
||||||
|
defineStyle(definition: StyleDefinition): Off | null;
|
||||||
setStyle(id: string | null, params?: Record<string, number>): void;
|
setStyle(id: string | null, params?: Record<string, number>): void;
|
||||||
setStyleIntensity(id: string, intensity: number): void;
|
setStyleIntensity(id: string, intensity: number): void;
|
||||||
setStyleParams(id: string, params: Record<string, number>): void;
|
setStyleParams(id: string, params: Record<string, number>): void;
|
||||||
|
|||||||
Vendored
+5
-1
@@ -10,7 +10,11 @@
|
|||||||
* move detection (`moveStart` / `moveEnd`) → `preRender` → render →
|
* move detection (`moveStart` / `moveEnd`) → `preRender` → render →
|
||||||
* `postRender`. `preUpdate` fires on every frame the loop ticks, including
|
* `postRender`. `preUpdate` fires on every frame the loop ticks, including
|
||||||
* frames that end up drawing nothing, so owners that write the camera there
|
* frames that end up drawing nothing, so owners that write the camera there
|
||||||
* (a cockpit, a follow clamp) see each one.
|
* (a cockpit, a follow clamp) see each one. This is the default renderer's
|
||||||
|
* own order. `changed` is measured before follow and `preUpdate` move the
|
||||||
|
* camera, so a move made there is reported by `changed` on the next frame;
|
||||||
|
* move detection runs after them and sees it in the same frame. The
|
||||||
|
* default renderer's `postUpdate` has no place here: Core does not use it.
|
||||||
*/
|
*/
|
||||||
import type { Off, Release } from './common';
|
import type { Off, Release } from './common';
|
||||||
|
|
||||||
|
|||||||
@@ -198,9 +198,9 @@ export const PORTS = deepFreeze({
|
|||||||
'controls.enableZoom',
|
'controls.enableZoom',
|
||||||
'controls.minDistance',
|
'controls.minDistance',
|
||||||
'controls.maxDistance',
|
'controls.maxDistance',
|
||||||
'controls.inertiaSpin',
|
'controls.coast',
|
||||||
'controls.inertiaTranslate',
|
'controls.coast.drag',
|
||||||
'controls.inertiaZoom',
|
'controls.coast.zoom',
|
||||||
'controls.collision',
|
'controls.collision',
|
||||||
'controls.zoomFactor',
|
'controls.zoomFactor',
|
||||||
'controls.pitchRange',
|
'controls.pitchRange',
|
||||||
@@ -247,6 +247,7 @@ export const PORTS = deepFreeze({
|
|||||||
effects: Object.fromEntries([
|
effects: Object.fromEntries([
|
||||||
...required(
|
...required(
|
||||||
'styles',
|
'styles',
|
||||||
|
'defineStyle',
|
||||||
'setStyle',
|
'setStyle',
|
||||||
'setStyleIntensity',
|
'setStyleIntensity',
|
||||||
'setStyleParams',
|
'setStyleParams',
|
||||||
|
|||||||
Vendored
+33
-5
@@ -3,9 +3,26 @@
|
|||||||
*
|
*
|
||||||
* A tool takes the pointer with a lease; while a lease is out, ambient
|
* A tool takes the pointer with a lease; while a lease is out, ambient
|
||||||
* subscribers (those without a lease) get only `move` and `up`: no presses,
|
* subscribers (those without a lease) get only `move` and `up`: no presses,
|
||||||
* clicks, double-clicks, wheel or pinch. Suspending camera
|
* clicks, double-clicks, wheel or pinch. A lease with `grab` takes only the
|
||||||
* input is separate and counted, and takes effect on the same pointerdown
|
* presses it grabs: `grab` is asked on each `down`, and a grabbed press (its
|
||||||
* that asked for it.
|
* `down`, and the `click` and `dblclick` it ends in) goes to the lease's
|
||||||
|
* listeners alone. Every other press, and wheel and pinch, go to ambient
|
||||||
|
* subscribers and the camera as if the pointer were free; `move` and `up`
|
||||||
|
* go to both.
|
||||||
|
*
|
||||||
|
* Every event of a press goes where its `down` went, even when the lease
|
||||||
|
* ends or changes before the press is over (an ended lease's to no one), so
|
||||||
|
* a claim never takes a press already under way. Each button's presses
|
||||||
|
* are separate. A press runs from its `down` through its `up`, the `click`
|
||||||
|
* straight after it where it went up, and a `dblclick` straight after that
|
||||||
|
* at the same spot;
|
||||||
|
* any other event after its `up` ends it. A click or double-click with no
|
||||||
|
* press of its own is decided as a press, `grab` seeing it as a `down`
|
||||||
|
* (without `pick`, `drill` or `ground`).
|
||||||
|
*
|
||||||
|
* Suspending camera input is separate and counted, and takes effect on the
|
||||||
|
* same pointerdown that asked for it, so a tool that grabs a press suspends
|
||||||
|
* the camera in its `down` listener.
|
||||||
*
|
*
|
||||||
* A click is delivered with its pick already made, once, and shared by
|
* A click is delivered with its pick already made, once, and shared by
|
||||||
* every subscriber (`PointerEv.pick`). A renderer whose picks answer a frame
|
* every subscriber (`PointerEv.pick`). A renderer whose picks answer a frame
|
||||||
@@ -70,8 +87,19 @@ export interface InputPort {
|
|||||||
cb: (ev: PointerEv) => void,
|
cb: (ev: PointerEv) => void,
|
||||||
options?: { lease?: Lease | (() => Lease | null) },
|
options?: { lease?: Lease | (() => Lease | null) },
|
||||||
): Off;
|
): Off;
|
||||||
/** Take the pointer for a tool. A new claim ends the previous lease. */
|
/**
|
||||||
claimPointer(owner: string): Lease;
|
* Take the pointer for a tool. A new claim ends the previous lease.
|
||||||
|
* `grab` decides each press from its `down` event, synchronously: true
|
||||||
|
* makes it the tool's, anything else leaves it. A `down` carries no pick,
|
||||||
|
* so `grab` decides from the position, button and modifiers (and the
|
||||||
|
* tool's own hit test, through `query`). Omitted, every press is the
|
||||||
|
* tool's. A `grab` that throws is reported and leaves the press to the
|
||||||
|
* others.
|
||||||
|
*/
|
||||||
|
claimPointer(
|
||||||
|
owner: string,
|
||||||
|
options?: { grab?: (down: PointerEv) => boolean },
|
||||||
|
): Lease;
|
||||||
isPointerFree(): boolean;
|
isPointerFree(): boolean;
|
||||||
/** Suspend user camera input until released. Holds are counted. */
|
/** Suspend user camera input until released. Holds are counted. */
|
||||||
suspendCameraControls(reason: string): Release;
|
suspendCameraControls(reason: string): Release;
|
||||||
|
|||||||
Vendored
-2
@@ -30,8 +30,6 @@ export interface RendererPlugin {
|
|||||||
export interface CreateOptions {
|
export interface CreateOptions {
|
||||||
/** The element the renderer fills. It creates its own canvas inside. */
|
/** The element the renderer fills. It creates its own canvas inside. */
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
/** Where Core renders credits, when it has a place for them. */
|
|
||||||
creditContainer?: HTMLElement;
|
|
||||||
/** Google Maps Platform key for photorealistic tiles. */
|
/** Google Maps Platform key for photorealistic tiles. */
|
||||||
googleApiKey?: string;
|
googleApiKey?: string;
|
||||||
/** Short-lived Google session tokens, when the key is held server-side. */
|
/** Short-lived Google session tokens, when the key is held server-side. */
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export function createStubCamera({
|
|||||||
let moving = false;
|
let moving = false;
|
||||||
let lastMovedAt = 0;
|
let lastMovedAt = 0;
|
||||||
|
|
||||||
|
let coast = Object.freeze({ drag: 0.4, zoom: 0.2 });
|
||||||
const controls = {
|
const controls = {
|
||||||
get enabled() {
|
get enabled() {
|
||||||
return holds.size === 0;
|
return holds.size === 0;
|
||||||
@@ -125,9 +126,18 @@ export function createStubCamera({
|
|||||||
enableZoom: true,
|
enableZoom: true,
|
||||||
minDistance: 1,
|
minDistance: 1,
|
||||||
maxDistance: Infinity,
|
maxDistance: Infinity,
|
||||||
inertiaSpin: 0.9,
|
// The default renderer's inertia as decay times: its speed falls as
|
||||||
inertiaTranslate: 0.9,
|
// exp(-25 (1 - c) t), so c = 0.9 (spin, which drags and tilts in 3D)
|
||||||
inertiaZoom: 0.8,
|
// is 0.4 s and c = 0.8 (zoom) 0.2 s. Back: c = coast > 0 ? 1 - 1 / (25 coast) : 1.
|
||||||
|
get coast() {
|
||||||
|
return coast;
|
||||||
|
},
|
||||||
|
set coast(value) {
|
||||||
|
for (const key of ['drag', 'zoom'])
|
||||||
|
if (!(Number.isFinite(value?.[key]) && value[key] >= 0))
|
||||||
|
throw new TypeError(`coast.${key} must be seconds, 0 or more`);
|
||||||
|
coast = Object.freeze({ drag: value.drag, zoom: value.zoom });
|
||||||
|
},
|
||||||
collision: 'none',
|
collision: 'none',
|
||||||
zoomFactor: 5,
|
zoomFactor: 5,
|
||||||
pitchRange: [-Math.PI / 2, 0],
|
pitchRange: [-Math.PI / 2, 0],
|
||||||
|
|||||||
+136
-6
@@ -733,6 +733,16 @@ export function createStubRenderer(options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Each lease's `grab`, or null when it takes every press. */
|
||||||
|
const leaseGrab = new WeakMap();
|
||||||
|
/**
|
||||||
|
* The presses under way, by button, from their `down`: `owner` is the
|
||||||
|
* lease each went to, or null when it went to everyone else. A press's
|
||||||
|
* click and double-click go where its down went (an ended owner's to no
|
||||||
|
* one).
|
||||||
|
*/
|
||||||
|
const presses = new Map();
|
||||||
|
|
||||||
const leaseOf = (entry) =>
|
const leaseOf = (entry) =>
|
||||||
typeof entry.lease === 'function' ? entry.lease() : entry.lease;
|
typeof entry.lease === 'function' ? entry.lease() : entry.lease;
|
||||||
|
|
||||||
@@ -746,9 +756,10 @@ export function createStubRenderer(options = {}) {
|
|||||||
if (i >= 0) pointerListeners.splice(i, 1);
|
if (i >= 0) pointerListeners.splice(i, 1);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
claimPointer(owner) {
|
claimPointer(owner, opts = {}) {
|
||||||
if (currentLease) currentLease.release();
|
if (currentLease) currentLease.release();
|
||||||
let released = false;
|
let released = false;
|
||||||
|
const grab = typeof opts.grab === 'function' ? opts.grab : null;
|
||||||
const lease = {
|
const lease = {
|
||||||
owner: String(owner),
|
owner: String(owner),
|
||||||
get current() {
|
get current() {
|
||||||
@@ -761,6 +772,7 @@ export function createStubRenderer(options = {}) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
currentLease = lease;
|
currentLease = lease;
|
||||||
|
leaseGrab.set(lease, grab);
|
||||||
return lease;
|
return lease;
|
||||||
},
|
},
|
||||||
isPointerFree: () => currentLease === null,
|
isPointerFree: () => currentLease === null,
|
||||||
@@ -774,8 +786,91 @@ export function createStubRenderer(options = {}) {
|
|||||||
ctrlWheelZoom: false,
|
ctrlWheelZoom: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Who a new press belongs to, decided from its `down`. */
|
||||||
|
function pressFor(down) {
|
||||||
|
const lease = currentLease;
|
||||||
|
if (!lease) return { owner: null, clicked: false };
|
||||||
|
const grab = leaseGrab.get(lease);
|
||||||
|
let grabbed = !grab;
|
||||||
|
if (grab)
|
||||||
|
try {
|
||||||
|
grabbed = grab(down) === true;
|
||||||
|
} catch (error) {
|
||||||
|
report(error);
|
||||||
|
}
|
||||||
|
return { owner: grabbed ? lease : null, clicked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an event goes: to one lease's listeners (`lease`), to everyone
|
||||||
|
* without a lease (`ambient`), or both (`move` and `up`).
|
||||||
|
*/
|
||||||
|
function recipients(type, ev) {
|
||||||
|
const button = ev.button;
|
||||||
|
// A click comes straight after its press's up, and a double-click
|
||||||
|
// straight after that click, by the same button at the same spot;
|
||||||
|
// anything else after the up ends the press.
|
||||||
|
for (const [b, p] of presses) {
|
||||||
|
if (!p.up) continue;
|
||||||
|
const continues =
|
||||||
|
b === button &&
|
||||||
|
((type === 'click' &&
|
||||||
|
!p.clicked &&
|
||||||
|
Math.hypot(p.at.x - ev.position.x, p.at.y - ev.position.y) <= 4) ||
|
||||||
|
(type === 'dblclick' &&
|
||||||
|
p.clicked &&
|
||||||
|
Math.hypot(p.at.x - ev.position.x, p.at.y - ev.position.y) <= 4));
|
||||||
|
if (!continues) presses.delete(b);
|
||||||
|
}
|
||||||
|
if (type === 'up' && presses.has(button)) {
|
||||||
|
const p = presses.get(button);
|
||||||
|
p.up = true;
|
||||||
|
p.at = ev.position;
|
||||||
|
}
|
||||||
|
if (type === 'move' || type === 'up')
|
||||||
|
return { lease: currentLease, ambient: true };
|
||||||
|
if (type === 'wheel' || type === 'pinch') {
|
||||||
|
const exclusive = currentLease && !leaseGrab.get(currentLease);
|
||||||
|
return exclusive
|
||||||
|
? { lease: currentLease, ambient: false }
|
||||||
|
: { lease: null, ambient: true };
|
||||||
|
}
|
||||||
|
// `grab` sees a click with no press of its own as the down it stands
|
||||||
|
// for: no pick, no gesture.
|
||||||
|
const asDown = () => ({
|
||||||
|
...pressFor({
|
||||||
|
type: 'down',
|
||||||
|
position: ev.position,
|
||||||
|
button: ev.button,
|
||||||
|
modifiers: ev.modifiers,
|
||||||
|
timestamp: ev.timestamp,
|
||||||
|
pointerType: ev.pointerType,
|
||||||
|
}),
|
||||||
|
up: true,
|
||||||
|
});
|
||||||
|
let current;
|
||||||
|
if (type === 'down') {
|
||||||
|
current = pressFor(ev);
|
||||||
|
presses.set(button, current);
|
||||||
|
} else {
|
||||||
|
// Only a press the loop above kept, at the right step, continues;
|
||||||
|
// anything else is a press of its own and leaves a held one alone.
|
||||||
|
const held = presses.get(button);
|
||||||
|
const continues =
|
||||||
|
held?.up && (type === 'click' ? !held.clicked : held.clicked);
|
||||||
|
current = continues ? held : asDown();
|
||||||
|
if (type === 'click' && (continues || !held)) {
|
||||||
|
current.clicked = true;
|
||||||
|
presses.set(button, current);
|
||||||
|
} else if (continues) presses.delete(button);
|
||||||
|
}
|
||||||
|
current.at = ev.position;
|
||||||
|
return current.owner
|
||||||
|
? { lease: current.owner, ambient: false }
|
||||||
|
: { lease: null, ambient: true };
|
||||||
|
}
|
||||||
|
|
||||||
function dispatchPointer(type, x, y, extra = {}) {
|
function dispatchPointer(type, x, y, extra = {}) {
|
||||||
const free = currentLease === null;
|
|
||||||
let picked;
|
let picked;
|
||||||
const ev = {
|
const ev = {
|
||||||
type,
|
type,
|
||||||
@@ -801,13 +896,13 @@ export function createStubRenderer(options = {}) {
|
|||||||
opts.limit === undefined ? hits().slice() : hits().slice(0, opts.limit);
|
opts.limit === undefined ? hits().slice() : hits().slice(0, opts.limit);
|
||||||
ev.ground = () => rayEllipsoid(pickRay(x, y));
|
ev.ground = () => rayEllipsoid(pickRay(x, y));
|
||||||
}
|
}
|
||||||
const ambientAlways = type === 'move' || type === 'up';
|
const to = recipients(type, ev);
|
||||||
for (const entry of pointerListeners.slice()) {
|
for (const entry of pointerListeners.slice()) {
|
||||||
if (!entry.live || entry.type !== type) continue;
|
if (!entry.live || entry.type !== type) continue;
|
||||||
if (entry.lease) {
|
if (entry.lease) {
|
||||||
const lease = leaseOf(entry);
|
const lease = leaseOf(entry);
|
||||||
if (!lease?.current) continue;
|
if (!to.lease || lease !== to.lease || !lease.current) continue;
|
||||||
} else if (!free && !ambientAlways) continue;
|
} else if (!to.ambient) continue;
|
||||||
try {
|
try {
|
||||||
entry.cb(ev);
|
entry.cb(ev);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -982,6 +1077,8 @@ export function createStubRenderer(options = {}) {
|
|||||||
modulation: null,
|
modulation: null,
|
||||||
};
|
};
|
||||||
const effectListeners = [];
|
const effectListeners = [];
|
||||||
|
/** Styles Core defined, by id. */
|
||||||
|
const defined = new Map();
|
||||||
const STYLES = Object.freeze([
|
const STYLES = Object.freeze([
|
||||||
{
|
{
|
||||||
id: 'retro',
|
id: 'retro',
|
||||||
@@ -997,7 +1094,40 @@ export function createStubRenderer(options = {}) {
|
|||||||
}
|
}
|
||||||
const effects = {
|
const effects = {
|
||||||
styles: () =>
|
styles: () =>
|
||||||
capabilities.has('styles') ? STYLES.map((s) => ({ ...s })) : [],
|
capabilities.has('styles')
|
||||||
|
? [
|
||||||
|
...STYLES.filter((s) => !defined.has(s.id)),
|
||||||
|
...defined.values(),
|
||||||
|
].map(({ id, params, animated }) => ({
|
||||||
|
id,
|
||||||
|
params: params.map((p) => ({ ...p })),
|
||||||
|
animated,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
defineStyle(definition) {
|
||||||
|
// The stub runs GLSL only, as the default renderer does.
|
||||||
|
if (
|
||||||
|
!capabilities.has('styles') ||
|
||||||
|
typeof definition?.shader?.glsl !== 'string'
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const entry = {
|
||||||
|
id: String(definition.id),
|
||||||
|
params: (definition.params ?? []).map((p) => ({ ...p })),
|
||||||
|
animated: Boolean(definition.animated),
|
||||||
|
};
|
||||||
|
defined.set(entry.id, entry);
|
||||||
|
effectsChanged();
|
||||||
|
let removed = false;
|
||||||
|
return () => {
|
||||||
|
if (removed) return;
|
||||||
|
removed = true;
|
||||||
|
if (defined.get(entry.id) !== entry) return;
|
||||||
|
defined.delete(entry.id);
|
||||||
|
if (effectState.style === entry.id) effectState.style = null;
|
||||||
|
effectsChanged();
|
||||||
|
};
|
||||||
|
},
|
||||||
setStyle(id, params) {
|
setStyle(id, params) {
|
||||||
effectState.style = id;
|
effectState.style = id;
|
||||||
if (params) effectState.params[id] = { ...params };
|
if (params) effectState.params[id] = { ...params };
|
||||||
|
|||||||
Reference in new issue
Block a user