记录--ThreeJs手搓一个罗盘特效
Posted 林恒
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了记录--ThreeJs手搓一个罗盘特效相关的知识,希望对你有一定的参考价值。
这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助
先上效果
前言
最近在学Three.js.,对着文档看了一周多,正好赶上码上掘金的活动,就顺便写了一个小demo,手搓一个罗盘特效。
太极
先来看一下太极的实现方式,这里我们使用CircleGeometry,将其分解开来可以看出是由圆形和半圆形组成 。
CircleGeometry
CircleGeometry | 官网案例 |
---|---|
radius | 半径 |
segments | 分段(三角面)的数量 |
thetaStart | 第一个分段的起始角度 |
thetaLength | 圆形扇区的中心角 |
这里不需要用到segments,但是需要颜色,所以定义一个函数传入半径、颜色、起始角度、中心角。
const createCircle = (r, color, thetaStart, thetaLength) => const material = new THREE.MeshBasicMaterial( color: color, side: THREE.DoubleSide ); const geometry = new THREE.CircleGeometry(r, 64, thetaStart, thetaLength); const circle = new THREE.Mesh(geometry, material); return circle; ;
我们只需要通过传参生产不同大小的圆或半圆,再进行位移就可以实现其效果。
参考代码/73-96行 还有一些需要注意的地方写在注释里了。
罗盘
接下来看罗盘的实现,罗盘由一个个圆环组成,一个圆环又由内圈、外圈、分隔线、文字、八卦构成。
内外圈
内外圈我们使用两个RingGeometry
RingGeometry | 官网案例 |
---|---|
innerRadius | 内部半径 |
outerRadius | 外部半径 |
thetaSegments | 圆环的分段数 |
phiSegments | 圆环的分段数 |
thetaStart | 起始角度 |
thetaLength | 圆心角 |
通过circle控制内外圆圈的尺寸,circleWidth控制圆圈的线宽
const circleWidth = [0.1, 0.1] const circle = [0, 1]; circle.forEach((i, j) => const RingGeo = new THREE.RingGeometry( innerRing + i, innerRing + i + circleWidth[j], 64, 1 ); const Ring = new THREE.Mesh(RingGeo, material); RingGroup.add(Ring); );
分隔线
分隔线使用的是PlaneGeometry
PlaneGeometry | 官网案例 |
---|---|
width | 宽度 |
height | 高度 |
widthSegments | 宽度分段数 |
heightSegments | 高度分段数 |
关于分隔线,它的长度就是内外圈的差值,所以这里使用外圈的数值,确定与圆心的距离就要使用内圈的数值加上自身长度除2。除此之外,还需要计算分隔线与圆心的夹角。
for (let i = 0; i < lineNum; i++) const r = innerRing + circle[1] / 2; const rad = ((2 * Math.PI) / lineNum) * i; const x = Math.cos(rad) * r; const y = Math.sin(rad) * r; const planeGeo = new THREE.PlaneGeometry(lineWidth, circle[1]); const line = new THREE.Mesh(planeGeo, material); line.position.set(x, y, 0); line.rotation.set(0, 0, rad + Math.PI / 2); RingGroup.add(line);
文字
文字使用的是TextGeometry,定位与分隔线一致,只需要交错开来。
for (let i = 0; i < lineNum; i++) const r = innerRing + circle[1] / 2; const rad = ((2 * Math.PI) / lineNum) * i + Math.PI / lineNum; const x = Math.cos(rad) * r; const y = Math.sin(rad) * r; var txtGeo = new THREE.TextGeometry(text[i % text.length], font: font, size: size, height: 0.001, curveSegments: 12, ); txtGeo.translate(offsetX, offsetY, 0); var txt = new THREE.Mesh(txtGeo, material); txt.position.set(x, y, 0); txt.rotation.set(0, 0, rad + -Math.PI / 2); RingGroup.add(txtMesh);
不过TextGeometry的使用有一个得注意得前提,我们需要引入字体文件。
const fontLoader = new THREE.FontLoader(); const fontUrl = "https://xtjj-1253239320.cos.ap-shanghai.myqcloud.com/fonts.json"; let font; const loadFont = new Promise((resolve, reject) => fontLoader.load( fontUrl, function (loadedFont) font = loadedFont; resolve(); , undefined, function (err) reject(err); ); );
八卦
圆环中除了文字之外,还能展示八卦,通过传递baguaData给createBagua生成每一个符号。
const baguaData = [ [1, 1, 1], [0, 0, 0], [0, 0, 1], [0, 1, 0], [0, 1, 1], [1, 0, 0], [1, 0, 1], [1, 1, 0], ]; for (let i = 0; i < lineNum; i++) const r = innerRing + circle[1] / 2; const rad = ((2 * Math.PI) / lineNum) * i + Math.PI / lineNum; const x = Math.cos(rad) * r; const y = Math.sin(rad) * r; RingGroup.add( createBagua(baguaData[i % 8], x, y, 0 , rad + Math.PI / 2, text[0]), );
createBagua参考代码/114-146行 ,和分隔线是一样的,使用了PlaneGeometry只是做了一些位置的设置。
视频贴图
在罗盘外,还有一圈视频,这里是用到了VideoTexture,实现也很简单。唯一得注意的是视频的跨域问题,需要配置video.crossOrigin = "anonymous"
const videoSrc = [ "https://xtjj-1253239320.cos.ap-shanghai.myqcloud.com/yAC65vN6.mp4", "https://xtjj-1253239320.cos.ap-shanghai.myqcloud.com/6Z5VZdZM.mp4", ]; video.src = videoSrc[Math.floor(Math.random() * 2)]; video.crossOrigin = "anonymous"; const texture = new THREE.VideoTexture(video); ... const material = new THREE.MeshBasicMaterial( color: 0xffffff, side: THREE.DoubleSide, map: texture, );
动画
动画总共分为三个部分,一块是旋转动画,一块是分解动画和入场动画,我们使用gsap实现。
旋转动画
gsap.to(videoGroup.rotation, duration: 30, y: -Math.PI * 2, repeat: -1, ease: "none", );
分解动画
.to(RingGroup.position, duration: 1, ease: "ease.inOut", y: Math.random() * 10 - 5, delay: 5, ) .to(RingGroup.position, duration: 1, ease: "ease.inOut", delay: 5, y: 0, )
入场动画
item.scale.set(1.2, 1.2, 1.2); gsap.to(item.scale, duration: 0.8, x: 1, y: 1, repeat: 0, ease: "easeInOut", );
旋转动画与分解动画可以写在生成函数内,也可以写在添加scene时,但是入场动画只能写到scene后,因为在生成时,动画就添加上了,当我们点击开始的时候才会将其加入场景中,而这时动画可能已经执行了。
总代码
html
<!-- 灵感来源:一人之下里的八奇技————风后奇门,但是剧中和漫画中施展的罗盘有限,所以就参考了罗盘特效随便排布。 从抖音选取了两段剪辑随机播放。 实现方式:Three.js --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> </head> <body> <canvas class="webgl"></canvas> <div class="box"> <div>大道五十,天衍四九,人遁其一</div> <img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e80a3fa048e84f02bb5ef5b6b04af87f~tplv-k3u1fbpfcp-no-mark:240:240:240:160.awebp?"> <div class="btn">推衍中...</div> </div> </body> </html>
style
* margin: 0; padding: 0; body background-color: #3d3f42; .box width: 350px; height: 250px; background-color: #000; position:absolute; top: calc(50% - 75px); left: calc(50% - 150px); border-radius: 10px; font-size: 16px; color: #fff; display: flex; flex-direction: column; justify-content: space-evenly; align-items: center; .btn width: 120px; height: 35px; line-height: 35px; color: #fff; border: 2px solid #fff; border-radius: 10px; font-size: 20px; transition: 0.5s; text-align: center; cursor:default; opacity: 0.5; img width: 200px; height: 150px;
js
import * as THREE from "three@0.125.1"; import OrbitControls from "three/examples/jsm/controls/OrbitControls.js"; import gsap from "gsap@3.5.1"; // Canvas const canvas = document.querySelector("canvas.webgl"); const box = document.querySelector(".box"); const btn = document.querySelector(".btn"); const video = document.createElement("video"); // Scene const scene = new THREE.Scene(); //---------------------- const fontLoader = new THREE.FontLoader(); const fontUrl = "https://xtjj-1253239320.cos.ap-shanghai.myqcloud.com/fonts.json"; let font; const loadFont = new Promise((resolve, reject) => fontLoader.load( fontUrl, function (loadedFont) font = loadedFont; resolve(); , undefined, function (err) reject(err); ); ); const text = 五行: ["金", "木", "水", "火", "土"], 八卦: ["乾", "坤", "震", "巽", "坎", "艮", "离", "兑"], 数字: ["壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖", "拾"], 天干: ["甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"], 地支: [ "子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥", ], 方位: [ "甲", "卯", "乙", "辰", "巽", "巳", "丙", "午", "丁", "未", "坤", "申", "庚", "酉", "辛", "戍", "干", "亥", "壬", "子", "癸", "丑", "艮", "寅", ], 节气: [ "立 春", "雨 水", "惊 蛰", "春 分", "清 明", "谷 雨", "立 夏", "小 满", "芒 种", "夏 至", "小 暑", "大 暑", "立 秋", "处 暑", "白 露", "秋 分", "寒 露", "霜 降", "立 冬", "小 雪", "大 雪", "冬 至", "小 寒", "大 寒", ], 天星: [ "天辅", "天垒", "天汉", "天厨", "天市", "天掊", "天苑", "天衡", "天官", "天罡", "太乙", "天屏", "太微", "天马", "南极", "天常", "天钺", "天关", "天潢", "少微", "天乙", "天魁", "天厩", "天皇", ], 天干1: [ "甲", " ", "乙", " ", "丙", " ", "丁", " ", "戊", " ", "己", " ", "庚", " ", "辛", " ", "壬", " ", "癸", " ", "甲", " ", "乙", " ", ], 地支1: [ "子", " ", "丑", " ", "寅", " ", "卯", " ", "辰", " ", "巳", " ", "午", " ", "未", " ", "申", " ", "酉", " ", "戌", " ", "亥", " ", ], ; const data = [ innerRing: 2, outerRing: 1.5, lineWidth: 0.1, circleWidth: [0.1, 0.1], lineNum: 8, text: [0xffffff], offsetX: 0, offsetY: 0, size: 0.3, direction: -1, duration: 40, , innerRing: 3.5, outerRing: 0.7, lineWidth: 0.15, circleWidth: [0.1, 0.1], lineNum: 24, text: text["方位"], offsetX: -0.2, offsetY: -0.08, size: 0.3, direction: 1, duration: 10, , innerRing: 4.2, outerRing: 0.7, lineWidth: 0.15, circleWidth: [0.1, 0.1], lineNum: 24, text: text["八卦"], offsetX: -0.2, offsetY: -0.08, size: 0.3, direction: -1, duration: 20, , innerRing: 4.9, outerRing: 1.3, lineWidth: 0.15, circleWidth: [0.1, 0.1], lineNum: 24, text: text["方位"], offsetX: -0.4, offsetY: -0.2, size: 0.6, direction: 1, duration: 30, , innerRing: 6.2, outerRing: 0.4, lineWidth: 0.15, circleWidth: [0, 0], lineNum: 60, text: text["地支"], offsetX: -0.13, offsetY: 0.01, size: 0.2, direction: 1, duration: 25, , innerRing: 6.6, outerRing: 0.4, lineWidth: 0.15, circleWidth: [0, 0], lineNum: 60, text: text["天干"], offsetX: -0.13, offsetY: -0.07, size: 0.2, direction: 1, duration: 25, , innerRing: 7, outerRing: 0.5, lineWidth: 0.15, circleWidth: [0.1, 0.1], lineNum: 36, text: text["天星"], offsetX: -0.27, offsetY: -0.03, size: 0.2, direction: -1, duration: 20, , innerRing: 7.5, outerRing: 0.5, lineWidth: 0.15, circleWidth: [0.1, 0.1], lineNum: 24, text: text["节气"], offsetX: -0.36, offsetY: -0.03, size: 0.2, direction: 1, duration: 30, , innerRing: 8, outerRing: 0.8, lineWidth: 0.15, circleWidth: [0.1, 0.1], lineNum: 48, text: text["方位"], offsetX: -0.3, offsetY: -0.1, size: 0.4, direction: 1, duration: 35, , innerRing: 8.8, outerRing: 0.8, lineWidth: 0.15, circleWidth: [0.1, 0.1], lineNum: 32, text: text["八卦"], offsetX: -0.3, offsetY: -0.1, size: 0.4, direction: -1, duration: 60, , innerRing: 9.6, outerRing: 0.4, lineWidth: 0.18, circleWidth: [0, 0], lineNum: 120, text: text["地支1"], offsetX: -0.13, offsetY: 0.01, size: 0.2, direction: 1, duration: 30, , innerRing: 10, outerRing: 0.4, lineWidth: 0.18, circleWidth: [0, 0], lineNum: 120, text: text["天干1"], offsetX: -0.13, offsetY: -0.07, size: 0.2, direction: 1, duration: 30, , innerRing: 10.4, outerRing: 0.5, lineWidth: 0.1, circleWidth: [0.1, 0.1], lineNum: 60, text: text["数字"], offsetX: -0.13, offsetY: -0.02, size: 0.2, direction: 1, duration: 25, , innerRing: 10.9, outerRing: 0.5, lineWidth: 0.15, circleWidth: [0.1, 0.1], lineNum: 50, text: text["五行"], offsetX: -0.13, offsetY: -0.02, size: 0.2, direction: 1, duration: 35, , innerRing: 11.7, outerRing: 1, lineWidth: 0.1, circleWidth: [1, 0], lineNum: 64, text: [0x000000], offsetX: 0, offsetY: 0, size: 0.3, direction: 1, duration: 30, , ]; const Rings = []; const duration = [ 0, 0.7, 0.7, 0.7, 0.7, 0, 0.7, 0.7, 0.7, 0.7, 0.7, 0, 0.7, 0.7, 0.7, ]; //Ring const Ring = ( innerRing, outerRing, lineWidth, circleWidth, lineNum, offsetX, offsetY, text, size, direction, duration, ) => const RingGroup = new THREE.Group(); const circle = [0, outerRing]; const material = new THREE.MeshStandardMaterial( color: 0xffffff, side: THREE.DoubleSide, ); // create ring circle.forEach((i, j) => const RingGeo = new THREE.RingGeometry( innerRing + i, innerRing + circleWidth[j] + i, 64, 1 ); const Ring = new THREE.Mesh(RingGeo, material); RingGroup.add(Ring); ); // create line for (let i = 0; i < lineNum; i++) const r = innerRing + circle[1] / 2; const rad = ((2 * Math.PI) / lineNum) * i; const x = Math.cos(rad) * r; const y = Math.sin(rad) * r; const planeGeo = new THREE.PlaneGeometry(lineWidth, circle[1]); const line = new THREE.Mesh(planeGeo, material); line.position.set(x, y, 0); line.rotation.set(0, 0, rad + Math.PI / 2); RingGroup.add(line); // create text if (text.length > 1) for (let i = 0; i < lineNum; i++) const r = innerRing + circle[1] / 2; const rad = ((2 * Math.PI) / lineNum) * i + Math.PI / lineNum; const x = Math.cos(rad) * r; const y = Math.sin(rad) * r; var txtGeo = new THREE.TextGeometry(text[i % text.length], font: font, size: size, height: 0.001, curveSegments: 12, ); txtGeo.translate(offsetX, offsetY, 0); var txtMater = new THREE.MeshStandardMaterial( color: 0xffffff ); var txtMesh = new THREE.Mesh(txtGeo, txtMater); txtMesh.position.set(x, y, 0); txtMesh.rotation.set(0, 0, rad + -Math.PI / 2); RingGroup.add(txtMesh); // create bagua if (text.length == 1) const baguaData = [ [1, 1, 1], [0, 0, 0], [0, 0, 1], [0, 1, 0], [0, 1, 1], [1, 0, 0], [1, 0, 1], [1, 1, 0], ]; for (let i = 0; i < lineNum; i++) const r = innerRing + circle[1] / 2; const rad = ((2 * Math.PI) / lineNum) * i + Math.PI / lineNum; const x = Math.cos(rad) * r; const y = Math.sin(rad) * r; RingGroup.add( createBagua(baguaData[i % 8], x, y, 0.0001, rad + Math.PI / 2, text[0]), createBagua(baguaData[i % 8], x, y, -0.0001, rad + Math.PI / 2, text[0]) ); // animation gsap.to(RingGroup.rotation, duration: duration, z: Math.PI * 2 * direction, repeat: -1, ease: "none", ); const amColor = r: 1, g: 1, b: 1 ; const explode = gsap.timeline( repeat: -1, delay: 5 ); explode .to(RingGroup.position, duration: 1, ease: "ease.inOut", y: Math.random() * 10 - 5, delay: 5, ) .to(amColor, r: 133 / 255, g: 193 / 255, b: 255 / 255, duration: 2, onUpdate: () => ambientLight.color.setRGB(amColor.r, amColor.g, amColor.b), ) .to(RingGroup.position, duration: 1, ease: "ease.inOut", delay: 5, y: 0, ) .to(amColor, r: 1, g: 1, b: 1, duration: 3, onUpdate: () => ambientLight.color.setRGB(amColor.r, amColor.g, amColor.b), ); // rotate RingGroup.rotateX(-Math.PI / 2); return RingGroup; ; //taiji const createTaiji = (position, scale) => const taiji = new THREE.Group(); const createCircle = (r, color, thetaStart, thetaLength) => const material = new THREE.MeshBasicMaterial( color: color, side: THREE.DoubleSide, ); const geometry = new THREE.CircleGeometry(r, 64, thetaStart, thetaLength); const circle = new THREE.Mesh(geometry, material); return circle; ; const ying = createCircle(1.8, 0x000000, 0, Math.PI); const yang = createCircle(1.8, 0xffffff, Math.PI, Math.PI); const Lblack = createCircle(0.9, 0x000000, 0, Math.PI * 2); const Lwhite = createCircle(0.9, 0xffffff, 0, Math.PI * 2); const Sblack = createCircle(0.25, 0x000000, 0, Math.PI * 2); const Swhite = createCircle(0.25, 0xffffff, 0, Math.PI * 2); const Lblack1 = createCircle(0.9, 0x000000, 0, Math.PI * 2); const Lwhite1 = createCircle(0.9, 0xffffff, 0, Math.PI * 2); const Sblack1 = createCircle(0.25, 0x000000, 0, Math.PI * 2); const Swhite1 = createCircle(0.25, 0xffffff, 0, Math.PI * 2); Lblack.position.set(-0.9, 0, 0.001); Lwhite.position.set(0.9, 0, 0.001); Swhite.position.set(-0.9, 0, 0.002); Sblack.position.set(0.9, 0, 0.002); Lblack1.position.set(-0.9, 0, -0.001); Lwhite1.position.set(0.9, 0, -0.001); Swhite1.position.set(-0.9, 0, -0.002); Sblack1.position.set(0.9, 0, -0.002); taiji.add( ying, yang, Lblack, Lwhite, Swhite, Sblack, Lblack1, Lwhite1, Swhite1, Sblack1 ); gsap.to(taiji.rotation, duration: 30, z: Math.PI * 2, repeat: -1, ease: "none", ); taiji.rotateX(-Math.PI / 2); taiji.position.set(...position); taiji.scale.set(...scale); return taiji; ; scene.add(createTaiji([0, 0, 0], [1, 1, 1])); // bagua const createBagua = (data, x, y, z, deg, color) => const idx = [-0.32, 0, 0.32]; const bagua = new THREE.Group(); const material = new THREE.MeshStandardMaterial( color: color, side: THREE.DoubleSide, ); data.forEach((i, j) => if (i == 1) const yang = new THREE.Mesh(new THREE.PlaneGeometry(1, 0.2), material); yang.position.set(0, idx[j], 0); bagua.add(yang); if (i == 0) const ying1 = new THREE.Mesh( new THREE.PlaneGeometry(0.45, 0.2), material ); const ying2 = new THREE.Mesh( new THREE.PlaneGeometry(0.45, 0.2), material ); ying1.position.set(-0.275, idx[j], 0); ying2.position.set(0.275, idx[j], 0); bagua.add(ying1, ying2); ); bagua.position.set(x, y, z); bagua.rotation.set(0, 0, deg); return bagua; ; const showVideo = () => const videoSrc = [ "https://xtjj-1253239320.cos.ap-shanghai.myqcloud.com/yAC65vN6.mp4", "https://xtjj-1253239320.cos.ap-shanghai.myqcloud.com/6Z5VZdZM.mp4", ]; video.src = videoSrc[Math.floor(Math.random() * 2)]; video.crossOrigin = "anonymous"; const texture = new THREE.VideoTexture(video); const videoGroup = new THREE.Group(); for (let i = 0; i < 8; i++) const r = 25; const rad = ((2 * Math.PI) / 8) * i; const x = Math.cos(rad) * r; const y = Math.sin(rad) * r; const planeGeo = new THREE.PlaneGeometry(16, 9); const material = new THREE.MeshBasicMaterial( color: 0xffffff, side: THREE.DoubleSide, map: texture, ); const plane = new THREE.Mesh(planeGeo, material); plane.position.set(x, 4.5, y); if (i % 2 == 0) plane.rotation.set(0, rad + Math.PI / 2, 0); else plane.rotation.set(0, rad, 0); videoGroup.add(plane); gsap.to(videoGroup.rotation, duration: 30, y: -Math.PI * 2, repeat: -1, ease: "none", ); scene.add(videoGroup); ; //loadFont, Rings loadFont.then(() => data.forEach((item) => Rings.push(Ring(item)); ); btn.innerText = "入 局"; btn.style.opacity = 1; btn.style.cursor = "pointer"; ); //start const start = function () const showRing = (item) => scene.add(item); item.scale.set(1.2, 1.2, 1.2); gsap.to(item.scale, duration: 0.8, x: 1, y: 1, repeat: 0, ease: "easeInOut", ); ; const tl = gsap.timeline(); Rings.forEach((item, idx) => tl.to(".webgl", duration: duration[idx] ).call(() => showRing(item); ); ); ; btn.addEventListener("click", () => box.style.display = "none"; start(); showVideo(); video.play(); video.loop = true; ); //---------------------- //Light const ambientLight = new THREE.AmbientLight(0xffffff, 1); scene.add(ambientLight); //Sizes const sizes = width: window.innerWidth, height: window.innerHeight, ; // Camera const camera = new THREE.PerspectiveCamera( 75, sizes.width / sizes.height, 1, 1000 ); camera.position.y = 10; camera.position.x = 10; camera.position.z = 10; camera.lookAt(scene.position); scene.add(camera); //Renderer const renderer = new THREE.WebGLRenderer( canvas: canvas, antialias: true, alpha: true, ); renderer.setSize(sizes.width, sizes.height); //controls const controls = new OrbitControls(camera, canvas); controls.enableDamping = true; controls.maxDistance = 50; controls.enablePan = false; const tick = () => renderer.render(scene, camera); controls.update(); window.requestAnimationFrame(tick); ; tick(); window.addEventListener("resize", () => sizes.height = window.innerHeight; sizes.width = window.innerWidth; camera.aspect = sizes.width / sizes.height; camera.updateProjectionMatrix(); renderer.setSize(sizes.width, sizes.height); renderer.setPixelRatio(window.devicePixelRatio); );
本文转载于:
https://juejin.cn/post/7220629398965108794
如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。
ThreeJS之动画交互逻辑及特效
工作需要,研究了一下 threejs 简单逻辑动画交互方法。写了一个小示例,分享一下,挺丑的。
第一步
当然就是初始化 threejs 的渲染场景了。
var camera; //相机 var scene;//场景 var renderer;//webGL渲染器 var controls;//轨道控件,用于特定场景,模拟轨道中的卫星,可以用鼠标和键盘在场景中游走 var raycaster;//THREE.Raycaster对象从屏幕上的点击位置想场景中发射一束光线,返回射线穿透物体的数组 var composer;//后期特效合成器,给场景选中物体添加发光特效
第二步
在 ThreeJs Editor 中建立简单的示例模型,“Export Scene”,导出。并导入示例程序。免去了在示例程序中自己建模的麻烦,不过因为示例程序要加载本地的json,所以可以设置一个简单的 nodejs 服务器。
在 nodejs 的 anywhere 下运行该示例:
加载模型文件,将文件中的相关 object 加入 group 中:
var url = \'nofloor.json\'; var loader= new THREE.ObjectLoader();
var geometry = new THREE.Geometry();//存放objects的position坐标,为之后线条的起始点坐标服务 loader.load( url, function ( loadedScene ) { //scene = loadedScene; var objects = loadedScene.children; for(var i=0;i<objects.length;i++){ if(objects[i].type == \'Mesh\' ){ objects[i].receiveShadow = true; objects[i].castShadow = true; geometry.vertices.push(objects[i].position); group.add(objects[i]); } } } , onProgress, onError);
导入的模型差不多就是这样子(丑一点,担待),并在示例程序中加了stats 和 dat.gui 用来检测渲染效果和改变特效参数。
第三步
完成的交互目标是,点击上图中某个柱体选中,出现相应的连线,并且让选中的柱体和连线发光。
现在先利用 raycaster 选中物体:
var mouse = new THREE.Vector2(); //鼠标经过或者点击的屏幕 canvas 上的位置 mouse.x = ( event.clientX / window.innerWidth ) * 2 - 1;//将 canvas 坐标系转换为 WebGL 坐标系 mouse.y = - ( event.clientY / window.innerHeight ) * 2 + 1; raycaster.setFromCamera( mouse, camera );// raycaster的作用场景 var intersects = raycaster.intersectObjects( [group], true );//从鼠标 mouse 位置发射线,选中 group 组中的objects ,并返回 objects 给 intersects
一旦 intersects 不为空,intersects[0].object 就是鼠标选中的物体,可以是上图中的正方体,也可以是上图中的地板。
接下来,皆可以根据选中物体来连线了。连线呢有两种方法。
第一种
代码如下:
var material = new THREE.LineBasicMaterial({ color: 0x0000ff }); var geometry = new THREE.Geometry(); geometry.vertices.push( //各个柱体的 position 坐标,也就是上面加载模型文件时候生成的 geometry.vertices
);
// THREE.Line 会将 geometry.vertices 中所有坐标点连成一条连续线,但不是首尾相接。
//比如 geometry.vertices 中存放了 v1,v2,v3,v4四个三维坐标点,就会生成v1->v2->v3->v4 连续线,中间有三条线。
var line = new THREE.Line( geometry, material );
但是有一个缺点就是由于受限于 角度层(ANGLE layer),在Windows平台上使用 WebGL,线宽将总是为1而不管设置的值。这样一旦模型是五六米高,可是线条只有不到1cm的宽度,看起来模型就会很奇怪了。这时候就要用第二种方法。
第二种
画圆柱体,用圆柱体代替线段,但是生成圆柱体就比线段复杂多了。大家知道,threejs 只会指定一个object的position(中心),而不能指定两端的位置(我还没发现,若有错误,请指正),所以画的初始圆柱体是直立的,如下图
让我们要的是下图这样的圆柱体(下面统称为“柱子线”)
因为半径是 0.02,所以看起来像线段,这也是为什么要用圆柱体模拟线段的原因了,逼真,而且还能根据模型大小调整这个柱子线的“linewidth”。
下面我们看看怎么怎么根据正方体个球体的坐标动态生成柱子线吧。
上面我们讲了如何选中物体,选中之后,我们把这个物体相邻的物体(建模时候,将所有物体坐标顺序放在 geometry.vertices 中,此处默认坐标相邻就是物体相邻)的 position 坐标加入 geometryChange.vertices 中
var object = intersects[0].object; geometryChange = new THREE.Geometry(); var position = intersects[0].object.position;//当前选中物体的坐标 //搜索 geometry.vertices 中的 position 重新绘制选中物体相关linet var p = geometry.vertices.length; for(i=0;i<p;i++){ if(geometry.vertices[i] == position){ if (i == p - 1){//将最后一个物体的前一个物体坐标加入 geometryChange.vertices.push(geometry.vertices[p - 2]); geometryChange.vertices.push(position); } else if(i == 0){//将第一个物体的后一个坐标加入 geometryChange.vertices.push(position); geometryChange.vertices.push(geometry.vertices[i+1]); } else{ geometryChange.vertices.push(geometry.vertices[i-1]); geometryChange.vertices.push(position);//将物体前后相邻的加入 geometryChange.vertices.push(geometry.vertices[i+1]); } } }
这样我们就把选中物体相邻的物体坐标放在 geometryChange.vertices。现在我们知道柱子线的起点和终点坐标了,那柱子线怎么画,画哪里呢?
var temp = geometryChange.vertices.length; var xyz = geometryChange.vertices; //position(x,y,z),就是柱子线的中点位置,xw是起点和中点的 X 轴方向距离,zh是起点和中点的 Z 轴方向距离,cheight是起点和中点的空间距离 var x,y,z,xw,zh,cheight;
先知道柱子线的position(x,y,z),xyz[i]是柱子线起点,xyz[i+1]是柱子线终点。
x= (xyz[i].x+xyz[i+1].x)/2; y=0.1;//线我是画在地面附近的,所以y默认0.1 z=(xyz[i].z+xyz[i+1].z)/2
再来求柱子线的长度
xw=xyz[i].x-xyz[i+1].x; zh=xyz[i].z-xyz[i+1].z; cheight=Math.sqrt(xw*xw+zh*zh);//圆柱体长度,勾股定理
这下画柱子线
var material = new THREE.MeshPhongMaterial( { color: 0x156289, emissive: 0x00FFFF, side: THREE.DoubleSide, shading: THREE.FlatShading, vertexColors:THREE.FaceColors } ); var cylinder = new THREE.Mesh( geometryCylinderLine, material ); cylinder.position.set( x, y, z );//两实体的中点,也就是柱子线的中点,自己理解
可是发现画的柱子是竖直向上的
这个时候就需要改变柱子线的模型矩阵的,对它做旋转,达到我们理想的效果。
我们先分析一下怎么旋转,首先将绕 x 轴转90° ,让柱子线躺地上。
cylinder.rotation.x -= Math.PI * 0.5;
之后如下图分析所示,红线就是躺地上的柱子(自行脑补3D场景)。
其中红线和黑线长度相同,红线只需要旋转 θ 角度之后就可以和黑线重合,达到我们要的效果。
θ = Math.asin(xw/cheight);//弧度制
这个时候知道转多少度了,转就ok
//考虑到局部坐标系和全局坐标系的转换,柱体是在全局坐标系下旋转 if(xyz[i].x > xyz[i+1].x && xyz[i].z < xyz[i+1].z) cylinder.rotation.z -= Math.asin(xw/cheight);//Math.asin(xw/cheight)为柱体要旋转的角度 else if(xyz[i].x > xyz[i+1].x && xyz[i].z > xyz[i+1].z) cylinder.rotation.z += Math.asin(xw/cheight); else if(xyz[i].x < xyz[i+1].x && xyz[i].z < xyz[i+1].z) cylinder.rotation.z -= Math.asin(xw/cheight); else cylinder.rotation.z += Math.asin(xw/cheight);
好了,柱子线画出来了。
当然不止一条柱子线,把当前的都加入lineGroup 中
lineGroup.add( cylinder );
scene.add( lineGroup );
下次绘制的时候只要移除当前的 lineGroup 即可。
scene.remove( lineGroup );
第四步
加发光特效
借助threejs的 outlinePass 通道
composer = new THREE.EffectComposer( renderer ); var renderPass = new THREE.RenderPass( scene, camera ); composer.addPass( renderPass ); outlinePass = new THREE.OutlinePass( new THREE.Vector2( window.innerWidth, window.innerHeight ), scene, camera ); composer.addPass( outlinePass ); var onLoad = function ( texture ) { outlinePass.patternTexture = texture; texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; }; var loader = new THREE.TextureLoader(); loader.load( \'tri_pattern.jpg\', onLoad ); effectFXAA = new THREE.ShaderPass( THREE.FXAAShader ); effectFXAA.uniforms[ \'resolution\' ].value.set( 1 / window.innerWidth, 1 / window.innerHeight ); effectFXAA.renderToScreen = true; composer.addPass( effectFXAA );
在选中时,将物体和相应柱子线加入 outlinePass 渲染目标中即可。
selectedObjects = []; selectedObjects.push( lineGroup );//给选中的线条和物体加发光特效 selectedObjects.push( intersects[ 0 ].object ); outlinePass.selectedObjects = selectedObjects;
ok,这就实现了,点击交互的简单特效。
当然,这只是个示例,要把它用到复杂的3D场景中,还需要很多的事情要做,加油。
有错误敬请指正。
以上是关于记录--ThreeJs手搓一个罗盘特效的主要内容,如果未能解决你的问题,请参考以下文章