fix: release v1.0.3 预设渲染与语言搬运监督修复
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
This commit is contained in:
@@ -30,3 +30,39 @@ test('主工作台:加载预设、语言命名目标及连续自由坐标抓
|
||||
await expect(page.getByRole('button', { name: '强化学习任务', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('language-workbench.png') });
|
||||
});
|
||||
|
||||
test.describe('预设画布高 DPI 回归', () => {
|
||||
test.use({ deviceScaleFactor: 2 });
|
||||
test('主画布不被 DPR 放大裁切,双相机共用高分辨率绘图缓冲', async ({ page }) => {
|
||||
let inference = 0;
|
||||
await page.route('**/api/decision/v1/**', async (route) => {
|
||||
if (/\/(command|plan|decide|test)$/.test(new URL(route.request().url()).pathname)) {
|
||||
inference++;
|
||||
await route.abort();
|
||||
} else await route.continue();
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.getByRole('tab', { name: '控制台', exact: true }).click();
|
||||
await page.getByRole('button', { name: '机器人语言控制', exact: true }).click();
|
||||
const panel = page.getByLabel('机器人语言控制', { exact: true });
|
||||
await panel.getByRole('button', { name: '载入预设场景' }).click();
|
||||
await expect(panel.getByRole('status')).toContainText('预设已载入并暂停', { timeout: 90000 });
|
||||
for (const name of ['腕部摄像头', '前置摄像头']) {
|
||||
await page.getByRole('combobox', { name: '摄像头视角' }).selectOption({ label: name });
|
||||
const size = await page
|
||||
.locator('canvas')
|
||||
.first()
|
||||
.evaluate((canvas: HTMLCanvasElement) => ({
|
||||
css: [canvas.clientWidth, canvas.clientHeight],
|
||||
host: [canvas.parentElement!.clientWidth, canvas.parentElement!.clientHeight],
|
||||
pixels: [canvas.width, canvas.height],
|
||||
}));
|
||||
expect(size.css).toEqual(size.host);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
expect(size.pixels[i] / size.css[i]).toBeGreaterThanOrEqual(1.74);
|
||||
expect(size.pixels[i] / size.css[i]).toBeLessThanOrEqual(2);
|
||||
}
|
||||
}
|
||||
expect(inference).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { loadWebsiteRobot, runWebsiteCommand } from './website.helpers';
|
||||
import { collectRobotResults, loadWebsiteRobot, runWebsiteCommand } from './website.helpers';
|
||||
|
||||
test('显式授权:公网内置模型,前进/转向/抓放,无手填密钥或自动重试', async ({ page }, info) => {
|
||||
test.skip(process.env.CADWORLD_LIVE_API !== '1', '仅经用户授权后运行,会消耗服务器公共额度');
|
||||
test.skip(
|
||||
process.env.CADWORLD_LIVE_API !== '1' || process.env.CADWORLD_LIVE_PRESET === '1',
|
||||
'仅经用户授权后运行;预设专项测试不重复执行运动测试',
|
||||
);
|
||||
test.setTimeout(360000);
|
||||
const calls = { command: 0, plan: 0, decide: 0, blocked: 0 };
|
||||
let csrf = '',
|
||||
@@ -86,3 +89,87 @@ test('显式授权:公网内置模型,前进/转向/抓放,无手填密钥
|
||||
}
|
||||
expect(cleanup).toBe(true);
|
||||
});
|
||||
|
||||
test('显式授权:空工作台默认预设指令完成真实模型抓放', async ({ page }, info) => {
|
||||
test.skip(
|
||||
process.env.CADWORLD_LIVE_API !== '1' || process.env.CADWORLD_LIVE_PRESET !== '1',
|
||||
'仅在显式选择预设专项实测时调用付费 API',
|
||||
);
|
||||
test.setTimeout(360000);
|
||||
const calls = { command: 0, plan: 0, decide: 0, blocked: 0 };
|
||||
let csrf = '',
|
||||
cleanup = false;
|
||||
const captures: Promise<void>[] = [];
|
||||
page.on('response', (response) => {
|
||||
if (response.url().endsWith('/session') && response.request().method() === 'POST')
|
||||
captures.push(
|
||||
response.json().then((value) => {
|
||||
csrf = value.csrfToken;
|
||||
}),
|
||||
);
|
||||
});
|
||||
await page.route('**/api/decision/v1/**', async (route) => {
|
||||
const endpoint = new URL(route.request().url()).pathname.split('/').at(-1);
|
||||
if (endpoint === 'command' || endpoint === 'plan' || endpoint === 'decide') {
|
||||
if (calls[endpoint] >= { command: 1, plan: 1, decide: 13 }[endpoint]) {
|
||||
calls.blocked++;
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
calls[endpoint]++;
|
||||
} else if (endpoint === 'test') {
|
||||
calls.blocked++;
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
try {
|
||||
await collectRobotResults(page);
|
||||
await page.goto('/');
|
||||
await page.getByRole('tab', { name: '控制台', exact: true }).click();
|
||||
await page.getByRole('button', { name: '机器人语言控制', exact: true }).click();
|
||||
const panel = page.getByLabel('机器人语言控制', { exact: true });
|
||||
await panel.getByRole('button', { name: '载入预设场景' }).click();
|
||||
await expect(panel.getByRole('status')).toContainText('预设已载入并暂停', { timeout: 120000 });
|
||||
await expect(panel.getByRole('textbox', { name: '机器人指令' })).toHaveValue('把方块搬到 B 区');
|
||||
const result = await runWebsiteCommand(page, '把方块搬到 B 区', info, 'live-default-preset');
|
||||
expect(result.goal).toEqual([0.257, 0.75, 0.128]);
|
||||
expect(result.transported).toBeGreaterThan(0.7);
|
||||
expect(result.sample.onGoalSupport).toBe(true);
|
||||
expect(result.llmCalls).toBe(2);
|
||||
expect(result.jevCalls).toBe(14);
|
||||
expect(calls).toEqual({ command: 1, plan: 1, decide: 13, blocked: 0 });
|
||||
await page.screenshot({ path: info.outputPath('live-default-preset.png') });
|
||||
} finally {
|
||||
await Promise.all(captures);
|
||||
if (csrf)
|
||||
cleanup = await page
|
||||
.evaluate(
|
||||
async (token) =>
|
||||
(
|
||||
await fetch('/api/decision/v1/session', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
|
||||
body: '{}',
|
||||
})
|
||||
).status === 200,
|
||||
csrf,
|
||||
)
|
||||
.catch(() => false);
|
||||
await writeFile(
|
||||
info.outputPath('live-default-summary.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
calls,
|
||||
sessionDestroyed: cleanup,
|
||||
maximumUpstreamCalls: { llm: 2, jev: 14 },
|
||||
retries: 0,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
expect(cleanup).toBe(true);
|
||||
});
|
||||
|
||||
@@ -5,5 +5,10 @@ export default defineConfig(production, {
|
||||
testMatch: 'website.live.spec.ts',
|
||||
retries: 0,
|
||||
timeout: 360_000,
|
||||
use: { trace: 'off', video: 'off', screenshot: 'off' },
|
||||
use: {
|
||||
...(process.env.CADWORLD_LIVE_URL ? { baseURL: process.env.CADWORLD_LIVE_URL } : {}),
|
||||
trace: 'off',
|
||||
video: 'off',
|
||||
screenshot: 'off',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AgentTaskController, type SkillExecutor } from './AgentTaskController';
|
||||
import { MockDecisionProvider } from './MockDecisionProvider';
|
||||
import { PickPlaceEvaluator, type PhysicalSample } from './PhysicalEvidence';
|
||||
import { SKILLS, type Skill } from './protocol';
|
||||
import { LANGUAGE_VERSION } from './LanguageScene';
|
||||
|
||||
class FakeSkills implements SkillExecutor {
|
||||
phase: Skill = 'open';
|
||||
@@ -99,6 +100,27 @@ describe('模型闭环调度(技能为 mock,物理另有真实 WASM 验收
|
||||
expect(driver.results).toHaveLength(11);
|
||||
driver.dispose();
|
||||
});
|
||||
it('v2 veto remains in terminal telemetry and never authorizes motion', async () => {
|
||||
const skills = new FakeSkills();
|
||||
const provider = new MockDecisionProvider();
|
||||
const normal = provider.decide.bind(provider);
|
||||
provider.decide = async (...args) => {
|
||||
const reply = await normal(...args);
|
||||
reply.value.noul = 'uncertain';
|
||||
reply.value.reason = 'none';
|
||||
return reply;
|
||||
};
|
||||
const driver = new AgentTaskController(skills, provider, '搬运方块', {
|
||||
version: LANGUAGE_VERSION,
|
||||
});
|
||||
await run(driver);
|
||||
expect(driver.state).toBe('failed');
|
||||
expect(driver.error).toContain('模型无法确认下一技能安全');
|
||||
expect(driver.snapshot().decision).toMatchObject({ noul: 'uncertain', reason: 'none' });
|
||||
expect(skills.authorized).toEqual([]);
|
||||
expect(skills.sample.time).toBe(0);
|
||||
driver.dispose();
|
||||
});
|
||||
it('支持可恢复空抓,但跨重规划也不能重置每技能 2 次上限', async () => {
|
||||
const skills = new FakeSkills();
|
||||
skills.fault = 'empty_grasp';
|
||||
|
||||
@@ -136,7 +136,8 @@ export class AgentTaskController implements AgentDriver {
|
||||
phase: this.skills.phase,
|
||||
candidate: this.skills.candidate,
|
||||
plan: this.plan,
|
||||
decision: this.lastDecision,
|
||||
// Preserve even a vetoed response for diagnostics; it never authorizes a skill.
|
||||
decision: this.supervisor.last,
|
||||
llmCalls: this.llmCalls,
|
||||
jevCalls: this.jevCalls,
|
||||
replans: this.replans,
|
||||
|
||||
@@ -23,6 +23,32 @@ describe('Choice / Noul / Score', () => {
|
||||
new TaskSupervisor().inspect({ ...decision, noul }, ['carry'], LANGUAGE_VERSION, true),
|
||||
).toThrow('Noul');
|
||||
});
|
||||
it.each(['deny', 'uncertain'] as const)(
|
||||
'explains %s even when upstream reason is none',
|
||||
(noul) => {
|
||||
const supervisor = new TaskSupervisor();
|
||||
const rejected = { ...decision, noul, reason: 'none' };
|
||||
expect(() => supervisor.inspect(rejected, ['carry'], LANGUAGE_VERSION, true)).toThrow(
|
||||
noul === 'uncertain' ? '模型无法确认下一技能安全' : '模型拒绝继续',
|
||||
);
|
||||
expect(supervisor.last).toEqual(rejected);
|
||||
expect(() => supervisor.inspect(rejected, ['carry'], LANGUAGE_VERSION, true)).not.toThrow(
|
||||
'Noul 拦截:none',
|
||||
);
|
||||
},
|
||||
);
|
||||
it('does not let an allow score override choice/recovery stops', () => {
|
||||
const supervisor = new TaskSupervisor();
|
||||
for (const stop of [{ choice: 'stop' }, { recovery: 'stop' }])
|
||||
expect(() =>
|
||||
supervisor.inspect(
|
||||
{ ...decision, ...stop, reason: 'none' },
|
||||
['carry', 'stop'],
|
||||
LANGUAGE_VERSION,
|
||||
true,
|
||||
),
|
||||
).toThrow('Jev 请求安全停止:模型拒绝继续,未提供具体原因');
|
||||
});
|
||||
it('rejects illegal candidate, missing fields and arbitrary scores', () => {
|
||||
const s = new TaskSupervisor();
|
||||
expect(() => s.inspect(decision, ['open'], LANGUAGE_VERSION, true)).toThrow();
|
||||
|
||||
@@ -2,6 +2,19 @@ import { LANGUAGE_VERSION } from './LanguageScene';
|
||||
import { validateJevDecision, type AgentVersion, type JevDecision, type Skill } from './protocol';
|
||||
|
||||
export const QUALITY_POINTS = { poor: 0, partial: 25, good: 75, excellent: 100 } as const;
|
||||
const REASONS = {
|
||||
unsafe: '检测到不安全因素',
|
||||
insufficient_evidence: '当前阶段证据不足',
|
||||
tracking_error: '动作跟踪或对齐异常',
|
||||
verified_progress: '模型报告已有进展,但仍拒绝继续',
|
||||
} as const;
|
||||
function stopReason(decision: JevDecision): string {
|
||||
const reason = decision.reason;
|
||||
if (reason && reason !== 'none') return REASONS[reason];
|
||||
return decision.noul === 'uncertain'
|
||||
? '模型无法确认下一技能安全,未提供具体原因'
|
||||
: '模型拒绝继续,未提供具体原因';
|
||||
}
|
||||
/** Supervisory advice can veto but cannot grant physical authority. */
|
||||
export class TaskSupervisor {
|
||||
last?: JevDecision;
|
||||
@@ -18,9 +31,9 @@ export class TaskSupervisor {
|
||||
const decision = validateJevDecision(value, candidates, version);
|
||||
this.last = decision;
|
||||
if (version === LANGUAGE_VERSION && decision.noul !== 'allow')
|
||||
throw new Error(`Noul 拦截:${decision.reason ?? 'insufficient_evidence'}`);
|
||||
throw new Error(`Noul 拦截(${decision.noul}):${stopReason(decision)}`);
|
||||
if (decision.choice === 'stop' || decision.recovery === 'stop')
|
||||
throw new Error('Jev 请求安全停止');
|
||||
throw new Error(`Jev 请求安全停止:${stopReason(decision)}`);
|
||||
if (decision.grasp === 'secure' && !secure) throw new Error('Jev 抓持判断与本地物理证据矛盾');
|
||||
if (
|
||||
['carry', 'stop-base'].includes(decision.choice) &&
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { EditableMapDocument, MapEditorTransformMode } from '../map/editor/
|
||||
import type { MapEditorDraftPreviewInstance } from '../map/mapSceneDraft';
|
||||
import { MapEditorLayer } from './MapEditorLayer';
|
||||
import { ParametricMapPreviewLayer } from './ParametricMapPreviewLayer';
|
||||
import { filterSurfaceTexture, fitSceneShadow, viewerPixelRatio } from './renderQuality';
|
||||
|
||||
export type InteractionMode = 'select' | 'joint' | 'force';
|
||||
export type ViewerTheme = 'light' | 'dark';
|
||||
@@ -144,6 +145,7 @@ export class MuJoCoViewer {
|
||||
private mapEditorLayer: MapEditorLayer;
|
||||
private grid: THREE.GridHelper;
|
||||
private hemisphere: THREE.HemisphereLight;
|
||||
private readonly sun = new THREE.DirectionalLight(0xffffff, 2);
|
||||
private themeTransition?: {
|
||||
started: number;
|
||||
fromBackground: THREE.Color;
|
||||
@@ -177,8 +179,9 @@ export class MuJoCoViewer {
|
||||
String(gl.getParameter(debug.UNMASKED_RENDERER_WEBGL)),
|
||||
)
|
||||
: false;
|
||||
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75));
|
||||
this.renderer.setPixelRatio(viewerPixelRatio(devicePixelRatio, this.softwareRendering));
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
host.append(this.renderer.domElement);
|
||||
this.camera.up.set(0, 0, 1);
|
||||
@@ -209,10 +212,9 @@ export class MuJoCoViewer {
|
||||
this.scene.background = new THREE.Color(0x0b1220);
|
||||
this.hemisphere = new THREE.HemisphereLight(0xffffff, 0x223344, 1.3);
|
||||
this.scene.add(this.hemisphere);
|
||||
const light = new THREE.DirectionalLight(0xffffff, 2);
|
||||
light.position.set(4, -3, 7);
|
||||
light.castShadow = true;
|
||||
this.scene.add(light);
|
||||
this.sun.castShadow = true;
|
||||
fitSceneShadow(this.sun, [0, 0, 0], 2);
|
||||
this.scene.add(this.sun, this.sun.target);
|
||||
this.grid = new THREE.GridHelper(20, 40, 0x3b82f6, 0x253047).rotateX(Math.PI / 2);
|
||||
this.grid.visible = this.displayOptions.showGrid;
|
||||
this.scene.add(this.grid);
|
||||
@@ -628,6 +630,7 @@ export class MuJoCoViewer {
|
||||
this.cameraFocusTransition = undefined;
|
||||
const { extent, center } = session.geometryBounds();
|
||||
this.modelExtent = extent;
|
||||
fitSceneShadow(this.sun, center, extent);
|
||||
this.controls.target.set(center[0], center[1], center[2]);
|
||||
this.camera.position.set(
|
||||
center[0] + extent * 1.5,
|
||||
@@ -651,10 +654,19 @@ export class MuJoCoViewer {
|
||||
private resize(): void {
|
||||
const w = Math.max(1, this.host.clientWidth),
|
||||
h = Math.max(1, this.host.clientHeight);
|
||||
if (w === this.resizeWidth && h === this.resizeHeight) return;
|
||||
const ratio = viewerPixelRatio(devicePixelRatio, this.softwareRendering);
|
||||
if (
|
||||
w === this.resizeWidth &&
|
||||
h === this.resizeHeight &&
|
||||
ratio === this.renderer.getPixelRatio()
|
||||
)
|
||||
return;
|
||||
this.renderer.setPixelRatio(ratio);
|
||||
this.resizeWidth = w;
|
||||
this.resizeHeight = h;
|
||||
this.renderer.setSize(w, h, false);
|
||||
// CSS size must stay in logical pixels: otherwise DPR also enlarges/clips the canvas
|
||||
// and cancels the sensor viewport's resolution gain.
|
||||
this.renderer.setSize(w, h);
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
@@ -977,7 +989,7 @@ export class MuJoCoViewer {
|
||||
found = new THREE.DataTexture(data, w, h, THREE.RGBAFormat);
|
||||
found.colorSpace = THREE.SRGBColorSpace;
|
||||
found.flipY = true;
|
||||
found.needsUpdate = true;
|
||||
filterSurfaceTexture(found, this.renderer.capabilities.getMaxAnisotropy());
|
||||
this.textures.set(id, found);
|
||||
return found;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as THREE from 'three';
|
||||
import { filterSurfaceTexture, fitSceneShadow, viewerPixelRatio } from './renderQuality';
|
||||
import { MuJoCoViewer } from './MuJoCoViewer';
|
||||
|
||||
it('GPU supersampling is bounded; software rendering keeps its existing budget', () => {
|
||||
expect(viewerPixelRatio(1, false)).toBe(1.5);
|
||||
expect(viewerPixelRatio(1.75, false)).toBe(1.75);
|
||||
expect(viewerPixelRatio(3, false)).toBe(2);
|
||||
expect(viewerPixelRatio(1, true)).toBe(1);
|
||||
expect(viewerPixelRatio(3, true)).toBe(1.75);
|
||||
expect(viewerPixelRatio(NaN, false)).toBe(1.5);
|
||||
});
|
||||
|
||||
it('fits a 2048px biased shadow to the loaded scene, including translated scenes', () => {
|
||||
const light = new THREE.DirectionalLight();
|
||||
fitSceneShadow(light, [12, -3, 2], 1.5);
|
||||
expect(light.shadow.mapSize.toArray()).toEqual([2048, 2048]);
|
||||
expect(light.target.position.toArray()).toEqual([12, -3, 2]);
|
||||
expect(light.position.distanceTo(light.target.position)).toBeCloseTo(3);
|
||||
expect(light.shadow.camera.left).toBe(-1.5);
|
||||
expect(light.shadow.camera.right).toBe(1.5);
|
||||
expect(light.shadow.camera.far).toBe(6);
|
||||
expect(light.shadow.bias).toBeLessThan(0);
|
||||
expect(light.shadow.normalBias).toBeGreaterThan(0);
|
||||
fitSceneShadow(light, [0, 0, 0], 0.2);
|
||||
expect(light.shadow.camera.right).toBe(1);
|
||||
light.dispose();
|
||||
});
|
||||
|
||||
it('filters magnified and oblique textures without changing their pixels', () => {
|
||||
const pixels = new Uint8Array([255, 127, 0, 255]);
|
||||
const texture = new THREE.DataTexture(pixels, 1, 1);
|
||||
filterSurfaceTexture(texture, 16);
|
||||
expect(texture.magFilter).toBe(THREE.LinearFilter);
|
||||
expect(texture.minFilter).toBe(THREE.LinearMipmapLinearFilter);
|
||||
expect(texture.generateMipmaps).toBe(true);
|
||||
expect(texture.anisotropy).toBe(8);
|
||||
expect(texture.image.data).toBe(pixels);
|
||||
filterSurfaceTexture(texture, 2);
|
||||
expect(texture.anisotropy).toBe(2);
|
||||
texture.dispose();
|
||||
});
|
||||
|
||||
it('resizes logical CSS dimensions independently of DPR, including DPR-only changes', () => {
|
||||
vi.stubGlobal('devicePixelRatio', 2);
|
||||
try {
|
||||
let pixelRatio = 1;
|
||||
const renderer = {
|
||||
getPixelRatio: () => pixelRatio,
|
||||
setPixelRatio: vi.fn((value: number) => {
|
||||
pixelRatio = value;
|
||||
}),
|
||||
setSize: vi.fn(),
|
||||
};
|
||||
const viewer = Object.assign(Object.create(MuJoCoViewer.prototype), {
|
||||
host: { clientWidth: 800, clientHeight: 600 },
|
||||
renderer,
|
||||
camera: new THREE.PerspectiveCamera(),
|
||||
softwareRendering: false,
|
||||
resizeWidth: 800,
|
||||
resizeHeight: 600,
|
||||
}) as { resize(): void };
|
||||
viewer.resize();
|
||||
expect(renderer.setPixelRatio).toHaveBeenCalledWith(2);
|
||||
// Do not pass updateStyle=false: the DOM canvas would become 1600x1200 CSS pixels.
|
||||
expect(renderer.setSize).toHaveBeenCalledWith(800, 600);
|
||||
viewer.resize();
|
||||
expect(renderer.setSize).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/** Supersample low-DPI GPU displays too; software WebGL keeps its latency budget. */
|
||||
export function viewerPixelRatio(dpr: number, software: boolean): number {
|
||||
const ratio = Number.isFinite(dpr) && dpr > 0 ? dpr : 1;
|
||||
return software ? Math.min(ratio, 1.75) : Math.min(Math.max(ratio, 1.5), 2);
|
||||
}
|
||||
|
||||
/** Fit shadow texels to the loaded scene instead of a default 10 m / 512 px map. */
|
||||
export function fitSceneShadow(
|
||||
light: THREE.DirectionalLight,
|
||||
center: readonly number[],
|
||||
extent: number,
|
||||
): void {
|
||||
const radius = Math.max(1, extent);
|
||||
light.target.position.set(center[0], center[1], center[2]);
|
||||
light.position
|
||||
.set(4, -3, 7)
|
||||
.normalize()
|
||||
.multiplyScalar(radius * 2)
|
||||
.add(light.target.position);
|
||||
const shadow = light.shadow;
|
||||
shadow.mapSize.set(2048, 2048);
|
||||
Object.assign(shadow.camera, {
|
||||
left: -radius,
|
||||
right: radius,
|
||||
top: radius,
|
||||
bottom: -radius,
|
||||
near: radius * 0.01,
|
||||
far: radius * 4,
|
||||
});
|
||||
shadow.bias = -0.00005;
|
||||
shadow.normalBias = radius / 4096;
|
||||
shadow.camera.updateProjectionMatrix();
|
||||
shadow.needsUpdate = true;
|
||||
}
|
||||
|
||||
/** DataTexture defaults to nearest-neighbour; oblique camera views need mipmaps. */
|
||||
export function filterSurfaceTexture(texture: THREE.DataTexture, maxAnisotropy: number): void {
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.generateMipmaps = true;
|
||||
texture.anisotropy = Math.min(8, maxAnisotropy);
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
Reference in New Issue
Block a user