概述
在3D应用开发中,无障碍设计(Accessibility)常常被忽视,但对于确保所有用户(包括残障人士)都能访问和使用3D内容至关重要。本节将深入探讨如何为Three.js应用实现全面的无障碍支持,让3D体验对每个人都更加友好。

3D无障碍设计架构:
3D无障碍设计 视觉无障碍 听觉无障碍 运动无障碍 认知无障碍 屏幕阅读器支持 高对比度模式 颜色盲友好 音频描述 声音反馈 字幕支持 键盘导航 语音控制 简化交互 清晰导航 一致布局 渐进披露 平等访问 多感官体验 灵活操作 易于理解
核心无障碍原则
WCAG 2.1标准在3D环境中的应用
| 原则 | 3D应用实现 | 技术要点 |
|---|---|---|
| 可感知性 | 多感官反馈、文本替代 | 屏幕阅读器、音频提示、高对比度 |
| 可操作性 | 多种交互方式 | 键盘导航、语音控制、简化操作 |
| 可理解性 | 直观的3D界面 | 清晰标识、一致交互、错误预防 |
| 健壮性 | 跨设备兼容 | 标准API、渐进增强、兼容性 |
3D特定无障碍挑战
-
空间导航
- 3D空间中的方向感知
- 对象关系和层级表达
- 视点切换和场景理解
-
交互复杂性
- 多维度的操作控制
- 精确的目标选择
- 复杂的操作序列
-
信息密度
- 大量视觉信息同时呈现
- 深度和遮挡关系
- 动态变化的场景内容
完整无障碍实现
1. 屏幕阅读器支持系统
javascript
// ScreenReaderSupport.js
class ScreenReaderSupport {
constructor() {
this.ariaLiveRegion = null;
this.currentFocus = null;
this.objectDescriptions = new Map();
this.init();
}
init() {
this.createAriaLiveRegion();
this.setupFocusManagement();
this.setupKeyboardNavigation();
}
// 创建ARIA实时区域
createAriaLiveRegion() {
this.ariaLiveRegion = document.createElement('div');
this.ariaLiveRegion.setAttribute('aria-live', 'polite');
this.ariaLiveRegion.setAttribute('aria-atomic', 'true');
this.ariaLiveRegion.setAttribute('class', 'sr-only');
this.ariaLiveRegion.style.cssText = `
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
`;
document.body.appendChild(this.ariaLiveRegion);
}
// 设置焦点管理
setupFocusManagement() {
// 跟踪焦点变化
document.addEventListener('focusin', (event) => {
this.handleFocusChange(event.target);
});
// 为3D对象创建虚拟焦点
this.setupVirtualFocus();
}
// 设置虚拟焦点系统
setupVirtualFocus() {
// 创建不可见的焦点陷阱元素
this.focusTraps = new Map();
// 监听键盘事件进行3D导航
document.addEventListener('keydown', (event) => {
this.handle3DNavigation(event);
});
}
// 为3D对象注册描述
registerObject(object, description, options = {}) {
const objectInfo = {
description: description,
role: options.role || 'group',
label: options.label || '',
actions: options.actions || [],
position: object.position.clone(),
priority: options.priority || 0
};
this.objectDescriptions.set(object.uuid, objectInfo);
// 为对象添加ARIA属性
this.enhanceObjectWithARIA(object, objectInfo);
}
// 为对象增强ARIA属性
enhanceObjectWithARIA(object, objectInfo) {
object.userData.ariaLabel = objectInfo.label || objectInfo.description;
object.userData.ariaRole = objectInfo.role;
object.userData.accessibleActions = objectInfo.actions;
// 创建虚拟焦点元素
this.createVirtualFocusElement(object, objectInfo);
}
// 创建虚拟焦点元素
createVirtualFocusElement(object, objectInfo) {
const focusElement = document.createElement('div');
focusElement.setAttribute('role', objectInfo.role);
focusElement.setAttribute('aria-label', objectInfo.description);
focusElement.setAttribute('tabindex', '0');
focusElement.setAttribute('data-object-id', object.uuid);
focusElement.className = 'virtual-focus-element sr-only';
focusElement.style.cssText = `
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
`;
// 添加事件监听
focusElement.addEventListener('focus', () => {
this.onObjectFocus(object);
});
focusElement.addEventListener('keydown', (event) => {
this.onVirtualFocusKeydown(event, object);
});
document.body.appendChild(focusElement);
this.focusTraps.set(object.uuid, focusElement);
}
// 对象获得焦点
onObjectFocus(object) {
this.currentFocus = object;
const objectInfo = this.objectDescriptions.get(object.uuid);
if (objectInfo) {
this.announce(`${objectInfo.description} 已聚焦。按空格键选择,方向键导航。`);
// 视觉反馈:高亮显示
this.highlightObject(object);
}
}
// 虚拟焦点键盘事件
onVirtualFocusKeydown(event, object) {
const objectInfo = this.objectDescriptions.get(object.uuid);
switch (event.key) {
case ' ':
case 'Enter':
event.preventDefault();
this.activateObject(object);
break;
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
event.preventDefault();
this.navigateToAdjacentObject(object, event.key);
break;
case 'Escape':
this.exitObjectFocus();
break;
}
}
// 激活对象
activateObject(object) {
const objectInfo = this.objectDescriptions.get(object.uuid);
if (objectInfo && objectInfo.actions.length > 0) {
this.announce(`已选择 ${objectInfo.description}。可用操作:${objectInfo.actions.join(',')}`);
// 执行主要操作或显示操作菜单
this.showObjectActions(object);
} else {
this.announce(`${objectInfo.description} 已激活`);
}
}
// 导航到相邻对象
navigateToAdjacentObject(currentObject, direction) {
const adjacentObject = this.findAdjacentObject(currentObject, direction);
if (adjacentObject) {
const focusElement = this.focusTraps.get(adjacentObject.uuid);
if (focusElement) {
focusElement.focus();
}
} else {
this.announce('没有可导航的对象');
}
}
// 查找相邻对象
findAdjacentObject(currentObject, direction) {
const currentPos = currentObject.position;
let candidates = [];
// 收集所有可聚焦对象
for (const [uuid, objectInfo] of this.objectDescriptions) {
const object = this.findObjectByUUID(uuid);
if (object && object !== currentObject) {
const distance = object.position.distanceTo(currentPos);
const directionVector = new THREE.Vector3()
.subVectors(object.position, currentPos)
.normalize();
candidates.push({
object: object,
distance: distance,
direction: directionVector
});
}
}
// 根据方向筛选
candidates = candidates.filter(candidate => {
switch (direction) {
case 'ArrowUp':
return candidate.direction.y > 0.5;
case 'ArrowDown':
return candidate.direction.y < -0.5;
case 'ArrowLeft':
return candidate.direction.x < -0.5;
case 'ArrowRight':
return candidate.direction.x > 0.5;
default:
return true;
}
});
// 返回最近的对象
if (candidates.length > 0) {
candidates.sort((a, b) => a.distance - b.distance);
return candidates[0].object;
}
return null;
}
// 通过UUID查找对象
findObjectByUUID(uuid) {
// 在实际应用中,需要维护对象引用
for (const [storedUUID, objectInfo] of this.objectDescriptions) {
if (storedUUID === uuid) {
// 这里需要根据实际场景查找对象
return null; // 实际实现中返回真实对象
}
}
return null;
}
// 高亮对象
highlightObject(object) {
// 移除之前的高亮
this.clearHighlights();
// 添加高亮效果
object.userData.originalMaterial = object.material;
const highlightMaterial = new THREE.MeshBasicMaterial({
color: 0xffff00,
transparent: true,
opacity: 0.3
});
object.material = highlightMaterial;
// 添加轮廓效果
this.addOutlineEffect(object);
}
// 添加轮廓效果
addOutlineEffect(object) {
// 使用后期处理或复制网格添加轮廓
// 简化实现:改变材质颜色
if (object.material && object.material.emissive) {
object.material.emissive.set(0xffff00);
}
}
// 清除高亮
clearHighlights() {
for (const [uuid, objectInfo] of this.objectDescriptions) {
const object = this.findObjectByUUID(uuid);
if (object && object.userData.originalMaterial) {
object.material = object.userData.originalMaterial;
delete object.userData.originalMaterial;
}
}
}
// 宣布消息
announce(message, priority = 'polite') {
if (this.ariaLiveRegion) {
this.ariaLiveRegion.setAttribute('aria-live', priority);
this.ariaLiveRegion.textContent = message;
// 清除内容以便重复播报相同消息
setTimeout(() => {
this.ariaLiveRegion.textContent = '';
}, 1000);
}
// 同时输出到控制台用于调试
console.log(`[Screen Reader] ${message}`);
}
// 处理3D导航
handle3DNavigation(event) {
// 全局键盘导航处理
if (event.altKey && event.key === 'Tab') {
event.preventDefault();
this.cycleFocusThroughObjects(event.shiftKey);
}
}
// 循环遍历对象焦点
cycleFocusThroughObjects(reverse = false) {
const focusableElements = Array.from(this.focusTraps.values());
if (focusableElements.length === 0) return;
const currentIndex = focusableElements.findIndex(el =>
el === document.activeElement
);
let nextIndex;
if (reverse) {
nextIndex = currentIndex <= 0 ? focusableElements.length - 1 : currentIndex - 1;
} else {
nextIndex = currentIndex >= focusableElements.length - 1 ? 0 : currentIndex + 1;
}
focusableElements[nextIndex].focus();
}
// 场景变化通知
onSceneChange(description) {
this.announce(`场景已更新:${description}`, 'assertive');
}
// 对象状态变化通知
onObjectStateChange(object, newState) {
const objectInfo = this.objectDescriptions.get(object.uuid);
if (objectInfo) {
this.announce(`${objectInfo.description} 状态已变为:${newState}`);
}
}
// 清理资源
dispose() {
if (this.ariaLiveRegion) {
this.ariaLiveRegion.remove();
}
for (const focusElement of this.focusTraps.values()) {
focusElement.remove();
}
this.focusTraps.clear();
this.objectDescriptions.clear();
}
}
2. 高对比度和色盲友好模式
javascript
// ColorAccessibility.js
class ColorAccessibility {
constructor(renderer, scene) {
this.renderer = renderer;
this.scene = scene;
this.originalMaterials = new Map();
this.currentMode = 'default';
this.modes = {
default: { contrast: 1, adjustments: {} },
highContrast: { contrast: 1.5, adjustments: { saturation: 1.2 } },
protanopia: {
contrast: 1.3,
adjustments: {
colorMatrix: this.getProtanopiaMatrix()
}
},
deuteranopia: {
contrast: 1.3,
adjustments: {
colorMatrix: this.getDeuteranopiaMatrix()
}
},
tritanopia: {
contrast: 1.3,
adjustments: {
colorMatrix: this.getTritanopiaMatrix()
}
},
grayscale: {
contrast: 1.5,
adjustments: {
colorMatrix: this.getGrayscaleMatrix()
}
}
};
this.init();
}
init() {
this.setupColorModeSwitcher();
this.storeOriginalMaterials();
}
// 存储原始材质
storeOriginalMaterials() {
this.scene.traverse((object) => {
if (object.isMesh && object.material) {
this.originalMaterials.set(object.uuid, {
material: object.material,
originalColor: object.material.color?.clone(),
originalEmissive: object.material.emissive?.clone()
});
}
});
}
// 设置颜色模式切换器
setupColorModeSwitcher() {
// 创建模式切换UI
this.createModeSelector();
// 监听系统偏好
this.detectSystemPreferences();
}
// 创建模式选择器
createModeSelector() {
const container = document.createElement('div');
container.className = 'color-accessibility-panel';
container.innerHTML = `
<fieldset>
<legend>颜色辅助模式</legend>
<label>
<input type="radio" name="colorMode" value="default" checked>
默认模式
</label>
<label>
<input type="radio" name="colorMode" value="highContrast">
高对比度
</label>
<label>
<input type="radio" name="colorMode" value="protanopia">
红色盲模拟
</label>
<label>
<input type="radio" name="colorMode" value="deuteranopia">
绿色盲模拟
</label>
<label>
<input type="radio" name="colorMode" value="tritanopia">
蓝色盲模拟
</label>
<label>
<input type="radio" name="colorMode" value="grayscale">
灰度模式
</label>
</fieldset>
`;
container.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 1000;
font-family: Arial, sans-serif;
`;
document.body.appendChild(container);
// 添加事件监听
const radios = container.querySelectorAll('input[type="radio"]');
radios.forEach(radio => {
radio.addEventListener('change', (event) => {
this.setMode(event.target.value);
});
});
}
// 检测系统偏好
detectSystemPreferences() {
// 检测 prefers-contrast
if (window.matchMedia('(prefers-contrast: high)').matches) {
this.setMode('highContrast');
}
// 检测 prefers-color-scheme
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
this.adjustForDarkMode();
}
// 持续监听系统偏好变化
window.matchMedia('(prefers-contrast: high)').addEventListener('change', (event) => {
if (event.matches) {
this.setMode('highContrast');
}
});
}
// 设置颜色模式
setMode(modeName) {
if (!this.modes[modeName]) {
console.warn(`未知的颜色模式: ${modeName}`);
return;
}
this.currentMode = modeName;
const modeConfig = this.modes[modeName];
// 应用颜色调整
this.applyColorAdjustments(modeConfig);
// 更新UI反馈
this.updateModeIndicator(modeName);
// 保存用户偏好
this.saveUserPreference(modeName);
}
// 应用颜色调整
applyColorAdjustments(config) {
this.scene.traverse((object) => {
if (object.isMesh && object.material) {
const original = this.originalMaterials.get(object.uuid);
if (!original) return;
// 根据模式调整材质
this.adjustMaterialForMode(object.material, original, config);
}
});
// 应用后期处理效果
this.applyPostProcessingEffects(config);
}
// 为模式调整材质
adjustMaterialForMode(material, original, config) {
if (config.adjustments.colorMatrix) {
// 应用颜色矩阵
this.applyColorMatrix(material, config.adjustments.colorMatrix);
} else {
// 恢复原始颜色
if (material.color && original.originalColor) {
material.color.copy(original.originalColor);
}
}
// 调整对比度
if (config.contrast !== 1) {
this.adjustContrast(material, config.contrast);
}
// 调整饱和度
if (config.adjustments.saturation) {
this.adjustSaturation(material, config.adjustments.saturation);
}
material.needsUpdate = true;
}
// 应用颜色矩阵
applyColorMatrix(material, colorMatrix) {
// 创建着色器材质或使用后期处理
// 这里简化实现,实际应用中可能需要更复杂的处理
if (material.isShaderMaterial) {
material.uniforms.colorMatrix.value = colorMatrix;
}
}
// 调整对比度
adjustContrast(material, contrast) {
if (material.color) {
const original = this.originalMaterials.get(material.uuid);
if (original && original.originalColor) {
// 简化对比度调整
material.color.r = this.applyContrast(original.originalColor.r, contrast);
material.color.g = this.applyContrast(original.originalColor.g, contrast);
material.color.b = this.applyContrast(original.originalColor.b, contrast);
}
}
}
// 应用对比度计算
applyContrast(value, contrast) {
return ((value - 0.5) * contrast) + 0.5;
}
// 调整饱和度
adjustSaturation(material, saturation) {
if (material.color) {
const gray = material.color.r * 0.299 + material.color.g * 0.587 + material.color.b * 0.114;
material.color.r = gray + (material.color.r - gray) * saturation;
material.color.g = gray + (material.color.g - gray) * saturation;
material.color.b = gray + (material.color.b - gray) * saturation;
}
}
// 获取色盲模拟矩阵
getProtanopiaMatrix() {
// 红色盲模拟矩阵
return [
0.567, 0.433, 0, 0,
0.558, 0.442, 0, 0,
0, 0.242, 0.758, 0,
0, 0, 0, 1
];
}
getDeuteranopiaMatrix() {
// 绿色盲模拟矩阵
return [
0.625, 0.375, 0, 0,
0.7, 0.3, 0, 0,
0, 0.3, 0.7, 0,
0, 0, 0, 1
];
}
getTritanopiaMatrix() {
// 蓝色盲模拟矩阵
return [
0.95, 0.05, 0, 0,
0, 0.433, 0.567, 0,
0, 0.475, 0.525, 0,
0, 0, 0, 1
];
}
getGrayscaleMatrix() {
// 灰度矩阵
return [
0.299, 0.587, 0.114, 0,
0.299, 0.587, 0.114, 0,
0.299, 0.587, 0.114, 0,
0, 0, 0, 1
];
}
// 应用后期处理效果
applyPostProcessingEffects(config) {
// 在实际应用中,这里会设置后期处理着色器
// 使用颜色矩阵或自定义着色器应用全局颜色调整
console.log('应用后期处理效果:', config);
}
// 更新模式指示器
updateModeIndicator(modeName) {
const modeNames = {
default: '默认模式',
highContrast: '高对比度模式',
protanopia: '红色盲友好模式',
deuteranopia: '绿色盲友好模式',
tritanopia: '蓝色盲友好模式',
grayscale: '灰度模式'
};
// 显示模式切换反馈
if (window.screenReader) {
window.screenReader.announce(`已切换到${modeNames[modeName]}`);
}
}
// 保存用户偏好
saveUserPreference(modeName) {
localStorage.setItem('threejs-color-accessibility', modeName);
}
// 加载用户偏好
loadUserPreference() {
const savedMode = localStorage.getItem('threejs-color-accessibility');
if (savedMode && this.modes[savedMode]) {
this.setMode(savedMode);
}
}
// 为暗色模式调整
adjustForDarkMode() {
// 自动调整材质以适应暗色模式
this.scene.traverse((object) => {
if (object.isMesh && object.material) {
// 降低亮度,增加对比度
if (object.material.color) {
object.material.color.multiplyScalar(0.8);
}
}
});
// 调整场景背景
if (this.scene.background && this.scene.background.isColor) {
this.scene.background = new THREE.Color(0x1a1a1a);
}
}
}
3. 键盘导航和语音控制
javascript
// KeyboardNavigation.js
class KeyboardNavigation {
constructor(camera, scene, renderer) {
this.camera = camera;
this.scene = scene;
this.renderer = renderer;
this.navigationMode = 'object'; // 'object', 'camera', 'ui'
this.focusStack = [];
this.keyboardState = new Set();
this.init();
}
init() {
this.setupEventListeners();
this.createNavigationHelp();
this.setupVoiceControl();
}
// 设置事件监听器
setupEventListeners() {
document.addEventListener('keydown', (event) => {
this.onKeyDown(event);
});
document.addEventListener('keyup', (event) => {
this.onKeyUp(event);
});
// 防止Tab键失去焦点
this.renderer.domElement.setAttribute('tabindex', '0');
this.renderer.domElement.addEventListener('keydown', (event) => {
if (event.key === 'Tab') {
event.preventDefault();
}
});
}
// 键盘按下处理
onKeyDown(event) {
this.keyboardState.add(event.key);
// 防止默认行为
if (this.isNavigationKey(event.key)) {
event.preventDefault();
}
this.handleNavigation(event);
}
// 键盘释放处理
onKeyUp(event) {
this.keyboardState.delete(event.key);
}
// 检查是否是导航键
isNavigationKey(key) {
const navigationKeys = [
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
'Tab', 'Enter', ' ', 'Escape', 'Home', 'End'
];
return navigationKeys.includes(key);
}
// 处理导航
handleNavigation(event) {
switch (this.navigationMode) {
case 'object':
this.handleObjectNavigation(event);
break;
case 'camera':
this.handleCameraNavigation(event);
break;
case 'ui':
this.handleUINavigation(event);
break;
}
}
// 对象导航处理
handleObjectNavigation(event) {
switch (event.key) {
case 'Tab':
if (event.shiftKey) {
this.previousObject();
} else {
this.nextObject();
}
break;
case 'ArrowUp':
this.navigateDirection('up');
break;
case 'ArrowDown':
this.navigateDirection('down');
break;
case 'ArrowLeft':
this.navigateDirection('left');
break;
case 'ArrowRight':
this.navigateDirection('right');
break;
case ' ':
case 'Enter':
this.activateCurrentObject();
break;
case 'Escape':
this.exitObjectMode();
break;
case 'c':
case 'C':
this.switchToCameraMode();
break;
}
}
// 相机导航处理
handleCameraNavigation(event) {
const moveSpeed = 0.5;
const rotateSpeed = 0.02;
switch (event.key) {
case 'ArrowUp':
this.camera.translateY(moveSpeed);
break;
case 'ArrowDown':
this.camera.translateY(-moveSpeed);
break;
case 'ArrowLeft':
this.camera.translateX(-moveSpeed);
break;
case 'ArrowRight':
this.camera.translateX(moveSpeed);
break;
case 'w':
case 'W':
this.camera.translateZ(-moveSpeed);
break;
case 's':
case 'S':
this.camera.translateZ(moveSpeed);
break;
case 'a':
case 'A':
this.camera.rotation.y += rotateSpeed;
break;
case 'd':
case 'D':
this.camera.rotation.y -= rotateSpeed;
break;
case 'Escape':
case 'o':
case 'O':
this.switchToObjectMode();
break;
}
this.announceCameraPosition();
}
// 导航到下一个对象
nextObject() {
const focusableObjects = this.getFocusableObjects();
if (focusableObjects.length === 0) return;
const currentIndex = this.getCurrentObjectIndex();
const nextIndex = (currentIndex + 1) % focusableObjects.length;
this.focusObject(focusableObjects[nextIndex]);
}
// 导航到上一个对象
previousObject() {
const focusableObjects = this.getFocusableObjects();
if (focusableObjects.length === 0) return;
const currentIndex = this.getCurrentObjectIndex();
const prevIndex = currentIndex <= 0 ? focusableObjects.length - 1 : currentIndex - 1;
this.focusObject(focusableObjects[prevIndex]);
}
// 方向导航
navigateDirection(direction) {
const currentObject = this.getCurrentFocusedObject();
if (!currentObject) return;
const adjacentObject = this.findObjectInDirection(currentObject, direction);
if (adjacentObject) {
this.focusObject(adjacentObject);
} else {
this.announce(`${direction}方向没有可导航的对象`);
}
}
// 获取可聚焦对象
getFocusableObjects() {
const objects = [];
this.scene.traverse((object) => {
if (object.userData && object.userData.focusable) {
objects.push(object);
}
});
// 按位置排序(从左到右,从上到下)
objects.sort((a, b) => {
if (a.position.y !== b.position.y) {
return b.position.y - a.position.y; // 从上到下
}
return a.position.x - b.position.x; // 从左到右
});
return objects;
}
// 获取当前对象索引
getCurrentObjectIndex() {
const currentObject = this.getCurrentFocusedObject();
const focusableObjects = this.getFocusableObjects();
return focusableObjects.findIndex(obj => obj === currentObject);
}
// 获取当前聚焦对象
getCurrentFocusedObject() {
return this.focusStack.length > 0 ?
this.focusStack[this.focusStack.length - 1] : null;
}
// 聚焦对象
focusObject(object) {
// 移除之前的高亮
this.clearFocus();
// 添加新焦点
this.focusStack.push(object);
// 视觉反馈
this.highlightObject(object);
// 屏幕阅读器通知
this.announceObjectFocus(object);
// 相机看向对象
this.lookAtObject(object);
}
// 宣布对象焦点
announceObjectFocus(object) {
const description = object.userData.ariaLabel ||
object.userData.description ||
'未命名对象';
const actions = object.userData.accessibleActions || [];
const actionText = actions.length > 0 ?
`可用操作:${actions.join(',')}` : '按空格键激活';
this.announce(`${description}。${actionText}`);
}
// 宣布相机位置
announceCameraPosition() {
const position = this.camera.position;
const roundedX = Math.round(position.x * 10) / 10;
const roundedY = Math.round(position.y * 10) / 10;
const roundedZ = Math.round(position.z * 10) / 10;
this.announce(`相机位置:X ${roundedX}, Y ${roundedY}, Z ${roundedZ}`, 'off');
}
// 看向对象
lookAtObject(object) {
// 创建看向目标的动画
const targetPosition = object.position.clone();
const cameraPosition = this.camera.position.clone();
// 计算看向方向
const direction = new THREE.Vector3()
.subVectors(targetPosition, cameraPosition)
.normalize();
// 平滑过渡
this.animateCameraLook(direction);
}
// 动画相机看向
animateCameraLook(targetDirection, duration = 500) {
const startRotation = this.camera.rotation.clone();
const targetRotation = new THREE.Euler().setFromRotationMatrix(
new THREE.Matrix4().lookAt(
this.camera.position,
new THREE.Vector3().addVectors(
this.camera.position,
targetDirection
),
new THREE.Vector3(0, 1, 0)
)
);
const startTime = performance.now();
const animate = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// 使用缓动函数
const easedProgress = this.easeInOutCubic(progress);
this.camera.rotation.x = startRotation.x +
(targetRotation.x - startRotation.x) * easedProgress;
this.camera.rotation.y = startRotation.y +
(targetRotation.y - startRotation.y) * easedProgress;
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
// 缓动函数
easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// 设置语音控制
setupVoiceControl() {
if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
this.setupSpeechRecognition();
} else {
console.log('浏览器不支持语音识别');
}
}
// 设置语音识别
setupSpeechRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
this.recognition = new SpeechRecognition();
this.recognition.continuous = true;
this.recognition.interimResults = true;
this.recognition.lang = 'zh-CN';
this.recognition.onresult = (event) => {
const transcript = Array.from(event.results)
.map(result => result[0].transcript)
.join('');
this.processVoiceCommand(transcript);
};
this.recognition.onerror = (event) => {
console.log('语音识别错误:', event.error);
};
// 添加语音控制开关
this.createVoiceControlToggle();
}
// 处理语音命令
processVoiceCommand(transcript) {
const commands = {
'下一个': () => this.nextObject(),
'上一个': () => this.previousObject(),
'选择': () => this.activateCurrentObject(),
'退出': () => this.exitObjectMode(),
'相机模式': () => this.switchToCameraMode(),
'对象模式': () => this.switchToObjectMode(),
'向左': () => this.navigateDirection('left'),
'向右': () => this.navigateDirection('right'),
'向上': () => this.navigateDirection('up'),
'向下': () => this.navigateDirection('down')
};
for (const [command, action] of Object.entries(commands)) {
if (transcript.includes(command)) {
action();
this.announce(`执行命令:${command}`);
break;
}
}
}
// 创建语音控制开关
createVoiceControlToggle() {
const button = document.createElement('button');
button.textContent = '🎤 语音控制';
button.className = 'voice-control-toggle';
button.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
padding: 10px 15px;
background: #007acc;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
z-index: 1000;
`;
let isListening = false;
button.addEventListener('click', () => {
if (isListening) {
this.recognition.stop();
button.textContent = '🎤 语音控制';
button.style.background = '#007acc';
} else {
this.recognition.start();
button.textContent = '🔴 监听中...';
button.style.background = '#cc0000';
}
isListening = !isListening;
});
document.body.appendChild(button);
}
// 创建导航帮助
createNavigationHelp() {
const helpButton = document.createElement('button');
helpButton.textContent = '? 导航帮助';
helpButton.className = 'navigation-help-toggle';
helpButton.style.cssText = `
position: fixed;
bottom: 20px;
left: 20px;
padding: 10px 15px;
background: #28a745;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
z-index: 1000;
`;
helpButton.addEventListener('click', () => {
this.showNavigationHelp();
});
document.body.appendChild(helpButton);
}
// 显示导航帮助
showNavigationHelp() {
const helpText = `
<h3>3D场景导航帮助</h3>
<h4>对象导航模式:</h4>
<ul>
<li><kbd>Tab</kbd> - 下一个对象</li>
<li><kbd>Shift + Tab</kbd> - 上一个对象</li>
<li><kbd>方向键</kbd> - 按方向导航</li>
<li><kbd>空格</kbd> 或 <kbd>Enter</kbd> - 激活对象</li>
<li><kbd>Esc</kbd> - 退出对象模式</li>
<li><kbd>C</kbd> - 切换到相机模式</li>
</ul>
<h4>相机导航模式:</h4>
<ul>
<li><kbd>方向键</kbd> - 移动相机</li>
<li><kbd>WASD</kbd> - 移动和旋转相机</li>
<li><kbd>Esc</kbd> 或 <kbd>O</kbd> - 返回对象模式</li>
</ul>
<h4>语音命令:</h4>
<ul>
<li>"下一个" - 下一个对象</li>
<li>"上一个" - 上一个对象</li>
<li>"选择" - 激活对象</li>
<li>"向左/右/上/下" - 方向导航</li>
<li>"相机模式" - 切换模式</li>
</ul>
`;
const helpDialog = document.createElement('div');
helpDialog.className = 'navigation-help-dialog';
helpDialog.innerHTML = helpText;
helpDialog.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
z-index: 1001;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
`;
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
`;
overlay.addEventListener('click', () => {
document.body.removeChild(overlay);
document.body.removeChild(helpDialog);
});
document.body.appendChild(overlay);
document.body.appendChild(helpDialog);
// 屏幕阅读器通知
this.announce('已打开导航帮助对话框');
}
// 通用消息宣布
announce(message, priority = 'polite') {
if (window.screenReader) {
window.screenReader.announce(message, priority);
} else {
console.log(`[Navigation] ${message}`);
}
}
}
4. 无障碍CSS样式
css
/* Accessibility.css */
.sr-only {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
.virtual-focus-element:focus {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* 高对比度模式样式 */
.high-contrast {
--text-color: #ffffff;
--background-color: #000000;
--focus-color: #ffff00;
--border-color: #ffffff;
}
.high-contrast .virtual-focus-element:focus {
outline: 3px solid var(--focus-color);
}
/* 减少动画偏好 */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* 增大光标偏好 */
@media (pointer: coarse) {
.virtual-focus-element {
min-width: 44px;
min-height: 44px;
}
}
/* 暗色模式支持 */
@media (prefers-color-scheme: dark) {
.color-accessibility-panel {
background: #2d2d2d;
color: #ffffff;
}
}
/* 焦点可见性 */
.focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* 无障碍工具提示 */
.accessibility-tooltip {
position: absolute;
background: #000000;
color: #ffffff;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
z-index: 10000;
max-width: 300px;
pointer-events: none;
}
/* 大字模式支持 */
.large-text-mode {
font-size: 1.5em;
}
.large-text-mode .virtual-focus-element {
min-height: 60px;
min-width: 60px;
}
无障碍测试与验证
自动化无障碍测试
javascript
// AccessibilityValidator.js
class AccessibilityValidator {
constructor(scene, renderer) {
this.scene = scene;
this.renderer = renderer;
this.issues = [];
}
// 运行完整验证
async runFullValidation() {
this.issues = [];
await this.validateScreenReaderSupport();
await this.validateKeyboardNavigation();
await this.validateColorAccessibility();
await this.validateTextAlternatives();
await this.validateFocusManagement();
return this.generateReport();
}
// 验证屏幕阅读器支持
async validateScreenReaderSupport() {
const objects = this.collectSceneObjects();
objects.forEach(object => {
// 检查是否有ARIA标签
if (!object.userData.ariaLabel && !object.userData.description) {
this.issues.push({
type: 'screen_reader',
severity: 'medium',
message: `对象 ${object.uuid} 缺少屏幕阅读器描述`,
element: object
});
}
// 检查角色定义
if (!object.userData.ariaRole) {
this.issues.push({
type: 'screen_reader',
severity: 'low',
message: `对象 ${object.uuid} 缺少ARIA角色`,
element: object
});
}
});
}
// 验证键盘导航
async validateKeyboardNavigation() {
const focusableObjects = this.collectFocusableObjects();
if (focusableObjects.length === 0) {
this.issues.push({
type: 'keyboard_navigation',
severity: 'high',
message: '场景中没有可键盘导航的对象',
element: null
});
}
// 检查Tab顺序
this.validateTabOrder(focusableObjects);
// 检查键盘陷阱
this.validateKeyboardTraps();
}
// 验证颜色可访问性
async validateColorAccessibility() {
const materials = this.collectMaterials();
materials.forEach(material => {
// 检查颜色对比度
if (material.color) {
const contrast = this.calculateColorContrast(material.color);
if (contrast < 4.5) {
this.issues.push({
type: 'color_contrast',
severity: 'medium',
message: `材质颜色对比度不足: ${contrast.toFixed(2)}`,
element: material
});
}
}
// 检查颜色单独使用
if (material.userData && material.userData.usesColorAlone) {
this.issues.push({
type: 'color_usage',
severity: 'medium',
message: '颜色被单独用于传达信息',
element: material
});
}
});
}
// 收集场景对象
collectSceneObjects() {
const objects = [];
this.scene.traverse(object => {
if (object.isMesh) {
objects.push(object);
}
});
return objects;
}
// 收集可聚焦对象
collectFocusableObjects() {
return this.collectSceneObjects().filter(obj =>
obj.userData && obj.userData.focusable
);
}
// 收集材质
collectMaterials() {
const materials = new Set();
this.scene.traverse(object => {
if (object.material) {
materials.add(object.material);
}
});
return Array.from(materials);
}
// 计算颜色对比度
calculateColorContrast(color) {
// 简化实现 - 实际需要计算与背景的对比度
const luminance = this.calculateLuminance(color);
const backgroundLuminance = 0.05; // 假设暗色背景
const lighter = Math.max(luminance, backgroundLuminance);
const darker = Math.min(luminance, backgroundLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
// 计算亮度
calculateLuminance(color) {
const r = color.r <= 0.03928 ? color.r / 12.92 : Math.pow((color.r + 0.055) / 1.055, 2.4);
const g = color.g <= 0.03928 ? color.g / 12.92 : Math.pow((color.g + 0.055) / 1.055, 2.4);
const b = color.b <= 0.03928 ? color.b / 12.92 : Math.pow((color.b + 0.055) / 1.055, 2.4);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
// 生成报告
generateReport() {
const summary = {
totalIssues: this.issues.length,
bySeverity: {
high: this.issues.filter(issue => issue.severity === 'high').length,
medium: this.issues.filter(issue => issue.severity === 'medium').length,
low: this.issues.filter(issue => issue.severity === 'low').length
},
byType: {}
};
this.issues.forEach(issue => {
summary.byType[issue.type] = (summary.byType[issue.type] || 0) + 1;
});
return {
summary: summary,
issues: this.issues,
timestamp: new Date().toISOString()
};
}
}
通过实施这些无障碍设计策略,可以确保Three.js应用对所有用户都更加友好和可访问。无障碍设计不仅是一项法律要求,更是创造更好用户体验的重要实践。