Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/client/core/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { Ctx } from './context';
import type { CoreState } from './ctx';
import type { Parts } from './parts';
import type { Frame } from './registry';
import { FOV } from './scene';
import { FOV, SHADOW_EVERY } from './scene';
import { FirstPersonBody } from '../world/character/person-first';

/** Covering less ground than this (m/s) since your last footstep, your feet make no sound: a walk is 4.6. */
Expand Down Expand Up @@ -186,12 +186,16 @@ export function installLoop(ctx: Ctx, core: CoreState, parts: Pick<Parts, 'stage
ctx.view.draw(f, drawScene);
}

/** Frames drawn, to redraw the sun's shadow every SHADOW_EVERY of them. */
let drawn = 0;

/** The scene, then your hands on top of it. */
function drawScene() {
const { player, hands, sky, camera, renderer } = ctx;
const { effect, scene } = parts.stage;
const firstPerson = player.view === 'first';
const unhide = firstBody?.hideExtras();
renderer.shadowMap.needsUpdate = drawn++ % SHADOW_EVERY === 0;
effect.render(scene, camera);
unhide?.();
// Not while something has the screen to itself (the telescope, the boss's monitor or the arcade up close), where they'd cover it.
Expand Down
13 changes: 12 additions & 1 deletion src/client/core/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ export const FAR = HAZE_MAX + 20;
/** How wide the camera sees (degrees), unless what you're doing has it otherwise (see ctx.view). */
export const FOV = 55;

/**
* Firefox's WebGL pays a lot of main-thread time per draw call, and the sun's shadow is hundreds of
* them each time it's drawn, so there it's redrawn every other frame. A Retina screen there is drawn
* at 1.5 pixels to a CSS pixel, not 2: a touch softer, with half as many pixels to fill and hold.
*/
const FIREFOX = navigator.userAgent.includes('Firefox/');
export const SHADOW_EVERY = FIREFOX ? 2 : 1;
const MAX_PIXEL_RATIO = FIREFOX ? 1.5 : 2;

/** The renderer, the scene with its lights and camera, and what's always in it (see createScene). */
export interface Stage {
readonly canvas: HTMLCanvasElement;
Expand Down Expand Up @@ -52,8 +61,10 @@ export function noWebGL(): Promise<never> {

/** Sets up the renderer on `canvas`, and builds the scene: its lights, the camera, the office, the sky. */
export function createScene(canvas: HTMLCanvasElement, renderer: THREE.WebGLRenderer): Stage {
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setPixelRatio(Math.min(window.devicePixelRatio, MAX_PIXEL_RATIO));
renderer.shadowMap.enabled = true;
// Redrawn every SHADOW_EVERY frames, by drawScene (core/loop.ts).
renderer.shadowMap.autoUpdate = false;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
const effect = new OutlineEffect(renderer, { defaultThickness: 0.0032, defaultColor: [0.17, 0.18, 0.26] });
Expand Down
14 changes: 11 additions & 3 deletions src/client/input/pointer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,13 @@ export function installPointer(ctx: Ctx, core: CoreState, parts: PointerParts) {
const CROSSHAIR = new THREE.Vector2(0, 0);
const eye = new THREE.Vector3();

/** What the ray through `ndc` lands on first, whether it is within reach (plus `slack` meters), and where it hit. */
function aimedAt(ndc: THREE.Vector2, slack = 0): { it: Interactable; near: boolean; hit: THREE.Intersection } | null {
/**
* What the ray through `ndc` lands on first, whether it is within reach (plus `slack` meters), and
* where it hit. Nothing past `far` meters is looked at.
*/
function aimedAt(ndc: THREE.Vector2, slack = 0, far = Infinity): { it: Interactable; near: boolean; hit: THREE.Intersection } | null {
raycaster.setFromCamera(ndc, camera);
raycaster.far = far;
eye.set(player.pos.x, player.pos.y + EYE_HEIGHT, player.pos.z);
// (Workers standing in line in the castle carry their spot's interactable: see Court.)
const roof = parts.rooftop.roof();
Expand Down Expand Up @@ -115,6 +119,9 @@ export function installPointer(ctx: Ctx, core: CoreState, parts: PointerParts) {
return first ?? ctx.world().herald?.interactable ?? null;
}

/** The farthest any kind of thing can be used from (see ctx.interactions). */
const longestReach = () => Math.max(...ctx.interactions.kinds().map((k) => ctx.interactions.reach(k)));

/** The issue whose note on the issues board an aim lands on, or null (bare cork, the frame, anything else). */
function noteUnder(aim: { it: Interactable; hit: THREE.Intersection } | null): GhIssue | null {
if (aim?.it.kind !== 'issues' || aim.hit.object !== ctx.world().boardMeshes.issues || !aim.hit.uv) return null;
Expand All @@ -134,7 +141,8 @@ export function installPointer(ctx: Ctx, core: CoreState, parts: PointerParts) {
aimedNote = null;
if (modalOpen() || parts.telescope.active || ctx.activities.busy()) target = null;
else if (firstPerson) {
const aim = aimedAt(CROSSHAIR);
// Only what's in reach matters here, and the ray stopping there skips testing the rest of the building every frame.
const aim = aimedAt(CROSSHAIR, 0, longestReach() + 1);
target = aim?.near ? aim.it : (throneTarget() ?? seating.mySeat() ?? (inOffice() ? hoops.ballAtFeet() : null));
if (aim?.near) aimedNote = noteUnder(aim);
} else {
Expand Down