æ£å¦å¤§éçåç§å£°é³å¤çï¼æè å ¶ä»ï¼éæ©æ¯ä¸å¤çï¼WebAduioAPI ä¹å å«äºä¸äºå·¥å ·ï¼å¯ä»¥è®©ä½ 模仿å¬ä¼å¨å£°æºå¨å´ç§»å¨æ¶ç声é³å·®å¼ï¼ä¾å¦å½ä½ å¨ 3D 游æå£°æºä¸ç§»å¨æ¶å£°é³ç平移ã宿¹åè¯ç§°ä¸º 空é´åï¼è¿ç¯æç« å°ä¼ä»ç»å¦ä½å®ç°è¿æ ·ä¸ä¸ªç³»ç»çåºç¡ç¥è¯ã
空é´åçåºç¡ç¥è¯å¨ Web Audio ä¸ï¼å¤æç 3D 空é´åæ¯ä½¿ç¨ PannerNode
å建çï¼ç¨å¤è¡äººçè¯æ¥è¯´å°±æ¯ä¸ä¸ªä½¿é³é¢åºç°å¨ 3D 空é´ä¸çå¾é
·çæ°å¦ãæ³è±¡ä¸ä¸å£°é³ä»ä½ 头ä¸é£è¿ï¼ç¬å°ä½ 身åï¼å¨ä½ é¢åç§»å¨ãè¯¸å¦æ¤ç±»çäºæ
ã
å®å¯¹ WebXR 忏¸æé常æç¨ãå¨ 3D 空é´ä¸ï¼å®æ¯å®ç°é¼ççé³é¢ææçå¯ä¸æ¹å¼ãå three.js å A-frame è¿æ ·çåºå¨å¤ç声鳿¶å°±å©ç¨äºå®çæ½åãå¼å¾æ³¨æçæ¯ï¼ä½ ä¸å¿ å¨å®æ´ç 3D 空é´ä¸ç§»å¨å£°é³ - ä½ å¯ä»¥åªä½¿ç¨ 2D å¹³é¢ï¼å æ¤å¦æä½ 计åå®ç°ä¸ä¸ª 2D 游æï¼è¿ä¾ç¶æ¯ä½ è¦å¯»æ¾çèç¹ã
夿³¨ï¼ è¿æä¸ä¸ªè®¾è®¡ç¨äºå¤çå建ç®åçå·¦å³ç«ä½å£°å¹³ç§»ææç StereoPannerNode
ãè¿ä½¿ç¨èµ·æ¥æ´ç®åï¼ä½æ¾ç¶æ å¤å¯ç¨ãå¦æä½ åªæ³è¦ä¸ä¸ªç®åçç«ä½å£°å¹³ç§»ææï¼æä»¬ç StereoPannerNode 示ä¾ï¼è¯·åé
æºç ï¼åºè¯¥å¯ä»¥ä¸ºä½ æä¾æéçä¸åã
ä¸ºäºæ¼ç¤º 3D 空é´åï¼æä»¬å¨ Using the Web Audio API æåä¸ç boombox æ¼ç¤ºçåºç¡ä¸å建ä¸ä¸ªä¿®æ¹çæ¬ãåè§ 3D spatialization demo live ï¼åæ¶ä¹å¯ä»¥ç source codeï¼
é³ç®±æ¾ç½®äºæ¿é´ä¸ï¼ç±æµè§å¨è§åºè¾¹ç¼å®ä¹ï¼ï¼å¨æ¬ demo 䏿们å¯ä»¥éè¿æä¾çæ§ä»¶ç§»å¨åæè½¬å®ãå½æä»¬ç§»å¨é³ç®±æ¶ï¼å®äº§çç声é³ä¼ç¸åºçæ¹åï¼å½å®å¨ç§»å¨å°æ¿é´ç左侧æå³ä¾§æ¶å£°é³å¹³ç§»ï¼æå½å®è¿ç¦»ç¨æ·æ¶åå¾å®éï¼ææè½¬ä½¿å¾æ¬å£°å¨è离å®ä»¬çãè¿æ¯éè¿ç» PannerNode
对象å®ä¾è®¾ç½®ä¸åçä¸è¯¥è¿å¨æå
³ç屿§æ¥å®ç°æ¨¡æç©ºé´åã
夿³¨ï¼ å¦æä½ ä½¿ç¨è³æºæè å ¶ä»æç§ç¯ç»å£°ç³»ç»è¿æ¥è®¡ç®æºï¼ä½éªä¼æ´å¥½ã
å建 audio æ¶å¬è让æä»¬å¼å§ï¼ BaseAudioContext
ï¼ AudioContext
æ©å±èªè¯¥æ¥å£ï¼æä¸ä¸ª listener 屿§ï¼è¿åä¸ä¸ª AudioListener
对象ãè¿ä»£è¡¨çåºæ¯æ¶å¬è
ï¼é常æ¯ä½¿ç¨è
ï¼ç¨æ·ï¼ãä½ å¯ä»¥å®ä¹ä» (她) 们å¨ç©ºé´ä¸çä½ç½®åä» (她) 们é¢åçæ¹åãä» (她) ä»¬ä¿æéæ¢ã pannerNode
å¯ä»¥è®¡ç®åºå£°é³ç¸å¯¹äºæ¶å¬è
ä½ç½®çä½ç½®ã
让æä»¬å建æä»¬çä¸ä¸æåçå¬å¨ï¼å¹¶è®¾ç½®æ¶å¬è çä½ç½®æ¥æ¨¡æä¸ä¸ªçåï¼æ¢ç´¢ï¼æä»¬æ¿é´ç人ï¼
const AudioContext = window.AudioContext || window.webkitAudioContext;
const audioCtx = new AudioContext();
const listener = audioCtx.listener;
const posX = window.innerWidth / 2;
const posY = window.innerHeight / 2;
const posZ = 300;
listener.positionX.value = posX;
listener.positionY.value = posY;
listener.positionZ.value = posZ - 5;
æä»¬å¯ä»¥ä½¿ç¨ positionX
å°æ¶å¬è
åå·¦/å³ç§»å¨ï¼ä½¿ç¨ positionY
åä¸/ä¸ç§»å¨ï¼æä½¿ç¨ positionZ
ç§»åº/å
¥æ¿é´ãå¨è¿éï¼æä»¬å°æ¶å¬è
设置å¨è§å£ä¸é´åæ¶ç¨å¾®ä½äºé³ç®±çåæ¹ãæä»¬è¿å¯ä»¥è®¾ç½®æ¶å¬è
é¢å¯¹çæ¹åãè¿äºé»è®¤å¼å·¥ä½è¯å¥½ï¼
listener.forwardX.value = 0;
listener.forwardY.value = 0;
listener.forwardZ.value = -1;
listener.upX.value = 0;
listener.upY.value = 1;
listener.upZ.value = 0;
è¿äº forward 屿§ä»£è¡¨äºæ¶å¬è åè¿æ¹åç 3D åæ ä½ç½®ï¼ä¾å¦ä»/她们é¢åçæ¹åï¼ï¼è up 屿§è¡¨ç¤ºäºæ¶å¬è 头顶ç 3D åæ ä½ç½®ãè¿ä¸¤ç§å±æ§å¼å¯ä»¥å¾å¥½çè®¾å®æ¹ä½ã
å建 panner èç¹è®©æä»¬å建 PannerNode
èç¹ï¼è¿æå¾å¤ä¸ä¹ç¸å
³ç屿§ã让æä»¬æ¥ä¸ä¸ççï¼
é¦å
æä»¬å¯ä»¥è®¾ç½® panningModel
ãè¿æ¯ç¨äºå¨ 3D 空é´ä¸å®ä½é³é¢ç空é´åç®æ³ãæä»¬å¯ä»¥å°å
¶è®¾ç½®ä¸ºï¼
equalpower
â 计ç®åºé»è®¤åä¸è¬æ¹å¼ç平移ã
HRTF
â è¿ä»£è¡¨ 'Head-related transfer function' ï¼å¨å¼æ¸
æ¥å£°é³çä½ç½®æ¶ï¼ä¼èè人èï¼å¯¹å£°é³çå¤çï¼ã
éå¸¸èªæçä¸è¥¿ï¼è®©æä»¬ä½¿ç¨ HRTF
模åï¼
const pannerModel = "HRTF";
屿§ coneInnerAngle
å coneOuterAngle
æå®é³éååºçä½ç½®ãé»è®¤æ
åµä¸ï¼ä¸¤è
齿¯ 360 度ãæä»¬å¯ä»¥å®ä¹é³ç®±æ¬å£°å¨æ¥æè¾å°çé¥ä½ãå
饿¯æ»æ¯æ¨¡æå¢çï¼é³éï¼æå¤§å¼çå°æ¹ï¼å¤é¥æ¯å¢çå¼å§ä¸éçå°æ¹ã
å¢çéè¿ coneOuterGain
弿¥åå°ã让æä»¬å建ä¹åå°ä¼ç¨äºè¿äºåæ°ç常éï¼
const innerCone = 60;
const outerCone = 90;
const outerGain = 0.3;
ä¸ä¸ä¸ªåæ°æ¯ distanceModel
â è¿åªè½è®¾ç½®ä¸º linear
, inverse
, æè
exponential
ãè¿äºæ¯ä¸åçç®æ³ï¼ç¨äºå¨é³é¢æºè¿ç¦»æ¶å¬è
æ¶åå°é³é¢æºçé³éã æä»¬å°ä½¿ç¨linear
ï¼å 为å®å¾ç®åï¼
const distanceModel = "linear";
æä»¬å¯ä»¥è®¾ç½®æºåæ¶å¬è
ä¹é´çæå¤§è·ç¦» (maxDistance
) â 妿æºè·ç¦»æ¤ç¹æ´è¿ï¼åé³éå°ä¸ååå°ãè¿å¯è½å¾æç¨ï¼å ä¸ºä½ å¯è½ä¼åç°ä½ æ³è¦æ¨¡æè·ç¦»ï¼ä½æ¯é³éä¼ä¸éï¼èå®é
ä¸å¹¶ä¸æ¯ä½ æ³è¦çãé»è®¤æ
åµä¸ï¼å®æ¯ 10,000ï¼æ åä½çç¸å¯¹å¼ï¼ãæä»¬å¯ä»¥åè¿æ ·ä¿æå®ï¼
const maxDistance = 10000;
è¿æä¸ä¸ªåèè·ç¦» (refDistance
)ï¼ç±è·ç¦»æ¨¡å使ç¨ãæä»¬ä¹å¯ä»¥å°å
¶ä¿æä¸ºé»è®¤å¼ 1
ï¼
ç¶åå°±æ¯ roll-off å å (rolloffFactor
) â æè¿°éç panner è¿ç¦»æ¶å¬è
ï¼é³éåå°çé度æå¤å¿«ãé»è®¤å¼ä¸º 1ï¼è®©æä»¬ä½¿å
¶å¤§ä¸äºä»¥æ¾å¤§æä»¬çå¨ä½ã
ç°å¨æä»¬å¯ä»¥å¼å§è®¾ç½®æä»¬ boombox çä½ç½®åæ¹åãè¿ä¸æä»¬å¦ä½è®¾ç½®æ¶å¬è å¾åã è¿äºä¹æ¯æä»¬å¨ä½¿ç¨çé¢ä¸çæ§ä»¶æ¶è¦æ¹åçåæ°ã
const positionX = posX;
const positionY = posY;
const positionZ = posZ;
const orientationX = 0.0;
const orientationY = 0.0;
const orientationZ = -1.0;
注æ z æ¹åçè´å¼ - è¿ä¼å° boombox 设置为é¢åæä»¬ãæ£å¼ä¼ä½¿å£°æºè离æä»¬ã 让æä»¬ä½¿ç¨ç¸å ³çæé 彿°æ¥å建æä»¬ç panner èç¹ï¼å¹¶ä¼ å ¥æä»¬å¨ä¸é¢è®¾ç½®çææåæ°ï¼
const panner = new PannerNode(audioCtx, {
panningModel: pannerModel,
distanceModel: distanceModel,
positionX: positionX,
positionY: positionY,
positionZ: positionZ,
orientationX: orientationX,
orientationY: orientationY,
orientationZ: orientationZ,
refDistance: refDistance,
maxDistance: maxDistance,
rolloffFactor: rollOff,
coneInnerAngle: innerCone,
coneOuterAngle: outerCone,
coneOuterGain: outerGain,
});
ç§»å¨ boombox
ç°å¨æä»¬å°å¨æä»¬çâæ¿é´âä¸åå¤ç§»å¨ boomboxãæä»¬å·²ç»è®¾ç½®äºä¸äºæ§ä»¶æ¥æ§è¡æ¤æä½ãæä»¬å¯ä»¥å·¦å³ç§»å¨ï¼ä¸ä¸ç§»å¨ï¼æ¥åç§»å¨ï¼æä»¬ä¹å¯ä»¥æè½¬å®ã声鳿¹åæ¥èªåé¢ç boombox çæ¬å£°å¨ï¼å æ¤å½æä»¬æè½¬å®æ¶ï¼æä»¬å¯ä»¥æ¹å声é³çæ¹å - å³å½é³ç®±æè½¬ 180 度并èåæä»¬æ¶ï¼ä½¿å ¶ååæå°ã æä»¬éè¦ä¸ºçé¢è®¾ç½®ä¸äºä¸è¥¿ãé¦å ï¼æä»¬å°è·å¾æä»¬æ³è¦ç§»å¨çå ç´ çå¼ç¨ï¼ç¶åå卿们å¨è®¾ç½® CSS transforms æ¥å®é æ§è¡ç§»å¨æ¶å°è¦æ´æ¹çå¼çå¼ç¨ã æåï¼æä»¬å°è®¾ç½®ä¸äºè¾¹çå¼ï¼ä»¥ä¾¿æä»¬ç boombox å¨ä»»ä½æ¹åä¸é½ä¸ä¼èµ°å¾å¤ªè¿ï¼
const moveControls = document
.querySelector("#move-controls")
.querySelectorAll("button");
const boombox = document.querySelector(".boombox-body");
// the values for our css transforms
let transform = {
xAxis: 0,
yAxis: 0,
zAxis: 0.8,
rotateX: 0,
rotateY: 0,
};
// set our bounds
const topBound = -posY;
const bottomBound = posY;
const rightBound = posX;
const leftBound = -posX;
const innerBound = 0.1;
const outerBound = 1.5;
让æä»¬å建ä¸ä¸ªå½æ°ï¼å°æä»¬æ³è¦ç§»å¨çæ¹åä½ä¸ºåæ°ï¼å¹¶ä¸ä¿®æ¹ CSS åæ¢åæ´æ°æä»¬ç panner èç¹çä½ç½®åæ¹å屿§å¼ä»¥éå½å°æ´æ¹å£°é³ã é¦å 让æä»¬ççå·¦ï¼å³ï¼ä¸ï¼ä¸å¼ï¼å 为è¿äºé常ç®åãæä»¬å°æ²¿çè¿äºè½´ç§»å¨ boomboxï¼å¹¶æ´æ°åéçä½ç½®ã
function moveBoombox(direction) {
switch (direction) {
case "left":
if (transform.xAxis > leftBound) {
transform.xAxis -= 5;
panner.positionX.value -= 0.1;
}
break;
case "up":
if (transform.yAxis > topBound) {
transform.yAxis -= 5;
panner.positionY.value -= 0.3;
}
break;
case "right":
if (transform.xAxis < rightBound) {
transform.xAxis += 5;
panner.positionX.value += 0.1;
}
break;
case "down":
if (transform.yAxis < bottomBound) {
transform.yAxis += 5;
panner.positionY.value += 0.3;
}
break;
}
}
ç§»å ¥åç§»åºä¹æ¯ç±»ä¼¼çæ äºï¼
case 'back':
if (transform.zAxis > innerBound) {
transform.zAxis -= 0.01;
panner.positionZ.value += 40;
}
break;
case 'forward':
if (transform.zAxis < outerBound) {
transform.zAxis += 0.01;
panner.positionZ.value -= 40;
}
break;
ç¶èï¼æä»¬çæè½¬å¼ç¨ä¸ºå¤æï¼å 为æä»¬éè¦å¨å¨å´ç§»å¨å£°é³ãæä»¬ä¸ä»
éè¦æ´æ°ä¸¤ä¸ªè½´å¼ï¼ä¾å¦ï¼å¦æå´ç» x è½´æè½¬å¯¹è±¡ï¼åæ´æ°è¯¥å¯¹è±¡ç y å z åæ ï¼ï¼è¿éè¦ä¸ºæ¤è¿è¡æ´å¤çæ°å¦è¿ç®ãæè½¬æ¯ä¸ä¸ªååï¼æä»¬éè¦ Math.sin
å Math.cos
æ¥å¸®å©æä»¬ç»å¶è¿ä¸ªååã 让æä»¬è®¾ç½®ä¸ä¸ªæè½¬éçï¼æä»¬å°ä¼å°å®è½¬æ¢ä¸ºå¼§åº¦èå´çå¼ï¼ä»¥ä¾¿ç¨åå¨ Math.sin
å Math.cos
ä¸ä½¿ç¨ï¼å½æä»¬å¨æè½¬æä»¬ç boomboxï¼éè¦è®¡ç®åºæ°çåæ æ¶ï¼
// set up rotation constants
const rotationRate = 60; // bigger number equals slower sound rotation
const q = Math.PI / rotationRate; //rotation increment in radians
æä»¬ä¹å¯ä»¥ä½¿ç¨å®æ¥è®¡ç®æè½¬åº¦ï¼è¿å°æå©äºæä»¬å³å°å¿ é¡»å建ç CSS åæ¢ï¼æ³¨ææä»¬éè¦ç¨äº CSS 忢ç x å y è½´ï¼ï¼
// get degrees for css
const degreesX = (q * 180) / Math.PI;
const degreesY = (q * 180) / Math.PI;
æä»¬ä»¥å·¦æè½¬ä¸ºä¾çä¸çãæä»¬éè¦æ´æ¹ panner x æ¹åå z æ¹åçåæ ï¼ä»¥å´ç» y è½´ç§»å¨è¿è¡å·¦æè½¬ï¼
case 'rotate-left':
transform.rotateY -= degreesY;
// 'left' is rotation about y-axis with negative angle increment
z = panner.orientationZ.value*Math.cos(q) - panner.orientationX.value*Math.sin(q);
x = panner.orientationZ.value*Math.sin(q) + panner.orientationX.value*Math.cos(q);
y = panner.orientationY.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
è¿æç¹ä»¤äººå°æï¼ä½æä»¬æ£å¨åçæ¯ä½¿ç¨ sin å cos æ¥å¸®å©æä»¬è®¡ç®åºæè½¬ boombox æéçåå¨è¿å¨çåæ ã æä»¬å¯ä»¥ä¸ºææè½´åå°è¿ä¸ç¹ãåªéè¦éæ©æ£ç¡®çæ¹åè¿è¡æ´æ°ï¼ä»¥åæä»¬æ¯æ³è¦æ£å¢éè¿æ¯è´å¢éã
case 'rotate-right':
transform.rotateY += degreesY;
// 'right' is rotation about y-axis with positive angle increment
z = panner.orientationZ.value*Math.cos(-q) - panner.orientationX.value*Math.sin(-q);
x = panner.orientationZ.value*Math.sin(-q) + panner.orientationX.value*Math.cos(-q);
y = panner.orientationY.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
case 'rotate-up':
transform.rotateX += degreesX;
// 'up' is rotation about x-axis with negative angle increment
z = panner.orientationZ.value*Math.cos(-q) - panner.orientationY.value*Math.sin(-q);
y = panner.orientationZ.value*Math.sin(-q) + panner.orientationY.value*Math.cos(-q);
x = panner.orientationX.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
case 'rotate-down':
transform.rotateX -= degreesX;
// 'down' is rotation about x-axis with positive angle increment
z = panner.orientationZ.value*Math.cos(q) - panner.orientationY.value*Math.sin(q);
y = panner.orientationZ.value*Math.sin(q) + panner.orientationY.value*Math.cos(q);
x = panner.orientationX.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
æåä¸ä»¶äº - æä»¬éè¦æ´æ° CSS å¹¶ä¿çé¼ æ äºä»¶æå䏿¥çå¼ç¨ã è¿æ¯æç»ç moveBoombox
彿°ã
function moveBoombox(direction, prevMove) {
switch (direction) {
case "left":
if (transform.xAxis > leftBound) {
transform.xAxis -= 5;
panner.positionX.value -= 0.1;
}
break;
case "up":
if (transform.yAxis > topBound) {
transform.yAxis -= 5;
panner.positionY.value -= 0.3;
}
break;
case "right":
if (transform.xAxis < rightBound) {
transform.xAxis += 5;
panner.positionX.value += 0.1;
}
break;
case "down":
if (transform.yAxis < bottomBound) {
transform.yAxis += 5;
panner.positionY.value += 0.3;
}
break;
case "back":
if (transform.zAxis > innerBound) {
transform.zAxis -= 0.01;
panner.positionZ.value += 40;
}
break;
case "forward":
if (transform.zAxis < outerBound) {
transform.zAxis += 0.01;
panner.positionZ.value -= 40;
}
break;
case "rotate-left":
transform.rotateY -= degreesY;
// 'left' is rotation about y-axis with negative angle increment
z =
panner.orientationZ.value * Math.cos(q) -
panner.orientationX.value * Math.sin(q);
x =
panner.orientationZ.value * Math.sin(q) +
panner.orientationX.value * Math.cos(q);
y = panner.orientationY.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
case "rotate-right":
transform.rotateY += degreesY;
// 'right' is rotation about y-axis with positive angle increment
z =
panner.orientationZ.value * Math.cos(-q) -
panner.orientationX.value * Math.sin(-q);
x =
panner.orientationZ.value * Math.sin(-q) +
panner.orientationX.value * Math.cos(-q);
y = panner.orientationY.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
case "rotate-up":
transform.rotateX += degreesX;
// 'up' is rotation about x-axis with negative angle increment
z =
panner.orientationZ.value * Math.cos(-q) -
panner.orientationY.value * Math.sin(-q);
y =
panner.orientationZ.value * Math.sin(-q) +
panner.orientationY.value * Math.cos(-q);
x = panner.orientationX.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
case "rotate-down":
transform.rotateX -= degreesX;
// 'down' is rotation about x-axis with positive angle increment
z =
panner.orientationZ.value * Math.cos(q) -
panner.orientationY.value * Math.sin(q);
y =
panner.orientationZ.value * Math.sin(q) +
panner.orientationY.value * Math.cos(q);
x = panner.orientationX.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
}
boombox.style.transform =
"translateX(" +
transform.xAxis +
"px) translateY(" +
transform.yAxis +
"px) scale(" +
transform.zAxis +
") rotateY(" +
transform.rotateY +
"deg) rotateX(" +
transform.rotateX +
"deg)";
const move = prevMove || {};
move.frameId = requestAnimationFrame(() => moveBoombox(direction, move));
return move;
}
è¿æ¥æä»¬çæ§ä»¶
è¿æ¥æ§å¶æé®ç¸å¯¹ç®å - ç°å¨æä»¬å¯ä»¥å¨æ§ä»¶ä¸çå¬é¼ æ äºä»¶å¹¶è¿è¡æ¤æ¹æ³ï¼å¹¶å¨éæ¾é¼ æ æ¶åæ¢å®ï¼
// for each of our controls, move the boombox and change the position values
moveControls.forEach(function (el) {
let moving;
el.addEventListener(
"mousedown",
function () {
let direction = this.dataset.control;
if (moving && moving.frameId) {
window.cancelAnimationFrame(moving.frameId);
}
moving = moveBoombox(direction);
},
false,
);
window.addEventListener(
"mouseup",
function () {
if (moving && moving.frameId) {
window.cancelAnimationFrame(moving.frameId);
}
},
false,
);
});
æ¦è¿°
å¸ææ¬æè½è®©ä½ æ·±å
¥äºè§£ Web Audio 空é´åçå·¥ä½åçï¼ä»¥åæ¯ä¸ªPannerNode
屿§çä½ç¨ï¼å
¶ä¸æå¾å¤å±æ§ï¼ãè¿äºå¼ææ¶é¾ä»¥æä½ï¼æ ¹æ®ä½ çä½¿ç¨æ
åµï¼å¯è½éè¦ä¸äºæ¶é´æè½ä½¿å®ä»¬æ£ç¡®ã
夿³¨ï¼ é³é¢ç©ºé´åå¨ä¸åæµè§å¨ä¸çå¬èµ·æ¥ç¥æä¸åãpanner èç¹å¨åºå±åäºä¸äºéå¸¸å¤æçæ°å¦è¿ç®ï¼è¿éæ è®¸å¤æµè¯ï¼å æ¤ä½ å¯ä»¥è·è¸ªä¸åå¹³å°ä¸æ¤èç¹çå é¨å·¥ä½ç¶æã
忬¡ï¼ä½ å¯ä»¥å¨ è¿éæ¥çæç»çæ¼ç¤ºï¼åæ¶æç»çæºä»£ç å¨è¿éãè¿æä¸ä¸ª Codepen çæ¼ç¤ºã
å¦æä½ æ£å¨ä½¿ç¨ 3D 游æå/æ WebXRï¼æå¥½å©ç¨ 3D åºæ¥å建æ¤ç±»åè½ï¼è䏿¯å°è¯ä»æåçè§å宿ææè¿äºæä½ãæä»¬å¨æ¬æä¸æåºäºèªå·±çæ³æ³ï¼è®©ä½ äºè§£å®æ¯å¦ä½å·¥ä½çï¼ä½æ¯éè¿å©ç¨å«äººå¨ä½ ä¹åæåçå·¥ä½ï¼ä½ å°èç大鿶é´ã
RetroSearch is an open source project built by @garambo | Open a GitHub Issue
Search and Browse the WWW like it's 1997 | Search results from DuckDuckGo
HTML:
3.2
| Encoding:
UTF-8
| Version:
0.7.4