diff --git a/build/three.module.js b/build/three.module.js index 8fd0f0ab44106d..920b632861af05 100644 --- a/build/three.module.js +++ b/build/three.module.js @@ -3,7 +3,7 @@ * Copyright 2010-2026 Three.js Authors * SPDX-License-Identifier: MIT */ -import { Matrix3, Vector2, Color, Vector3, mergeUniforms, CubeUVReflectionMapping, Mesh, BoxGeometry, ShaderMaterial, BackSide, cloneUniforms, Matrix4, ColorManagement, SRGBTransfer, PlaneGeometry, FrontSide, getUnlitUniformColorSpace, IntType, warn, HalfFloatType, UnsignedByteType, FloatType, RGBAFormat, Plane, CubeReflectionMapping, CubeRefractionMapping, BufferGeometry, OrthographicCamera, PerspectiveCamera, NoToneMapping, MeshBasicMaterial, NoBlending, WebGLRenderTarget, BufferAttribute, LinearSRGBColorSpace, LinearFilter, CubeTexture, LinearMipmapLinearFilter, CubeCamera, EquirectangularReflectionMapping, EquirectangularRefractionMapping, warnOnce, Uint32BufferAttribute, Uint16BufferAttribute, error, DataArrayTexture, Vector4, DepthTexture, Float32BufferAttribute, RawShaderMaterial, CustomToneMapping, NeutralToneMapping, AgXToneMapping, ACESFilmicToneMapping, CineonToneMapping, ReinhardToneMapping, LinearToneMapping, Data3DTexture, GreaterEqualCompare, LessEqualCompare, Texture, GLSL3, VSMShadowMap, PCFShadowMap, AddOperation, MixOperation, MultiplyOperation, LinearTransfer, UniformsUtils, DoubleSide, NormalBlending, TangentSpaceNormalMap, ObjectSpaceNormalMap, Layers, RGFormat, RG11_EAC_Format, RED_GREEN_RGTC2_Format, MeshDepthMaterial, MeshDistanceMaterial, PCFSoftShadowMap, DepthFormat, NearestFilter, CubeDepthTexture, UnsignedIntType, Frustum, LessEqualDepth, ReverseSubtractEquation, SubtractEquation, AddEquation, OneMinusConstantAlphaFactor, ConstantAlphaFactor, OneMinusConstantColorFactor, ConstantColorFactor, OneMinusDstAlphaFactor, OneMinusDstColorFactor, OneMinusSrcAlphaFactor, OneMinusSrcColorFactor, DstAlphaFactor, DstColorFactor, SrcAlphaSaturateFactor, SrcAlphaFactor, SrcColorFactor, OneFactor, ZeroFactor, NotEqualDepth, GreaterDepth, GreaterEqualDepth, EqualDepth, LessDepth, AlwaysDepth, NeverDepth, CullFaceNone, CullFaceBack, CullFaceFront, CustomBlending, MultiplyBlending, SubtractiveBlending, AdditiveBlending, ReversedDepthFuncs, MinEquation, MaxEquation, MirroredRepeatWrapping, ClampToEdgeWrapping, RepeatWrapping, LinearMipmapNearestFilter, NearestMipmapLinearFilter, NearestMipmapNearestFilter, NotEqualCompare, GreaterCompare, EqualCompare, LessCompare, AlwaysCompare, NeverCompare, NoColorSpace, DepthStencilFormat, getByteLength, UnsignedInt248Type, UnsignedShortType, createElementNS, UnsignedShort4444Type, UnsignedShort5551Type, UnsignedInt5999Type, UnsignedInt101111Type, ByteType, ShortType, AlphaFormat, RGBFormat, RedFormat, RedIntegerFormat, RGIntegerFormat, RGBAIntegerFormat, RGB_S3TC_DXT1_Format, RGBA_S3TC_DXT1_Format, RGBA_S3TC_DXT3_Format, RGBA_S3TC_DXT5_Format, RGB_PVRTC_4BPPV1_Format, RGB_PVRTC_2BPPV1_Format, RGBA_PVRTC_4BPPV1_Format, RGBA_PVRTC_2BPPV1_Format, RGB_ETC1_Format, RGB_ETC2_Format, RGBA_ETC2_EAC_Format, R11_EAC_Format, SIGNED_R11_EAC_Format, SIGNED_RG11_EAC_Format, RGBA_ASTC_4x4_Format, RGBA_ASTC_5x4_Format, RGBA_ASTC_5x5_Format, RGBA_ASTC_6x5_Format, RGBA_ASTC_6x6_Format, RGBA_ASTC_8x5_Format, RGBA_ASTC_8x6_Format, RGBA_ASTC_8x8_Format, RGBA_ASTC_10x5_Format, RGBA_ASTC_10x6_Format, RGBA_ASTC_10x8_Format, RGBA_ASTC_10x10_Format, RGBA_ASTC_12x10_Format, RGBA_ASTC_12x12_Format, RGBA_BPTC_Format, RGB_BPTC_SIGNED_Format, RGB_BPTC_UNSIGNED_Format, RED_RGTC1_Format, SIGNED_RED_RGTC1_Format, SIGNED_RED_GREEN_RGTC2_Format, ExternalTexture, EventDispatcher, ArrayCamera, WebXRController, RAD2DEG, DataTexture, createCanvasElement, SRGBColorSpace, REVISION, log, WebGLCoordinateSystem, probeAsync } from './three.core.js'; +import { Matrix3, Vector2, Color, Vector3, mergeUniforms, CubeUVReflectionMapping, Mesh, BoxGeometry, ShaderMaterial, BackSide, cloneUniforms, Matrix4, ColorManagement, SRGBTransfer, PlaneGeometry, FrontSide, getUnlitUniformColorSpace, IntType, warn, HalfFloatType, UnsignedByteType, FloatType, RGBAFormat, Plane, CubeReflectionMapping, CubeRefractionMapping, BufferGeometry, OrthographicCamera, PerspectiveCamera, NoToneMapping, MeshBasicMaterial, NoBlending, WebGLRenderTarget, BufferAttribute, LinearSRGBColorSpace, LinearFilter, CubeTexture, LinearMipmapLinearFilter, CubeCamera, EquirectangularReflectionMapping, EquirectangularRefractionMapping, warnOnce, Uint32BufferAttribute, Uint16BufferAttribute, error, DataArrayTexture, Vector4, DepthTexture, DepthStencilFormat, UnsignedInt248Type, Float32BufferAttribute, RawShaderMaterial, CustomToneMapping, NeutralToneMapping, AgXToneMapping, ACESFilmicToneMapping, CineonToneMapping, ReinhardToneMapping, LinearToneMapping, Data3DTexture, GreaterEqualCompare, LessEqualCompare, Texture, GLSL3, VSMShadowMap, PCFShadowMap, AddOperation, MixOperation, MultiplyOperation, LinearTransfer, UniformsUtils, DoubleSide, NormalBlending, TangentSpaceNormalMap, ObjectSpaceNormalMap, Layers, RGFormat, RG11_EAC_Format, RED_GREEN_RGTC2_Format, MeshDepthMaterial, MeshDistanceMaterial, PCFSoftShadowMap, DepthFormat, NearestFilter, CubeDepthTexture, UnsignedIntType, Frustum, LessEqualDepth, ReverseSubtractEquation, SubtractEquation, AddEquation, OneMinusConstantAlphaFactor, ConstantAlphaFactor, OneMinusConstantColorFactor, ConstantColorFactor, OneMinusDstAlphaFactor, OneMinusDstColorFactor, OneMinusSrcAlphaFactor, OneMinusSrcColorFactor, DstAlphaFactor, DstColorFactor, SrcAlphaSaturateFactor, SrcAlphaFactor, SrcColorFactor, OneFactor, ZeroFactor, NotEqualDepth, GreaterDepth, GreaterEqualDepth, EqualDepth, LessDepth, AlwaysDepth, NeverDepth, CullFaceNone, CullFaceBack, CullFaceFront, CustomBlending, MultiplyBlending, SubtractiveBlending, AdditiveBlending, ReversedDepthFuncs, MinEquation, MaxEquation, MirroredRepeatWrapping, ClampToEdgeWrapping, RepeatWrapping, LinearMipmapNearestFilter, NearestMipmapLinearFilter, NearestMipmapNearestFilter, NotEqualCompare, GreaterCompare, EqualCompare, LessCompare, AlwaysCompare, NeverCompare, NoColorSpace, getByteLength, UnsignedShortType, createElementNS, UnsignedShort4444Type, UnsignedShort5551Type, UnsignedInt5999Type, UnsignedInt101111Type, ByteType, ShortType, AlphaFormat, RGBFormat, RedFormat, RedIntegerFormat, RGIntegerFormat, RGBAIntegerFormat, RGB_S3TC_DXT1_Format, RGBA_S3TC_DXT1_Format, RGBA_S3TC_DXT3_Format, RGBA_S3TC_DXT5_Format, RGB_PVRTC_4BPPV1_Format, RGB_PVRTC_2BPPV1_Format, RGBA_PVRTC_4BPPV1_Format, RGBA_PVRTC_2BPPV1_Format, RGB_ETC1_Format, RGB_ETC2_Format, RGBA_ETC2_EAC_Format, R11_EAC_Format, SIGNED_R11_EAC_Format, SIGNED_RG11_EAC_Format, RGBA_ASTC_4x4_Format, RGBA_ASTC_5x4_Format, RGBA_ASTC_5x5_Format, RGBA_ASTC_6x5_Format, RGBA_ASTC_6x6_Format, RGBA_ASTC_8x5_Format, RGBA_ASTC_8x6_Format, RGBA_ASTC_8x8_Format, RGBA_ASTC_10x5_Format, RGBA_ASTC_10x6_Format, RGBA_ASTC_10x8_Format, RGBA_ASTC_10x10_Format, RGBA_ASTC_12x10_Format, RGBA_ASTC_12x12_Format, RGBA_BPTC_Format, RGB_BPTC_SIGNED_Format, RGB_BPTC_UNSIGNED_Format, RED_RGTC1_Format, SIGNED_RED_RGTC1_Format, SIGNED_RED_GREEN_RGTC2_Format, ExternalTexture, EventDispatcher, ArrayCamera, WebXRController, RAD2DEG, DataTexture, createCanvasElement, SRGBColorSpace, REVISION, log, WebGLCoordinateSystem, probeAsync } from './three.core.js'; export { AdditiveAnimationBlendMode, AlwaysStencilFunc, AmbientLight, AnimationAction, AnimationClip, AnimationLoader, AnimationMixer, AnimationObjectGroup, AnimationUtils, ArcCurve, ArrowHelper, AttachedBindMode, Audio, AudioAnalyser, AudioContext, AudioListener, AudioLoader, AxesHelper, BasicDepthPacking, BasicShadowMap, BatchedMesh, BezierInterpolant, Bone, BooleanKeyframeTrack, Box2, Box3, Box3Helper, BoxHelper, BufferGeometryLoader, Cache, Camera, CameraHelper, CanvasTexture, CapsuleGeometry, CatmullRomCurve3, CircleGeometry, Clock, ColorKeyframeTrack, Compatibility, CompressedArrayTexture, CompressedCubeTexture, CompressedTexture, CompressedTextureLoader, ConeGeometry, Controls, CubeTextureLoader, CubicBezierCurve, CubicBezierCurve3, CubicInterpolant, CullFaceFrontBack, Curve, CurvePath, CylinderGeometry, Cylindrical, DataTextureLoader, DataUtils, DecrementStencilOp, DecrementWrapStencilOp, DefaultLoadingManager, DetachedBindMode, DirectionalLight, DirectionalLightHelper, DiscreteInterpolant, DodecahedronGeometry, DynamicCopyUsage, DynamicDrawUsage, DynamicReadUsage, EdgesGeometry, EllipseCurve, EqualStencilFunc, Euler, ExtrudeGeometry, FileLoader, Float16BufferAttribute, Fog, FogExp2, FramebufferTexture, FrustumArray, GLBufferAttribute, GLSL1, GreaterEqualStencilFunc, GreaterStencilFunc, GridHelper, Group, HTMLTexture, HemisphereLight, HemisphereLightHelper, IcosahedronGeometry, ImageBitmapLoader, ImageLoader, ImageUtils, IncrementStencilOp, IncrementWrapStencilOp, InstancedBufferAttribute, InstancedBufferGeometry, InstancedInterleavedBuffer, InstancedMesh, Int16BufferAttribute, Int32BufferAttribute, Int8BufferAttribute, InterleavedBuffer, InterleavedBufferAttribute, Interpolant, InterpolateBezier, InterpolateDiscrete, InterpolateLinear, InterpolateSmooth, InterpolationSamplingMode, InterpolationSamplingType, InvertStencilOp, KeepStencilOp, KeyframeTrack, LOD, LatheGeometry, LessEqualStencilFunc, LessStencilFunc, Light, LightProbe, Line, Line3, LineBasicMaterial, LineCurve, LineCurve3, LineDashedMaterial, LineLoop, LineSegments, LinearInterpolant, LinearMipMapLinearFilter, LinearMipMapNearestFilter, Loader, LoaderUtils, LoadingManager, LoopOnce, LoopPingPong, LoopRepeat, MOUSE, Material, MaterialBlending, MaterialLoader, MathUtils, Matrix2, MeshLambertMaterial, MeshMatcapMaterial, MeshNormalMaterial, MeshPhongMaterial, MeshPhysicalMaterial, MeshStandardMaterial, MeshToonMaterial, NearestMipMapLinearFilter, NearestMipMapNearestFilter, NeverStencilFunc, NoNormalPacking, NormalAnimationBlendMode, NormalGAPacking, NormalRGPacking, NotEqualStencilFunc, NumberKeyframeTrack, Object3D, ObjectLoader, OctahedronGeometry, Path, PlaneHelper, PointLight, PointLightHelper, Points, PointsMaterial, PolarGridHelper, PolyhedronGeometry, PositionalAudio, PropertyBinding, PropertyMixer, QuadraticBezierCurve, QuadraticBezierCurve3, Quaternion, QuaternionKeyframeTrack, QuaternionLinearInterpolant, RGBADepthPacking, RGBDepthPacking, RGBIntegerFormat, RGDepthPacking, Ray, Raycaster, RectAreaLight, RenderTarget, RenderTarget3D, ReplaceStencilOp, RingGeometry, Scene, ShadowMaterial, Shape, ShapeGeometry, ShapePath, ShapeUtils, Skeleton, SkeletonHelper, SkinnedMesh, Source, Sphere, SphereGeometry, Spherical, SphericalHarmonics3, SplineCurve, SpotLight, SpotLightHelper, Sprite, SpriteMaterial, StaticCopyUsage, StaticDrawUsage, StaticReadUsage, StereoCamera, StreamCopyUsage, StreamDrawUsage, StreamReadUsage, StringKeyframeTrack, TOUCH, TetrahedronGeometry, TextureLoader, TextureUtils, Timer, TimestampQuery, TorusGeometry, TorusKnotGeometry, Triangle, TriangleFanDrawMode, TriangleStripDrawMode, TrianglesDrawMode, TubeGeometry, UVMapping, Uint8BufferAttribute, Uint8ClampedBufferAttribute, Uniform, UniformsGroup, VectorKeyframeTrack, VideoFrameTexture, VideoTexture, WebGL3DRenderTarget, WebGLArrayRenderTarget, WebGPUCoordinateSystem, WireframeGeometry, WrapAroundEnding, ZeroCurvatureEnding, ZeroSlopeEnding, ZeroStencilOp, getConsoleFunction, setConsoleFunction } from './three.core.js'; function WebGLAnimation() { @@ -4693,13 +4693,30 @@ const toneMappingMap = { function WebGLOutput( type, width, height, antialias, depth, stencil ) { + // make sure depth texture matches renderer settings + + let depthTexture; + + if ( depth === true ) { + + depthTexture = new DepthTexture( width, height ); + + if ( stencil === true ) { + + depthTexture.format = DepthStencilFormat; + depthTexture.type = UnsignedInt248Type; + + } + + } + // render targets for scene and post-processing const targetA = new WebGLRenderTarget( width, height, { type: type, depthBuffer: depth, stencilBuffer: stencil, samples: antialias ? 4 : 0, - depthTexture: depth ? new DepthTexture( width, height ) : undefined + depthTexture: depthTexture } ); const targetB = new WebGLRenderTarget( width, height, { @@ -11967,8 +11984,6 @@ function WebGLTextures( _gl, extensions, state, properties, capabilities, utils, } - texture.clearLayerUpdates(); - } else { state.compressedTexSubImage3D( _gl.TEXTURE_2D_ARRAY, i, 0, 0, 0, mipmap.width, mipmap.height, image.depth, glFormat, mipmap.data ); @@ -12009,6 +12024,8 @@ function WebGLTextures( _gl, extensions, state, properties, capabilities, utils, } + if ( texture.layerUpdates.size > 0 ) texture.clearLayerUpdates(); + } else { if ( useTexStorage && allocateMemory ) { diff --git a/build/three.webgpu.js b/build/three.webgpu.js index f5a47aef3d798f..3cba712f0216ed 100644 --- a/build/three.webgpu.js +++ b/build/three.webgpu.js @@ -46,6 +46,7 @@ const refreshUniforms = [ 'normalMap', 'normalScale', 'opacity', + 'retroreflective', 'roughness', 'roughnessMap', 'sheen', @@ -195,6 +196,7 @@ class NodeMaterialObserver { data = { geometryId: geometry.id, + materialVersion: this.getMaterialData( renderObject.material )._version, worldMatrix: object.matrixWorld.clone() }; @@ -335,7 +337,7 @@ class NodeMaterialObserver { if ( data === undefined ) { - data = { _renderId: -1, _equal: false }; + data = { _renderId: -1, _version: 0 }; for ( const property of this.refreshUniforms ) { @@ -399,19 +401,21 @@ class NodeMaterialObserver { const materialData = this.getMaterialData( renderObject.material ); - // check the material for the "equal" state just once per render for all render objects + // check the material properties just once per render for all render objects if ( materialData._renderId !== renderId ) { materialData._renderId = renderId; + let changed = false; + for ( const property in materialData ) { const value = materialData[ property ]; const mtlValue = material[ property ]; if ( property === '_renderId' ) continue; - if ( property === '_equal' ) continue; + if ( property === '_version' ) continue; if ( value.equals !== undefined ) { @@ -419,8 +423,7 @@ class NodeMaterialObserver { value.copy( mtlValue ); - materialData._equal = false; - return false; + changed = true; } @@ -431,8 +434,7 @@ class NodeMaterialObserver { value.id = mtlValue.id; value.version = mtlValue.version; - materialData._equal = false; - return false; + changed = true; } @@ -440,34 +442,38 @@ class NodeMaterialObserver { materialData[ property ] = mtlValue; - materialData._equal = false; - return false; + changed = true; } } - if ( materialData.transmission > 0 ) { + if ( changed === true ) materialData._version ++; - const { width, height } = renderObject.context; + } - if ( renderObjectData.bufferWidth !== width || renderObjectData.bufferHeight !== height ) { + // a version mismatch means the material has changed since this render object was last refreshed - renderObjectData.bufferWidth = width; - renderObjectData.bufferHeight = height; + if ( renderObjectData.materialVersion !== materialData._version ) { - materialData._equal = false; - return false; + renderObjectData.materialVersion = materialData._version; - } + return false; - } + } - materialData._equal = true; + if ( materialData.transmission > 0 ) { - } else { + const { width, height } = renderObject.context; - if ( materialData._equal === false ) return false; + if ( renderObjectData.bufferWidth !== width || renderObjectData.bufferHeight !== height ) { + + renderObjectData.bufferWidth = width; + renderObjectData.bufferHeight = height; + + return false; + + } } @@ -22883,15 +22889,24 @@ class Line2NodeMaterial extends NodeMaterial { } /** - * Setups the position in clip space for the vertex stage of the fat line. - * Overrides the default model-view-projection to return the expanded fat line vertex coordinates. + * Setups the position of the expanded fat line vertex in local space. * * @param {NodeBuilder} builder - The current node builder. - * @return {Node} The position of the fat line vertex in clip space. + * @return {Node} The position of the fat line vertex in local space. */ - setupModelViewProjection( /*builder*/ ) { + setupPosition( builder ) { + + const localPosition = modelWorldMatrixInverse.mul( cameraWorldMatrix ).mul( cameraProjectionMatrixInverse ).mul( mvpLine ); + + positionLocal.assign( localPosition.xyz.div( localPosition.w ) ); - return mvpLine; + if ( builder.needsPreviousData() ) { + + positionPrevious.assign( positionLocal ); + + } + + return super.setupPosition( builder ); } @@ -48276,8 +48291,8 @@ const mx_heighttonormal = ( input, scale/*, texcoord*/ ) => { const getParallaxCorrectNormal = /*@__PURE__*/ Fn( ( [ normal, cubeSize, cubePos ] ) => { const nDir = normalize( normal ).toVar(); - const rbmax = sub( float( 0.5 ).mul( cubeSize.sub( cubePos ) ), positionWorld ).div( nDir ).toVar(); - const rbmin = sub( float( -0.5 ).mul( cubeSize.sub( cubePos ) ), positionWorld ).div( nDir ).toVar(); + const rbmax = cubeSize.mul( 0.5 ).add( cubePos ).sub( positionWorld ).div( nDir ).toVar(); + const rbmin = cubeSize.mul( -0.5 ).add( cubePos ).sub( positionWorld ).div( nDir ).toVar(); const rbminmax = vec3().toVar(); rbminmax.x = nDir.x.greaterThan( float( 0 ) ).select( rbmax.x, rbmin.x ); rbminmax.y = nDir.y.greaterThan( float( 0 ) ).select( rbmax.y, rbmin.y ); @@ -69830,12 +69845,29 @@ class WebGLTextureUtils { if ( texture.isCompressedArrayTexture ) { - if ( texture.format !== gl.RGBA ) { if ( glFormat !== null ) { - gl.compressedTexSubImage3D( gl.TEXTURE_2D_ARRAY, i, 0, 0, 0, mipmap.width, mipmap.height, image.depth, glFormat, mipmap.data ); + if ( texture.layerUpdates.size > 0 ) { + + const layerByteLength = getByteLength( mipmap.width, mipmap.height, texture.format, texture.type ); + + for ( const layerIndex of texture.layerUpdates ) { + + const layerData = mipmap.data.subarray( + layerIndex * layerByteLength / mipmap.data.BYTES_PER_ELEMENT, + ( layerIndex + 1 ) * layerByteLength / mipmap.data.BYTES_PER_ELEMENT + ); + gl.compressedTexSubImage3D( gl.TEXTURE_2D_ARRAY, i, 0, 0, layerIndex, mipmap.width, mipmap.height, 1, glFormat, layerData ); + + } + + } else { + + gl.compressedTexSubImage3D( gl.TEXTURE_2D_ARRAY, i, 0, 0, 0, mipmap.width, mipmap.height, image.depth, glFormat, mipmap.data ); + + } } else { @@ -69865,6 +69897,7 @@ class WebGLTextureUtils { } + if ( texture.isCompressedArrayTexture && texture.layerUpdates.size > 0 ) texture.clearLayerUpdates(); } else if ( texture.isCubeTexture ) { diff --git a/build/three.webgpu.nodes.js b/build/three.webgpu.nodes.js index 6ee8b25b4f5fb5..57e1155e7d55b0 100644 --- a/build/three.webgpu.nodes.js +++ b/build/three.webgpu.nodes.js @@ -46,6 +46,7 @@ const refreshUniforms = [ 'normalMap', 'normalScale', 'opacity', + 'retroreflective', 'roughness', 'roughnessMap', 'sheen', @@ -195,6 +196,7 @@ class NodeMaterialObserver { data = { geometryId: geometry.id, + materialVersion: this.getMaterialData( renderObject.material )._version, worldMatrix: object.matrixWorld.clone() }; @@ -335,7 +337,7 @@ class NodeMaterialObserver { if ( data === undefined ) { - data = { _renderId: -1, _equal: false }; + data = { _renderId: -1, _version: 0 }; for ( const property of this.refreshUniforms ) { @@ -399,19 +401,21 @@ class NodeMaterialObserver { const materialData = this.getMaterialData( renderObject.material ); - // check the material for the "equal" state just once per render for all render objects + // check the material properties just once per render for all render objects if ( materialData._renderId !== renderId ) { materialData._renderId = renderId; + let changed = false; + for ( const property in materialData ) { const value = materialData[ property ]; const mtlValue = material[ property ]; if ( property === '_renderId' ) continue; - if ( property === '_equal' ) continue; + if ( property === '_version' ) continue; if ( value.equals !== undefined ) { @@ -419,8 +423,7 @@ class NodeMaterialObserver { value.copy( mtlValue ); - materialData._equal = false; - return false; + changed = true; } @@ -431,8 +434,7 @@ class NodeMaterialObserver { value.id = mtlValue.id; value.version = mtlValue.version; - materialData._equal = false; - return false; + changed = true; } @@ -440,34 +442,38 @@ class NodeMaterialObserver { materialData[ property ] = mtlValue; - materialData._equal = false; - return false; + changed = true; } } - if ( materialData.transmission > 0 ) { + if ( changed === true ) materialData._version ++; - const { width, height } = renderObject.context; + } - if ( renderObjectData.bufferWidth !== width || renderObjectData.bufferHeight !== height ) { + // a version mismatch means the material has changed since this render object was last refreshed - renderObjectData.bufferWidth = width; - renderObjectData.bufferHeight = height; + if ( renderObjectData.materialVersion !== materialData._version ) { - materialData._equal = false; - return false; + renderObjectData.materialVersion = materialData._version; - } + return false; - } + } - materialData._equal = true; + if ( materialData.transmission > 0 ) { - } else { + const { width, height } = renderObject.context; - if ( materialData._equal === false ) return false; + if ( renderObjectData.bufferWidth !== width || renderObjectData.bufferHeight !== height ) { + + renderObjectData.bufferWidth = width; + renderObjectData.bufferHeight = height; + + return false; + + } } @@ -22883,15 +22889,24 @@ class Line2NodeMaterial extends NodeMaterial { } /** - * Setups the position in clip space for the vertex stage of the fat line. - * Overrides the default model-view-projection to return the expanded fat line vertex coordinates. + * Setups the position of the expanded fat line vertex in local space. * * @param {NodeBuilder} builder - The current node builder. - * @return {Node} The position of the fat line vertex in clip space. + * @return {Node} The position of the fat line vertex in local space. */ - setupModelViewProjection( /*builder*/ ) { + setupPosition( builder ) { + + const localPosition = modelWorldMatrixInverse.mul( cameraWorldMatrix ).mul( cameraProjectionMatrixInverse ).mul( mvpLine ); + + positionLocal.assign( localPosition.xyz.div( localPosition.w ) ); - return mvpLine; + if ( builder.needsPreviousData() ) { + + positionPrevious.assign( positionLocal ); + + } + + return super.setupPosition( builder ); } @@ -48276,8 +48291,8 @@ const mx_heighttonormal = ( input, scale/*, texcoord*/ ) => { const getParallaxCorrectNormal = /*@__PURE__*/ Fn( ( [ normal, cubeSize, cubePos ] ) => { const nDir = normalize( normal ).toVar(); - const rbmax = sub( float( 0.5 ).mul( cubeSize.sub( cubePos ) ), positionWorld ).div( nDir ).toVar(); - const rbmin = sub( float( -0.5 ).mul( cubeSize.sub( cubePos ) ), positionWorld ).div( nDir ).toVar(); + const rbmax = cubeSize.mul( 0.5 ).add( cubePos ).sub( positionWorld ).div( nDir ).toVar(); + const rbmin = cubeSize.mul( -0.5 ).add( cubePos ).sub( positionWorld ).div( nDir ).toVar(); const rbminmax = vec3().toVar(); rbminmax.x = nDir.x.greaterThan( float( 0 ) ).select( rbmax.x, rbmin.x ); rbminmax.y = nDir.y.greaterThan( float( 0 ) ).select( rbmax.y, rbmin.y ); @@ -69830,12 +69845,29 @@ class WebGLTextureUtils { if ( texture.isCompressedArrayTexture ) { - if ( texture.format !== gl.RGBA ) { if ( glFormat !== null ) { - gl.compressedTexSubImage3D( gl.TEXTURE_2D_ARRAY, i, 0, 0, 0, mipmap.width, mipmap.height, image.depth, glFormat, mipmap.data ); + if ( texture.layerUpdates.size > 0 ) { + + const layerByteLength = getByteLength( mipmap.width, mipmap.height, texture.format, texture.type ); + + for ( const layerIndex of texture.layerUpdates ) { + + const layerData = mipmap.data.subarray( + layerIndex * layerByteLength / mipmap.data.BYTES_PER_ELEMENT, + ( layerIndex + 1 ) * layerByteLength / mipmap.data.BYTES_PER_ELEMENT + ); + gl.compressedTexSubImage3D( gl.TEXTURE_2D_ARRAY, i, 0, 0, layerIndex, mipmap.width, mipmap.height, 1, glFormat, layerData ); + + } + + } else { + + gl.compressedTexSubImage3D( gl.TEXTURE_2D_ARRAY, i, 0, 0, 0, mipmap.width, mipmap.height, image.depth, glFormat, mipmap.data ); + + } } else { @@ -69865,6 +69897,7 @@ class WebGLTextureUtils { } + if ( texture.isCompressedArrayTexture && texture.layerUpdates.size > 0 ) texture.clearLayerUpdates(); } else if ( texture.isCubeTexture ) { diff --git a/examples/misc_controls_arcball.html b/examples/misc_controls_arcball.html index a9547544dec343..a4b3fa3b16f868 100644 --- a/examples/misc_controls_arcball.html +++ b/examples/misc_controls_arcball.html @@ -15,7 +15,7 @@
three.js - arcball controls
- Cerberus(FFVII Gun) model by Andrew Maximov. + Cerberus(FFVII Gun) model by Andrew Maximov.
- - -
-
-

Aligner les éléments HTML en 3D

-
-
-
-

Cet article fait partie d'une série d'articles sur three.js. Le premier article -est les bases de three.js. Si vous ne l'avez pas -encore lu et que vous débutez avec three.js, vous pourriez vouloir commencer par là.

-

Parfois, vous aimeriez afficher du texte dans votre scène 3D. Vous avez plusieurs options, -chacune avec ses avantages et ses inconvénients.

-
    -
  • Utiliser du texte 3D

    -

    Si vous regardez l'article sur les primitives, vous verrez la TextGeometry qui -permet de créer du texte 3D. Cela peut être utile pour des logos volants, mais probablement moins pour des statistiques, des informations, -ou l'étiquetage de nombreux éléments.

    -
  • -
  • Utiliser une texture avec du texte 2D dessiné dessus.

    -

    L'article sur l'utilisation d'un Canvas comme texture montre comment utiliser -un canvas comme texture. Vous pouvez dessiner du texte dans un canvas et l'afficher comme un panneau (billboard). -L'avantage ici pourrait être que le texte est intégré à la scène 3D. Pour quelque chose comme un terminal d'ordinateur -montré dans une scène 3D, cela pourrait être parfait.

    -
  • -
  • Utiliser des éléments HTML et les positionner pour correspondre à la 3D

    -

    L'avantage de cette approche est que vous pouvez utiliser tout le HTML. Votre HTML peut contenir plusieurs éléments. Il peut -être stylisé avec du CSS. Il peut également être sélectionné par l'utilisateur car c'est du vrai texte.

    -
  • -
-

Cet article couvrira cette dernière approche.

-

Commençons simplement. Nous allons créer une scène 3D avec quelques primitives et ajouter une étiquette à chaque primitive. Nous commencerons -avec un exemple tiré de l'article sur les pages responsives

-

Nous allons ajouter des OrbitControls comme nous l'avons fait dans l'article sur l'éclairage.

-
import * as THREE from 'three';
-+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-
-
const controls = new OrbitControls(camera, canvas);
-controls.target.set(0, 0, 0);
-controls.update();
-
-

Nous devons fournir un élément HTML pour contenir nos éléments d'étiquette

-
<body>
--  <canvas id="c"></canvas>
-+  <div id="container">
-+    <canvas id="c"></canvas>
-+    <div id="labels"></div>
-+  </div>
-</body>
-
-

En plaçant à la fois le canvas et le <div id="labels"> à l'intérieur d'un -conteneur parent, nous pouvons les faire se superposer avec ce CSS

-
#c {
--    width: 100%;
--    height: 100%;
-+    width: 100%;  /* laisser notre conteneur décider de notre taille */
-+    height: 100%;
-    display: block;
-}
-+#container {
-+  position: relative;  /* fait de ceci l'origine de ses enfants */
-+  width: 100%;
-+  height: 100%;
-+  overflow: hidden;
-+}
-+#labels {
-+  position: absolute;  /* nous permet de nous positionner à l'intérieur du conteneur */
-+  left: 0;             /* place notre position en haut à gauche du conteneur */
-+  top: 0;
-+  color: white;
-+}
-
-

ajoutons également du CSS pour les étiquettes elles-mêmes

-
#labels>div {
-  position: absolute;  /* nous permet de les positionner à l'intérieur du conteneur */
-  left: 0;             /* place leur position par défaut en haut à gauche du conteneur */
-  top: 0;
-  cursor: pointer;     /* change le curseur en main quand la souris est dessus */
-  font-size: large;
-  user-select: none;   /* empêche la sélection du texte */
-  text-shadow:         /* crée un contour noir */
-    -1px -1px 0 #000,
-     0   -1px 0 #000,
-     1px -1px 0 #000,
-     1px  0   0 #000,
-     1px  1px 0 #000,
-     0    1px 0 #000,
-    -1px  1px 0 #000,
-    -1px  0   0 #000;
-}
-#labels>div:hover {
-  color: red;
-}
-
-

Maintenant, dans notre code, nous n'avons pas grand-chose à ajouter. Nous avions une fonction -makeInstance que nous utilisions pour générer des cubes. Faisons en sorte -qu'elle ajoute également un élément d'étiquette.

-
+const labelContainerElem = document.querySelector('#labels');
-
--function makeInstance(geometry, color, x) {
-+function makeInstance(geometry, color, x, name) {
-  const material = new THREE.MeshPhongMaterial({color});
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  cube.position.x = x;
-
-+  const elem = document.createElement('div');
-+  elem.textContent = name;
-+  labelContainerElem.appendChild(elem);
-
--  return cube;
-+  return {cube, elem};
-}
-
-

Comme vous pouvez le voir, nous ajoutons un <div> au conteneur, un pour chaque cube. Nous -retournons également un objet avec à la fois le cube et l'elem pour l'étiquette.

-

Pour l'appeler, nous devons fournir un nom pour chacun

-
const cubes = [
--  makeInstance(geometry, 0x44aa88,  0),
--  makeInstance(geometry, 0x8844aa, -2),
--  makeInstance(geometry, 0xaa8844,  2),
-+  makeInstance(geometry, 0x44aa88,  0, 'Aqua'),
-+  makeInstance(geometry, 0x8844aa, -2, 'Purple'),
-+  makeInstance(geometry, 0xaa8844,  2, 'Gold'),
-];
-
-

Ce qui reste est le positionnement des éléments d'étiquette au moment du rendu

-
const tempV = new THREE.Vector3();
-
-...
-
--cubes.forEach((cube, ndx) => {
-+cubes.forEach((cubeInfo, ndx) => {
-+  const {cube, elem} = cubeInfo;
-  const speed = 1 + ndx * .1;
-  const rot = time * speed;
-  cube.rotation.x = rot;
-  cube.rotation.y = rot;
-
-+  // obtient la position du centre du cube
-+  cube.updateWorldMatrix(true, false);
-+  cube.getWorldPosition(tempV);
-+
-+  // obtient la coordonnée d'écran normalisée de cette position
-+  // x et y seront dans la plage de -1 à +1, avec x = -1 étant
-+  // à gauche et y = -1 étant en bas
-+  tempV.project(camera);
-+
-+  // convertit la position normalisée en coordonnées CSS
-+  const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
-+  const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
-+
-+  // déplace l'élément à cette position
-+  elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
-});
-
-

Et avec cela, nous avons des étiquettes alignées sur leurs objets correspondants.

-

- -

-

Il y a quelques problèmes que nous voudrons probablement résoudre.

-

L'un d'eux est que si nous faisons pivoter les objets de manière à ce qu'ils se chevauchent, toutes les étiquettes -se chevauchent également.

-
- -

Un autre est que si nous dézoomons beaucoup, de sorte que les objets sortent -du frustum, les étiquettes apparaîtront toujours.

-

Une solution possible au problème des objets qui se chevauchent est d'utiliser -le code de sélection (picking) de l'article sur la sélection. -Nous passerons la position de l'objet à l'écran, puis nous demanderons -au RayCaster de nous dire quels objets ont été intersectés. -Si notre objet n'est pas le premier, alors il n'est pas à l'avant.

-
const tempV = new THREE.Vector3();
-+const raycaster = new THREE.Raycaster();
-
-...
-
-cubes.forEach((cubeInfo, ndx) => {
-  const {cube, elem} = cubeInfo;
-  const speed = 1 + ndx * .1;
-  const rot = time * speed;
-  cube.rotation.x = rot;
-  cube.rotation.y = rot;
-
-  // obtient la position du centre du cube
-  cube.updateWorldMatrix(true, false);
-  cube.getWorldPosition(tempV);
-
-  // obtient la coordonnée d'écran normalisée de cette position
-  // x et y seront dans la plage de -1 à +1, avec x = -1 étant
-  // à gauche et y = -1 étant en bas
-  tempV.project(camera);
-
-+  // demande au raycaster tous les objets qui intersectent
-+  // depuis l'œil vers la position de cet objet
-+  raycaster.setFromCamera(tempV, camera);
-+  const intersectedObjects = raycaster.intersectObjects(scene.children);
-+  // Nous sommes visibles si la première intersection est cet objet.
-+  const show = intersectedObjects.length && cube === intersectedObjects[0].object;
-+
-+  if (!show) {
-+    // cache l'étiquette
-+    elem.style.display = 'none';
-+  } else {
-+    // affiche l'étiquette
-+    elem.style.display = '';
-
-    // convertit la position normalisée en coordonnées CSS
-    const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
-    const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
-
-    // déplace l'élément à cette position
-    elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
-+  }
-});
-
-

Cela gère le chevauchement.

-

Pour gérer la sortie du frustum, nous pouvons ajouter cette vérification si l'origine de -l'objet est en dehors du frustum en vérifiant tempV.z

-
-  if (!show) {
-+  if (!show || Math.abs(tempV.z) > 1) {
-    // cache l'étiquette
-    elem.style.display = 'none';
-
-

Cela fonctionne plus ou moins car les coordonnées normalisées que nous avons calculées incluent une valeur z -qui va de -1 lorsqu'elle est à la partie near de notre frustum de caméra à +1 lorsqu'elle est -à la partie far de notre frustum de caméra.

-

- -

-

Pour la vérification du frustum, la solution ci-dessus échoue car nous ne vérifions que l'origine de l'objet. Pour un objet -volumineux, cette origine pourrait sortir du frustum, mais la moitié de l'objet pourrait encore s'y trouver.

-

Une solution plus correcte serait de vérifier si l'objet lui-même est dans le frustum -ou non. Malheureusement, cette vérification est lente. Pour 3 cubes, ce ne sera pas un problème, -mais pour de nombreux objets, cela pourrait l'être.

-

Three.js fournit quelques fonctions pour vérifier si la sphère englobante d'un objet est -dans un frustum

-
// au moment de l'initialisation
-const frustum = new THREE.Frustum();
-const viewProjection = new THREE.Matrix4();
-
-...
-
-// avant de vérifier
-camera.updateMatrix();
-camera.updateMatrixWorld();
-camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
-
-...
-
-// puis pour chaque maillage
-someMesh.updateMatrix();
-someMesh.updateMatrixWorld();
-
-viewProjection.multiplyMatrices(
-    camera.projectionMatrix, camera.matrixWorldInverse);
-frustum.setFromProjectionMatrix(viewProjection);
-const inFrustum = frustum.contains(someMesh));
-
-

Notre solution actuelle de chevauchement a des problèmes similaires. La sélection est lente. Nous pourrions -utiliser la sélection basée sur le GPU comme nous l'avons vu dans l'article sur -la sélection, mais ce n'est pas non plus gratuit. La solution que vous -choisirez dépend de vos besoins.

-

Un autre problème est l'ordre d'apparition des étiquettes. Si nous modifions le code pour avoir -des étiquettes plus longues

-
const cubes = [
--  makeInstance(geometry, 0x44aa88,  0, 'Aqua'),
--  makeInstance(geometry, 0x8844aa, -2, 'Purple'),
--  makeInstance(geometry, 0xaa8844,  2, 'Gold'),
-+  makeInstance(geometry, 0x44aa88,  0, 'Boîte Couleur Aqua'),
-+  makeInstance(geometry, 0x8844aa, -2, 'Boîte Couleur Violet'),
-+  makeInstance(geometry, 0xaa8844,  2, 'Boîte Couleur Or'),
-];
-
-

et définir le CSS de manière à ce qu'elles ne s'enroulent pas (wrap)

-
#labels>div {
-+  white-space: nowrap;
-
-

Alors nous pouvons rencontrer ce problème

-
- -

Vous pouvez voir ci-dessus que la boîte violette est à l'arrière, mais son étiquette est devant la boîte aqua.

-

Nous pouvons résoudre ce problème en définissant le zIndex de chaque élément. La position projetée a une valeur z -qui va de -1 à l'avant à +1 à l'arrière. Le zIndex doit être un entier et va dans la direction -opposée, ce qui signifie que pour le zIndex, les valeurs plus grandes sont à l'avant, donc le code suivant devrait fonctionner.

-
// convertit la position normalisée en coordonnées CSS
-const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
-const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
-
-// déplace l'élément à cette position
-elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
-
-+// définit le zIndex pour le tri
-+elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
-
-

En raison de la façon dont fonctionne la valeur z projetée, nous devons choisir un grand nombre pour étaler les valeurs, -sinon beaucoup auront la même valeur. Pour s'assurer que les étiquettes ne se chevauchent pas avec d'autres parties de -la page, nous pouvons demander au navigateur de créer un nouveau contexte d'empilement -en définissant le z-index du conteneur des étiquettes

-
#labels {
-  position: absolute;  /* nous permet de nous positionner à l'intérieur du conteneur */
-+  z-index: 0;          /* crée un nouveau contexte d'empilement pour que les enfants ne soient pas triés avec le reste de la page */
-  left: 0;             /* place notre position en haut à gauche du conteneur */
-  top: 0;
-  color: white;
-  z-index: 0;
-}
-
-

et maintenant les étiquettes devraient toujours être dans le bon ordre.

-

- -

-

Tant que nous y sommes, faisons un autre exemple pour montrer un problème supplémentaire. -Dessinons un globe comme Google Maps et étiquetons les pays.

-

J'ai trouvé ces données -qui contiennent les frontières des pays. Elles sont sous licence -CC-BY-SA.

-

J'ai écrit du code -pour charger les données et générer les contours des pays ainsi que des données JSON avec les noms -des pays et leurs emplacements.

-
- -

Les données JSON sont un tableau d'entrées ressemblant à ceci

-
[
-  {
-    "name": "Algeria",
-    "min": [
-      -8.667223,
-      18.976387
-    ],
-    "max": [
-      11.986475,
-      37.091385
-    ],
-    "area": 238174,
-    "lat": 28.163,
-    "lon": 2.632,
-    "population": {
-      "2005": 32854159
-    }
-  },
-  ...
-
-

où min, max, lat, lon sont tous en degrés de latitude et de longitude.

-

Chargeons-les. Le code est basé sur les exemples de l'optimisation de nombreux -objets. Bien que nous ne dessinions pas beaucoup -d'objets, nous utiliserons les mêmes solutions pour le rendu à la demande.

-

La première chose est de créer une sphère et d'utiliser la texture des contours.

-
{
-  const loader = new THREE.TextureLoader();
-  const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
-  const geometry = new THREE.SphereGeometry(1, 64, 32);
-  const material = new THREE.MeshBasicMaterial({map: texture});
-  scene.add(new THREE.Mesh(geometry, material));
-}
-
-

Ensuite, chargeons le fichier JSON en créant d'abord un chargeur

-
async function loadJSON(url) {
-  const req = await fetch(url);
-  return req.json();
-}
-
-

puis en l'appelant

-
let countryInfos;
-async function loadCountryData() {
-  countryInfos = await loadJSON('resources/data/world/country-info.json');
-     ...
-  }
-  requestRenderIfNotRequested();
-}
-loadCountryData();
-
-

Maintenant, utilisons ces données pour générer et placer les étiquettes.

-

Dans l'article sur l'optimisation de nombreux objets, -nous avions mis en place un petit graphe de scène d'objets auxiliaires pour faciliter le -calcul des positions de latitude et de longitude sur notre globe. Consultez cet article -pour une explication de leur fonctionnement.

-
const lonFudge = Math.PI * 1.5;
-const latFudge = Math.PI;
-// ces helpers (aides) faciliteront le positionnement des boîtes
-// Nous pouvons faire pivoter le lon helper sur son axe Y pour la longitude
-const lonHelper = new THREE.Object3D();
-// Nous faisons pivoter le latHelper sur son axe X pour la latitude
-const latHelper = new THREE.Object3D();
-lonHelper.add(latHelper);
-// Le position helper déplace l'objet vers le bord de la sphère
-const positionHelper = new THREE.Object3D();
-positionHelper.position.z = 1;
-latHelper.add(positionHelper);
-
-

Nous utiliserons cela pour calculer une position pour chaque étiquette

-
const labelParentElem = document.querySelector('#labels');
-for (const countryInfo of countryInfos) {
-  const {lat, lon, name} = countryInfo;
-
-  // ajuste les aides pour pointer vers la latitude et la longitude
-  lonHelper.rotation.y = THREE.MathUtils.degToRad(lon) + lonFudge;
-  latHelper.rotation.x = THREE.MathUtils.degToRad(lat) + latFudge;
-
-  // obtient la position de la lat/lon
-  positionHelper.updateWorldMatrix(true, false);
-  const position = new THREE.Vector3();
-  positionHelper.getWorldPosition(position);
-  countryInfo.position = position;
-
-  // ajoute un élément pour chaque pays
-  const elem = document.createElement('div');
-  elem.textContent = name;
-  labelParentElem.appendChild(elem);
-  countryInfo.elem = elem;
-
-

Le code ci-dessus ressemble beaucoup au code que nous avons écrit pour créer les étiquettes de cube, -créant un élément par étiquette. Lorsque nous avons terminé, nous avons un tableau, countryInfos, -avec une entrée pour chaque pays, à laquelle nous avons ajouté une propriété elem pour -l'élément d'étiquette de ce pays et une position avec sa position sur le -globe.

-

Tout comme nous l'avons fait pour les cubes, nous devons mettre à jour la position des -étiquettes et le temps de rendu.

-
const tempV = new THREE.Vector3();
-
-function updateLabels() {
-  // quitte si nous n'avons pas encore chargé le fichier JSON
-  if (!countryInfos) {
-    return;
-  }
-
-  for (const countryInfo of countryInfos) {
-    const {position, elem} = countryInfo;
-
-    // obtient la coordonnée d'écran normalisée de cette position
-    // x et y seront dans la plage de -1 à +1, avec x = -1 étant
-    // à gauche et y = -1 étant en bas
-    tempV.copy(position);
-    tempV.project(camera);
-
-    // convertit la position normalisée en coordonnées CSS
-    const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
-    const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
-
-    // déplace l'élément à cette position
-    elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
-
-    // définit le zIndex pour le tri
-    elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
-  }
-}
-
-

Vous pouvez voir que le code ci-dessus est sensiblement similaire à l'exemple des cubes précédent. -La seule différence majeure est que nous avons précalculé les positions des étiquettes au moment de l'initialisation. -Nous pouvons le faire car le globe ne bouge jamais. Seule notre caméra bouge.

-

Enfin, nous devons appeler updateLabels dans notre boucle de rendu

-
function render() {
-  renderRequested = false;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-  controls.update();
-
-+  updateLabels();
-
-  renderer.render(scene, camera);
-}
-
-

Et voici ce que nous obtenons

-

- -

-

Il y a beaucoup trop d'étiquettes !

-

Nous avons 2 problèmes.

-
    -
  1. Les étiquettes qui font face à l'opposé de nous apparaissent.

    -
  2. -
  3. Il y a trop d'étiquettes.

    -
  4. -
-

Pour le problème n°1, nous ne pouvons pas vraiment utiliser le RayCaster comme nous l'avons fait ci-dessus car il n'y a -rien à intersecter, à part la sphère. Au lieu de cela, ce que nous pouvons faire est de vérifier si ce -pays particulier est tourné vers l'opposé de nous ou non. Cela fonctionne car les positions des étiquettes -sont autour d'une sphère. En fait, nous utilisons une sphère unitaire, une sphère avec -un rayon de 1,0. Cela signifie que les positions sont déjà des vecteurs unitaires, ce qui -rend les calculs relativement faciles.

-
const tempV = new THREE.Vector3();
-+const cameraToPoint = new THREE.Vector3();
-+const cameraPosition = new THREE.Vector3();
-+const normalMatrix = new THREE.Matrix3();
-
-function updateLabels() {
-  // quitte si nous n'avons pas encore chargé le fichier JSON
-  if (!countryInfos) {
-    return;
-  }
-
-+  const minVisibleDot = 0.2;
-+  // obtient une matrice qui représente une orientation relative de la caméra
-+  normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
-+  // obtient la position de la caméra
-+  camera.getWorldPosition(cameraPosition);
-  for (const countryInfo of countryInfos) {
-    const {position, elem} = countryInfo;
-
-+    // Oriente la position en fonction de l'orientation de la caméra.
-+    // Comme la sphère est à l'origine et que la sphère est une sphère unitaire
-+    // cela nous donne un vecteur direction relatif à la caméra pour la position.
-+    tempV.copy(position);
-+    tempV.applyMatrix3(normalMatrix);
-+
-+    // calcule la direction vers cette position depuis la caméra
-+    cameraToPoint.copy(position);
-+    cameraToPoint.applyMatrix4(camera.matrixWorldInverse).normalize();
-+
-+    // obtient le produit scalaire de la direction relative à la caméra vers cette position
-+    // sur le globe avec la direction de la caméra vers ce point.
-+    // 1 = fait face directement à la caméra
-+    // 0 = exactement tangente à la sphère vue de la caméra
-+    // < 0 = fait face à l'opposé
-+    const dot = tempV.dot(cameraToPoint);
-+
-+    // si l'orientation ne nous fait pas face, la cacher.
-+    if (dot < minVisibleDot) {
-+      elem.style.display = 'none';
-+      continue;
-+    }
-+
-+    // restaure le style d'affichage par défaut de l'élément
-+    elem.style.display = '';
-
-    // obtient la coordonnée d'écran normalisée de cette position
-    // x et y seront dans la plage de -1 à +1, avec x = -1 étant
-    // à gauche et y = -1 étant en bas
-    tempV.copy(position);
-    tempV.project(camera);
-
-    // convertit la position normalisée en coordonnées CSS
-    const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
-    const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
-
-    // déplace l'élément à cette position
-    countryInfo.elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
-
-    // définit le zIndex pour le tri
-    elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
-  }
-}
-
-

Ci-dessus, nous utilisons les positions comme direction et obtenons cette direction par rapport à la -caméra. Ensuite, nous obtenons la direction relative à la caméra depuis la caméra vers cette position sur le globe et calculons le produit scalaire. Le produit scalaire renvoie le cosinus -de l'angle entre les deux vecteurs. Cela nous donne une valeur de -1 -à +1, où -1 signifie que l'étiquette fait face à la caméra, 0 signifie que l'étiquette est exactement -sur le bord de la sphère par rapport à la caméra, et toute valeur supérieure à zéro est -derrière. Nous utilisons ensuite cette valeur pour afficher ou masquer l'élément.

-
-
-
-
-
- -

Dans le diagramme ci-dessus, nous pouvons voir le produit scalaire de la direction vers laquelle l'étiquette est -orientée et de la direction de la caméra vers cette position. Si vous faites pivoter la -direction, vous verrez que le produit scalaire est de -1,0 lorsque la direction est directement -face à la caméra, il est de 0,0 lorsqu'il est exactement tangent à la sphère par rapport -à la caméra, ou pour le dire autrement, il est de 0 lorsque les 2 vecteurs sont -perpendiculaires l'un à l'autre, à 90 degrés. Il est supérieur à zéro lorsque l'étiquette est -derrière la sphère.

-

Pour le problème n°2, trop d'étiquettes, nous avons besoin d'un moyen de décider quelles étiquettes -afficher. Une façon serait de n'afficher les étiquettes que pour les grands pays. -Les données que nous chargeons contiennent les valeurs min et max pour la superficie qu'un -pays couvre. À partir de là, nous pouvons calculer une superficie, puis utiliser cette -superficie pour décider d'afficher ou non le pays.

-

Au moment de l'initialisation, calculons la superficie

-
const labelParentElem = document.querySelector('#labels');
-for (const countryInfo of countryInfos) {
-  const {lat, lon, min, max, name} = countryInfo;
-
-  // ajuste les aides pour pointer vers la latitude et la longitude
-  lonHelper.rotation.y = THREE.MathUtils.degToRad(lon) + lonFudge;
-  latHelper.rotation.x = THREE.MathUtils.degToRad(lat) + latFudge;
-
-  // obtient la position de la lat/lon
-  positionHelper.updateWorldMatrix(true, false);
-  const position = new THREE.Vector3();
-  positionHelper.getWorldPosition(position);
-  countryInfo.position = position;
-
-+  // calcule la superficie pour chaque pays
-+  const width = max[0] - min[0];
-+  const height = max[1] - min[1];
-+  const area = width * height;
-+  countryInfo.area = area;
-
-  // ajoute un élément pour chaque pays
-  const elem = document.createElement('div');
-  elem.textContent = name;
-  labelParentElem.appendChild(elem);
-  countryInfo.elem = elem;
-}
-
-

Puis au moment du rendu, utilisons la superficie pour décider d'afficher l'étiquette -ou non

-
+const large = 20 * 20;
-const maxVisibleDot = 0.2;
-// obtient une matrice qui représente une orientation relative de la caméra
-normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
-// obtient la position de la caméra
-camera.getWorldPosition(cameraPosition);
-for (const countryInfo of countryInfos) {
--  const {position, elem} = countryInfo;
-+  const {position, elem, area} = countryInfo;
-+  // assez grand ?
-+  if (area < large) {
-+    elem.style.display = 'none';
-+    continue;
-+  }
-
-  ...
-
-

Enfin, comme je ne suis pas sûr des bonnes valeurs pour ces paramètres, ajoutons -une GUI pour que nous puissions jouer avec elles

-
import * as THREE from 'three';
-import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-+import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
-
-
+const settings = {
-+  minArea: 20,
-+  maxVisibleDot: -0.2,
-+};
-+const gui = new GUI({width: 300});
-+gui.add(settings, 'minArea', 0, 50).onChange(requestRenderIfNotRequested);
-+gui.add(settings, 'maxVisibleDot', -1, 1, 0.01).onChange(requestRenderIfNotRequested);
-
-function updateLabels() {
-  if (!countryInfos) {
-    return;
-  }
-
--  const large = 20 * 20;
--  const maxVisibleDot = -0.2;
-+  const large = settings.minArea * settings.minArea;
-  // obtient une matrice qui représente une orientation relative de la caméra
-  normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
-  // obtient la position de la caméra
-  camera.getWorldPosition(cameraPosition);
-  for (const countryInfo of countryInfos) {
-
-    ...
-
-    // si l'orientation ne nous fait pas face, la cacher.
--    if (dot > maxVisibleDot) {
-+    if (dot > settings.maxVisibleDot) {
-      elem.style.display = 'none';
-      continue;
-    }
-
-

et voici le résultat

-

- -

-

Vous pouvez voir qu'en faisant pivoter la Terre, les étiquettes qui passent derrière disparaissent. -Ajustez le minVisibleDot pour voir le changement de seuil. -Vous pouvez également ajuster la valeur de minArea pour voir apparaître des pays plus grands ou plus petits.

-

Plus j'ai travaillé là-dessus, plus j'ai réalisé l'énorme travail -investi dans Google Maps. Eux aussi doivent décider quelles étiquettes afficher. Je suis à peu près sûr qu'ils utilisent toutes sortes de critères. Par exemple, votre position actuelle, votre paramètre de langue par défaut, les paramètres de votre compte si vous en avez un, ils utilisent probablement la population ou la popularité, ils pourraient donner la priorité aux pays au centre de la vue, etc... Beaucoup de choses à considérer.

-

En tout cas, j'espère que ces exemples vous ont donné une idée de la façon d'aligner les éléments HTML -avec votre 3D. Quelques choses que je pourrais changer.

-

Prochaine étape, faisons en sorte que vous puissiez sélectionner et surligner un pays.

-

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/animation-system.html b/manual/fr/animation-system.html deleted file mode 100644 index af31ce1b9eaee2..00000000000000 --- a/manual/fr/animation-system.html +++ /dev/null @@ -1,173 +0,0 @@ - - - Système d'Animation - - - - - - - - - - - - - -
-
-

Système d'Animation

-
-
-
- -

Vue d'ensemble

- -

- Au sein du système d'animation de three.js, vous pouvez animer diverses propriétés de vos modèles : - les os d'un modèle skinné et riggé, les morph targets, différentes propriétés de matériaux - (couleurs, opacité, booléens), la visibilité et les transformations. Les propriétés animées peuvent être introduites en fondu, - dissoutes en fondu, fondues enchaînées et déformées. L'influence (weight) et l'échelle temporelle (time scales) de différentes animations simultanées - sur le même objet ainsi que sur différents objets peuvent être modifiées - indépendamment. Diverses animations sur le même objet et sur différents objets peuvent être - synchronisées.

- - Pour atteindre tout cela dans un seul système homogène, le système d'animation de three.js - [link:https://github.com/mrdoob/three.js/issues/6881 a complètement changé en 2015] - (méfiez-vous des informations obsolètes !), et il a maintenant une architecture similaire à - Unity/Unreal Engine 4. Cette page donne un bref aperçu des principaux composants du - système et de leur fonctionnement ensemble. - -

- -

Clips d'Animation

- -

- - Si vous avez importé avec succès un objet 3D animé (peu importe qu'il ait des - os, des morph targets, ou les deux) — par exemple en l'exportant depuis Blender avec l' - [link:https://github.com/KhronosGroup/glTF-Blender-IO exportateur glTF pour Blender] et - en le chargeant dans une scène three.js à l'aide de `GLTFLoader` — l'un des champs de réponse - devrait être un tableau nommé "animations", contenant les clips d'animation - pour ce modèle (voir une liste des chargeurs possibles ci-dessous).

- - Chaque `AnimationClip` contient généralement les données pour une certaine activité de l'objet. Si le - mesh est un personnage, par exemple, il peut y avoir un AnimationClip pour un cycle de marche, un second - pour un saut, un troisième pour un pas de côté, et ainsi de suite. - -

- -

Pistes d'Images Clés

- -

- - À l'intérieur d'un tel `AnimationClip`, les données pour chaque propriété animée sont stockées dans une - `KeyframeTrack` séparée. En supposant qu'un objet personnage a un squelette, - une piste d'images clés pourrait stocker les données des changements de position de l'os de l'avant-bras - au fil du temps, une piste différente les données des changements de rotation du même os, une troisième - la position, la rotation ou l'échelle d'un autre os, et ainsi de suite. Il devrait être clair - qu'un AnimationClip peut être composé de nombreuses pistes de ce type.

- - En supposant que le modèle a des morph targets (par exemple un morph - target montrant un visage amical et un autre montrant un visage en colère), chaque piste contient les - informations sur la manière dont l'influence d'un certain morph target change pendant la performance - du clip. - -

- -

Mixeur d'Animation

- -

- - Les données stockées ne forment que la base des animations - la lecture réelle est contrôlée par le - `AnimationMixer`. Vous pouvez l'imaginer non seulement comme un lecteur d'animations, mais - comme une simulation d'un matériel comme une véritable console de mixage, qui peut contrôler plusieurs animations - simultanément, en les mélangeant et en les fusionnant. - -

- -

Actions d'Animation

- -

- - Le `AnimationMixer` lui-même n'a que très peu de propriétés et de méthodes (générales), car il - peut être contrôlé par les actions d'animation. En configurant une - `AnimationAction`, vous pouvez déterminer quand un certain `AnimationClip` doit être lu, mis en pause - ou arrêté sur l'un des mixeurs, si et combien de fois le clip doit être répété, s'il - doit être exécuté avec un fondu ou une échelle temporelle, et quelques éléments supplémentaires, tels que le fondu enchaîné - ou la synchronisation. - -

- -

Groupes d'Objets d'Animation

- -

- - Si vous souhaitez qu'un groupe d'objets reçoive un état d'animation partagé, vous pouvez utiliser un - `AnimationObjectGroup`. - -

- -

Formats et Chargeurs Pris en Charge

- -

- Notez que tous les formats de modèle n'incluent pas l'animation (OBJ notamment ne le fait pas), et que seuls certains - chargeurs three.js supportent les séquences `AnimationClip`. Plusieurs qui supportent - ce type d'animation : -

- -
    -
  • THREE.ObjectLoader
  • -
  • THREE.BVHLoader
  • -
  • THREE.ColladaLoader
  • -
  • THREE.FBXLoader
  • -
  • THREE.GLTFLoader
  • -
- -

- Notez que 3ds max et Maya ne peuvent actuellement pas exporter plusieurs animations (c'est-à-dire des animations qui ne sont pas - sur la même ligne de temps) directement dans un seul fichier. -

- -

Exemple

- -
-let mesh;
-
-// Créer un AnimationMixer, et obtenir la liste des instances de AnimationClip
-const mixer = new THREE.AnimationMixer( mesh );
-const clips = mesh.animations;
-
-// Mettre à jour le mixeur à chaque image
-function update () {
-  mixer.update( deltaSeconds );
-}
-
-// Jouer une animation spécifique
-const clip = THREE.AnimationClip.findByName( clips, 'dance' );
-const action = mixer.clipAction( clip );
-action.play();
-
-// Jouer toutes les animations
-clips.forEach( function ( clip ) {
-  mixer.clipAction( clip ).play();
-} );
-
- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/backgrounds.html b/manual/fr/backgrounds.html deleted file mode 100644 index e8616c326c9e3d..00000000000000 --- a/manual/fr/backgrounds.html +++ /dev/null @@ -1,254 +0,0 @@ - - - Arrière-plans et Skyboxes - - - - - - - - - - - - - -
-
-

Arrière-plans et Skyboxes

-
-
-
-

La plupart des articles ici utilisent une couleur unie pour l'arrière-plan.

-

Ajouter un arrière-plan statique peut être aussi simple que de définir du CSS. En prenant -un exemple de l'article sur comment rendre THREE.js responsive -nous n'avons besoin de changer que 2 choses.

-

Nous devons ajouter du CSS à notre canvas pour définir son arrière-plan comme une image.

-
<style>
-body {
-    margin: 0;
-}
-#c {
-    width: 100%;
-    height: 100%;
-    display: block;
-+    background: url(resources/images/daikanyama.jpg) no-repeat center center;
-+    background-size: cover;
-}
-</style>
-
-

et nous devons dire au WebGLRenderer d'utiliser alpha pour que les endroits où nous ne dessinons rien soient transparents.

-
function main() {
-  const canvas = document.querySelector('#c');
--  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+  const renderer = new THREE.WebGLRenderer({
-+    antialias: true,
-+    canvas,
-+    alpha: true,
-+  });
-
-

Et nous obtenons un arrière-plan.

-

- -

-

Si nous voulons que l'arrière-plan puisse être affecté par des effets de post-traitement -alors nous devons dessiner l'arrière-plan en utilisant -THREE.js.

-

THREE.js rend cela quelque peu simple. Nous pouvons simplement définir l'arrière-plan de la scène sur -une texture.

-
const loader = new THREE.TextureLoader();
-const bgTexture = loader.load('resources/images/daikanyama.jpg');
-bgTexture.colorSpace = THREE.SRGBColorSpace;
-scene.background = bgTexture;
-
-

ce qui nous donne

-

- -

-

Cela nous donne une image de fond, mais elle est étirée pour s'adapter à l'écran.

-

Nous pouvons résoudre ce problème en définissant les propriétés repeat et offset de -la texture pour n'afficher qu'une partie de l'image.

-
function render(time) {
-
-   ...
-
-+  // Définir les propriétés repeat et offset de la texture de fond
-+  // pour maintenir l'aspect correct de l'image.
-+  // Note : l'image peut ne pas avoir encore été chargée.
-+  const canvasAspect = canvas.clientWidth / canvas.clientHeight;
-+  const imageAspect = bgTexture.image ? bgTexture.image.width / bgTexture.image.height : 1;
-+  const aspect = imageAspect / canvasAspect;
-+
-+  bgTexture.offset.x = aspect > 1 ? (1 - 1 / aspect) / 2 : 0;
-+  bgTexture.repeat.x = aspect > 1 ? 1 / aspect : 1;
-+
-+  bgTexture.offset.y = aspect > 1 ? 0 : (1 - aspect) / 2;
-+  bgTexture.repeat.y = aspect > 1 ? 1 : aspect;
-
-  ...
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-

et maintenant THREE.js dessine l'arrière-plan. Il n'y a pas de différence visible avec -la version CSS en haut, mais maintenant si nous utilisions un effet de post-traitement -l'arrière-plan serait également affecté.

-

- -

-

Bien sûr, un arrière-plan statique n'est généralement pas ce que l'on souhaite dans une scène 3D. Au lieu -de cela, nous voulons généralement une sorte de skybox. Une skybox est exactement cela, une boîte avec le ciel -dessiné dessus. Nous plaçons la caméra à l'intérieur de la boîte et on dirait qu'il y a un ciel en arrière-plan.

-

La manière la plus courante d'implémenter une skybox est de créer un cube, d'y appliquer une texture, -et de le dessiner depuis l'intérieur. Sur chaque côté du cube, placez une texture (en utilisant -les coordonnées de texture) qui ressemble à une image de l'horizon. Il est également fréquent -d'utiliser une sphère ou un dôme céleste avec une texture dessinée dessus. Vous pouvez -probablement le comprendre par vous-même. Il suffit de faire un cube ou une sphère, -appliquer une texture, le marquer comme THREE.BackSide pour que nous -rendions l'intérieur au lieu de l'extérieur, et soit de le mettre directement dans votre scène -comme ci-dessus, soit de créer 2 scènes : une spéciale pour dessiner la skybox/sphère/dôme et la -scène normale pour dessiner tout le reste. Vous utiliseriez votre PerspectiveCamera normale pour -dessiner. Pas besoin de la OrthographicCamera.

-

Une autre solution consiste à utiliser une Cubemap. Une Cubemap est un type de texture -spécial qui a 6 côtés, les côtés d'un cube. Au lieu d'utiliser des coordonnées de texture -standard, elle utilise une direction depuis le centre pointant vers l'extérieur pour décider -où obtenir une couleur.

-

Voici les 6 images d'une cubemap provenant du musée d'histoire de l'ordinateur à Mountain -View, Californie.

-
- - - -
-
- - - -
- -

Pour les utiliser, nous utilisons le CubeTextureLoader pour les charger, puis nous l'utilisons comme arrière-plan de la scène.

-
{
-  const loader = new THREE.CubeTextureLoader();
-  const texture = loader.load([
-    'resources/images/cubemaps/computer-history-museum/pos-x.jpg',
-    'resources/images/cubemaps/computer-history-museum/neg-x.jpg',
-    'resources/images/cubemaps/computer-history-museum/pos-y.jpg',
-    'resources/images/cubemaps/computer-history-museum/neg-y.jpg',
-    'resources/images/cubemaps/computer-history-museum/pos-z.jpg',
-    'resources/images/cubemaps/computer-history-museum/neg-z.jpg',
-  ]);
-  scene.background = texture;
-}
-
-

Au moment du rendu, nous n'avons pas besoin d'ajuster la texture comme nous l'avons fait ci-dessus

-
function render(time) {
-
-   ...
-
--  // Définir les propriétés repeat et offset de la texture de fond
--  // pour maintenir l'aspect correct de l'image.
--  // Note : l'image peut ne pas avoir encore été chargée.
--  const canvasAspect = canvas.clientWidth / canvas.clientHeight;
--  const imageAspect = bgTexture.image ? bgTexture.image.width / bgTexture.image.height : 1;
--  const aspect = imageAspect / canvasAspect;
--
--  bgTexture.offset.x = aspect > 1 ? (1 - 1 / aspect) / 2 : 0;
--  bgTexture.repeat.x = aspect > 1 ? 1 / aspect : 1;
--
--  bgTexture.offset.y = aspect > 1 ? 0 : (1 - aspect) / 2;
--  bgTexture.repeat.y = aspect > 1 ? 1 : aspect;
-
-  ...
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-

Ajoutons des contrôles pour pouvoir faire pivoter la caméra.

-
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-
-
const fov = 75;
-const aspect = 2;  // the canvas default
-const near = 0.1;
--const far = 5;
-+const far = 100;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
--camera.position.z = 2;
-+camera.position.z = 3;
-
-+const controls = new OrbitControls(camera, canvas);
-+controls.target.set(0, 0, 0);
-+controls.update();
-
-

et essayez-le. Faites glisser l'exemple pour faire pivoter la caméra et voir la cubemap nous entourer.

-

- -

-

Une autre option consiste à utiliser une carte équirectangulaire. C'est le type d'image qu'une caméra 360 prend.

-

En voici une que j'ai trouvée sur ce site.

-
- -
{
--  const loader = new THREE.CubeTextureLoader();
--  const texture = loader.load([
--    'resources/images/cubemaps/computer-history-museum/pos-x.jpg',
--    'resources/images/cubemaps/computer-history-museum/neg-x.jpg',
--    'resources/images/cubemaps/computer-history-museum/pos-y.jpg',
--    'resources/images/cubemaps/computer-history-museum/neg-y.jpg',
--    'resources/images/cubemaps/computer-history-museum/pos-z.jpg',
--    'resources/images/cubemaps/computer-history-museum/neg-z.jpg',
--  ]);
--  scene.background = texture;
-+  const loader = new THREE.TextureLoader();
-+  const texture = loader.load(
-+    'resources/images/equirectangularmaps/tears_of_steel_bridge_2k.jpg',
-+    () => {
-+      texture.mapping = THREE.EquirectangularReflectionMapping;
-+      texture.colorSpace = THREE.SRGBColorSpace;
-+      scene.background = texture;
-+    });
-}
-
-

Et c'est tout ce qu'il y a à faire.

-

- -

-

Plutôt que de le faire au moment du chargement, vous pouvez également convertir une image équirectangulaire -en cubemap au préalable. Voici un site qui le fera pour vous.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/billboards.html b/manual/fr/billboards.html deleted file mode 100644 index 783bcef0b0533b..00000000000000 --- a/manual/fr/billboards.html +++ /dev/null @@ -1,320 +0,0 @@ - - - Panneaux - - - - - - - - - - - - - -
-
-

Panneaux

-
-
-
-

Dans un article précédent nous avons utilisé une CanvasTexture -pour créer des étiquettes / badges sur les personnages. Parfois, nous aimerions créer des étiquettes ou -d'autres éléments qui font toujours face à la caméra. Three.js fournit le Sprite et le -SpriteMaterial pour y parvenir.

-

Modifions l'exemple de badge de l'article sur les textures de canevas -pour utiliser Sprite et le SpriteMaterial

-
function makePerson(x, labelWidth, size, name, color) {
-  const canvas = makeLabelCanvas(labelWidth, size, name);
-  const texture = new THREE.CanvasTexture(canvas);
-  // car notre canevas n'est probablement pas une puissance de 2
-  // dans les deux dimensions, définissez le filtrage de manière appropriée.
-  texture.minFilter = THREE.LinearFilter;
-  texture.wrapS = THREE.ClampToEdgeWrapping;
-  texture.wrapT = THREE.ClampToEdgeWrapping;
-
--  const labelMaterial = new THREE.MeshBasicMaterial({
-+  const labelMaterial = new THREE.SpriteMaterial({
-    map: texture,
--    side: THREE.DoubleSide,
-    transparent: true,
-  });
-
-  const root = new THREE.Object3D();
-  root.position.x = x;
-
-  const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
-  root.add(body);
-  body.position.y = bodyHeight / 2;
-
-  const head = new THREE.Mesh(headGeometry, bodyMaterial);
-  root.add(head);
-  head.position.y = bodyHeight + headRadius * 1.1;
-
--  const label = new THREE.Mesh(labelGeometry, labelMaterial);
-+  const label = new THREE.Sprite(labelMaterial);
-  root.add(label);
-  label.position.y = bodyHeight * 4 / 5;
-  label.position.z = bodyRadiusTop * 1.01;
-
-

et les étiquettes font maintenant toujours face à la caméra

-

- -

-

Un problème est que, sous certains angles, les étiquettes intersectent maintenant les -personnages.

-
- -

Nous pouvons déplacer la position des étiquettes pour corriger cela.

-
+// si les unités sont des mètres, alors 0.01 ici donne une taille
-+// de l'étiquette en centimètres.
-+const labelBaseScale = 0.01;
-const label = new THREE.Sprite(labelMaterial);
-root.add(label);
--label.position.y = bodyHeight * 4 / 5;
--label.position.z = bodyRadiusTop * 1.01;
-+label.position.y = head.position.y + headRadius + size * labelBaseScale;
-
--// si les unités sont des mètres, alors 0.01 ici donne une taille
--// de l'étiquette en centimètres.
--const labelBaseScale = 0.01;
-label.scale.x = canvas.width  * labelBaseScale;
-label.scale.y = canvas.height * labelBaseScale;
-
-

- -

-

Une autre chose que nous pouvons faire avec les panneaux d'affichage est de dessiner des façades.

-

Au lieu de dessiner des objets 3D, nous dessinons des plans 2D avec une image -d'objets 3D. C'est souvent plus rapide que de dessiner des objets 3D.

-

Par exemple, créons une scène avec une grille d'arbres. Nous allons créer chaque -arbre à partir d'un cylindre pour la base et d'un cône pour le sommet.

-

Nous créons d'abord la géométrie du cône et du cylindre ainsi que les matériaux que -tous les arbres partageront

-
const trunkRadius = .2;
-const trunkHeight = 1;
-const trunkRadialSegments = 12;
-const trunkGeometry = new THREE.CylinderGeometry(
-    trunkRadius, trunkRadius, trunkHeight, trunkRadialSegments);
-
-const topRadius = trunkRadius * 4;
-const topHeight = trunkHeight * 2;
-const topSegments = 12;
-const topGeometry = new THREE.ConeGeometry(
-    topRadius, topHeight, topSegments);
-
-const trunkMaterial = new THREE.MeshPhongMaterial({color: 'brown'});
-const topMaterial = new THREE.MeshPhongMaterial({color: 'green'});
-
-

Ensuite, nous allons créer une fonction qui crée un Mesh -chacun pour le tronc et le sommet d'un arbre -et les parentent à un Object3D.

-
function makeTree(x, z) {
-  const root = new THREE.Object3D();
-  const trunk = new THREE.Mesh(trunkGeometry, trunkMaterial);
-  trunk.position.y = trunkHeight / 2;
-  root.add(trunk);
-
-  const top = new THREE.Mesh(topGeometry, topMaterial);
-  top.position.y = trunkHeight + topHeight / 2;
-  root.add(top);
-
-  root.position.set(x, 0, z);
-  scene.add(root);
-
-  return root;
-}
-
-

Ensuite, nous allons créer une boucle pour placer une grille d'arbres.

-
for (let z = -50; z <= 50; z += 10) {
-  for (let x = -50; x <= 50; x += 10) {
-    makeTree(x, z);
-  }
-}
-
-

Ajoutons également un plan de sol tant que nous y sommes

-
// ajouter le sol
-{
-  const size = 400;
-  const geometry = new THREE.PlaneGeometry(size, size);
-  const material = new THREE.MeshPhongMaterial({color: 'gray'});
-  const mesh = new THREE.Mesh(geometry, material);
-  mesh.rotation.x = Math.PI * -0.5;
-  scene.add(mesh);
-}
-
-

et changeons l'arrière-plan en bleu clair

-
const scene = new THREE.Scene();
--scene.background = new THREE.Color('white');
-+scene.background = new THREE.Color('lightblue');
-
-

et nous obtenons une grille d'arbres

-

- -

-

Il y a 11x11 ou 121 arbres. Chaque arbre est constitué d'un cône de 12 polygones -et d'un tronc de 48 polygones, donc chaque arbre fait 60 polygones. 121 * 60 -= 7260 polygones. Ce n'est pas énorme, mais bien sûr, un arbre 3D plus détaillé -pourrait avoir entre 1000 et 3000 polygones. S'ils avaient 3000 polygones chacun, -alors 121 arbres représenteraient 363000 polygones à dessiner.

-

En utilisant des façades, nous pouvons réduire ce nombre.

-

Nous pourrions créer manuellement une façade dans un logiciel de dessin, mais écrivons -du code pour essayer d'en générer une.

-

Écrivons du code pour rendre un objet dans une texture -en utilisant un RenderTarget. Nous avons abordé le rendu vers une RenderTarget -dans l'article sur les cibles de rendu.

-
function frameArea(sizeToFitOnScreen, boxSize, boxCenter, camera) {
-  const halfSizeToFitOnScreen = sizeToFitOnScreen * 0.5;
-  const halfFovY = THREE.MathUtils.degToRad(camera.fov * .5);
-  const distance = halfSizeToFitOnScreen / Math.tan(halfFovY);
-
-  camera.position.copy(boxCenter);
-  camera.position.z += distance;
-
-  // choisir des valeurs proches et éloignées pour le frustum qui
-  // contiendra la boîte.
-  camera.near = boxSize / 100;
-  camera.far = boxSize * 100;
-
-  camera.updateProjectionMatrix();
-}
-
-function makeSpriteTexture(textureSize, obj) {
-  const rt = new THREE.WebGLRenderTarget(textureSize, textureSize);
-
-  const aspect = 1;  // car la cible de rendu est carrée
-  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-
-  scene.add(obj);
-
-  // calculer la boîte qui contient obj
-  const box = new THREE.Box3().setFromObject(obj);
-
-  const boxSize = box.getSize(new THREE.Vector3());
-  const boxCenter = box.getCenter(new THREE.Vector3());
-
-  // régler la caméra pour cadrer la boîte
-  const fudge = 1.1;
-  const size = Math.max(...boxSize.toArray()) * fudge;
-  frameArea(size, size, boxCenter, camera);
-
-  renderer.autoClear = false;
-  renderer.setRenderTarget(rt);
-  renderer.render(scene, camera);
-  renderer.setRenderTarget(null);
-  renderer.autoClear = true;
-
-  scene.remove(obj);
-
-  return {
-    position: boxCenter.multiplyScalar(fudge),
-    scale: size,
-    texture: rt.texture,
-  };
-}
-
-

Quelques points à noter concernant le code ci-dessus :

-

Nous utilisons le champ de vision (fov) défini au-dessus de ce code.

-

Nous calculons une boîte qui contient l'arbre de la même manière -que nous l'avons fait dans l'article sur le chargement d'un fichier .obj -avec quelques modifications mineures.

-

Nous appelons frameArea à nouveau, adapté de l'article sur le chargement d'un fichier .obj. -Dans ce cas, nous calculons à quelle distance la caméra doit se trouver de l'objet, -compte tenu de son champ de vision, pour contenir l'objet. Nous positionnons ensuite la caméra en -z à cette distance -du centre de la boîte qui contient l'objet.

-

Nous multiplions la taille que nous voulons ajuster par 1,1 (fudge) pour nous assurer que l'arbre rentre -complètement dans la cible de rendu. Le problème ici est que la taille que nous utilisons pour -calculer si l'objet rentre dans la vue de la caméra ne prend pas en compte -que les bords mêmes de l'objet finiront par sortir de la zone que nous avons calculée. -Nous pourrions calculer comment faire rentrer 100% de la boîte, mais cela gaspillerait aussi de l'espace, -alors au lieu de cela, nous 'truquons' un peu.

-

Ensuite, nous rendons sur la cible de rendu et retirons l'objet de -la scène.

-

Il est important de noter que nous avons besoin des lumières dans la scène, mais nous -devons nous assurer que rien d'autre n'est dans la scène.

-

Nous devons également ne pas définir de couleur d'arrière-plan sur la scène

-
const scene = new THREE.Scene();
--scene.background = new THREE.Color('lightblue');
-
-

Enfin, nous avons créé la texture, nous la renvoyons ainsi que la position et l'échelle -dont nous avons besoin pour créer la façade afin qu'elle apparaisse au même endroit.

-

Nous créons ensuite un arbre et appelons ce code en le lui passant

-
// créer la texture du panneau d'affichage
-const tree = makeTree(0, 0);
-const facadeSize = 64;
-const treeSpriteInfo = makeSpriteTexture(facadeSize, tree);
-
-

Nous pouvons ensuite créer une grille de façades au lieu d'une grille de modèles d'arbres

-
+function makeSprite(spriteInfo, x, z) {
-+  const {texture, offset, scale} = spriteInfo;
-+  const mat = new THREE.SpriteMaterial({
-+    map: texture,
-+    transparent: true,
-+  });
-+  const sprite = new THREE.Sprite(mat);
-+  scene.add(sprite);
-+  sprite.position.set(
-+      offset.x + x,
-+      offset.y,
-+      offset.z + z);
-+  sprite.scale.set(scale, scale, scale);
-+}
-
-for (let z = -50; z <= 50; z += 10) {
-  for (let x = -50; x <= 50; x += 10) {
--    makeTree(x, z);
-+    makeSprite(treeSpriteInfo, x, z);
-  }
-}
-
-

Dans le code ci-dessus, nous appliquons le décalage et l'échelle nécessaires pour positionner la façade afin qu'elle -apparaisse au même endroit où l'arbre d'origine aurait apparu.

-

Maintenant que nous avons terminé de créer la texture de la façade de l'arbre, nous pouvons à nouveau définir l'arrière-plan

-
scene.background = new THREE.Color('lightblue');
-
-

et maintenant nous obtenons une scène de façades d'arbres

-

- -

-

Comparez avec les modèles d'arbres ci-dessus et vous pouvez voir que l'apparence est assez similaire. -Nous avons utilisé une texture basse résolution, seulement 64x64 pixels, donc les façades sont pixelisées. -Vous pourriez augmenter la résolution. Souvent, les façades ne sont utilisées qu'à grande distance lorsqu'elles sont assez petites, -donc une texture basse résolution est suffisante et cela permet d'éviter de dessiner des arbres détaillés qui ne font -que quelques pixels de taille lorsqu'ils sont loin.

-

Un autre problème est que nous ne voyons l'arbre que d'un côté. -Cela est souvent résolu en rendant plus de façades, par exemple depuis 8 directions autour de l'objet, -puis en définissant quelle façade afficher en fonction de la direction depuis laquelle la caméra regarde la façade.

-

Que vous utilisiez des façades ou non, cela dépend de vous, mais j'espère que cet article -vous a donné des idées et suggéré des solutions si vous décidez de les utiliser.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/cameras.html b/manual/fr/cameras.html deleted file mode 100644 index 96862a03cf8dc6..00000000000000 --- a/manual/fr/cameras.html +++ /dev/null @@ -1,567 +0,0 @@ - - - Caméras - - - - - - - - - - - - - -
-
-

Caméras

-
-
-
-

Cet article fait partie d'une série d'articles sur three.js. -Le premier article traitait des bases. -Si vous ne l'avez pas encore lu, vous pourriez vouloir commencer par là.

-

Parlons des caméras dans three.js. Nous avons abordé une partie de cela dans le premier article, mais nous allons le couvrir plus en détail ici.

-

La caméra la plus courante dans three.js, et celle que nous avons utilisée jusqu'à présent, est -la PerspectiveCamera. Elle donne une vue 3D où les objets au loin apparaissent -plus petits que les objets proches.

-

La PerspectiveCamera définit un frustum. Un frustum est une forme pyramidale solide dont l'extrémité est coupée. -Par nom de solide, j'entends par exemple qu'un cube, un cône, une sphère, un cylindre -et un frustum sont tous des noms de différents types de solides.

-
-
cube
-
cône
-
sphère
-
cylindre
-
frustum
-
- -

Je ne le signale que parce que je ne le savais pas pendant des années. Un livre ou une page mentionnait -frustum et mes yeux se voilaient. Comprendre que c'est le nom d'un type de forme solide -a rendu ces descriptions soudainement plus logiques 😅

-

Une PerspectiveCamera définit son frustum en fonction de 4 propriétés. near définit où -commence l'avant du frustum. far définit où il se termine. fov, le champ de vision, définit -la hauteur de l'avant et de l'arrière du frustum en calculant la hauteur correcte pour obtenir -le champ de vision spécifié à near unités de la caméra. L'aspect définit -la largeur de l'avant et de l'arrière du frustum. La largeur du frustum est simplement la hauteur -multipliée par l'aspect.

-

-

Utilisons la scène de l'article précédent qui contient un plan au sol, -une sphère et un cube, et faisons en sorte de pouvoir ajuster les paramètres de la caméra.

-

Pour ce faire, nous allons créer un MinMaxGUIHelper pour les paramètres near et far afin que far -soit toujours supérieur à near. Il aura des propriétés min et max que lil-gui -ajustera. Lorsqu'elles seront ajustées, elles définiront les 2 propriétés que nous spécifions.

-
class MinMaxGUIHelper {
-  constructor(obj, minProp, maxProp, minDif) {
-    this.obj = obj;
-    this.minProp = minProp;
-    this.maxProp = maxProp;
-    this.minDif = minDif;
-  }
-  get min() {
-    return this.obj[this.minProp];
-  }
-  set min(v) {
-    this.obj[this.minProp] = v;
-    this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
-  }
-  get max() {
-    return this.obj[this.maxProp];
-  }
-  set max(v) {
-    this.obj[this.maxProp] = v;
-    this.min = this.min;  // ceci appellera le setter de min
-  }
-}
-
-

Maintenant, nous pouvons configurer notre interface graphique comme ceci

-
function updateCamera() {
-  camera.updateProjectionMatrix();
-}
-
-const gui = new GUI();
-gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
-const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
-gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
-gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
-
-

Chaque fois que les paramètres de la caméra changent, nous devons appeler la fonction -updateProjectionMatrix de la caméra. -Nous avons donc créé une fonction nommée updateCamera et l'avons passée à lil-gui pour qu'elle soit appelée lorsque les choses changent.

-

- -

-

Vous pouvez ajuster les valeurs et voir comment elles fonctionnent. Notez que nous n'avons pas rendu l'aspect modifiable car -il est tiré de la taille de la fenêtre. Donc, si vous voulez ajuster l'aspect, ouvrez l'exemple -dans une nouvelle fenêtre et redimensionnez-la.

-

Néanmoins, je pense que c'est un peu difficile à voir, alors changeons l'exemple pour qu'il comporte 2 caméras. -L'une montrera notre scène telle que nous la voyons ci-dessus, l'autre montrera une autre caméra regardant la -scène que la première caméra dessine et affichant le frustum de cette caméra.

-

Pour ce faire, nous pouvons utiliser la fonction scissor de three.js. -Changeons-le pour dessiner 2 scènes avec 2 caméras côte à côte en utilisant la fonction scissor.

-

Tout d'abord, utilisons du HTML et du CSS pour définir 2 éléments côte à côte. Cela nous -aidera également avec les événements afin que les deux caméras puissent facilement avoir leurs propres OrbitControls.

-
<body>
-  <canvas id="c"></canvas>
-+  <div class="split">
-+     <div id="view1" tabindex="1"></div>
-+     <div id="view2" tabindex="2"></div>
-+  </div>
-</body>
-
-

Et le CSS qui fera apparaître ces 2 vues côte à côte superposées sur -le canvas

-
.split {
-  position: absolute;
-  left: 0;
-  top: 0;
-  width: 100%;
-  height: 100%;
-  display: flex;
-}
-.split>div {
-  width: 100%;
-  height: 100%;
-}
-
-

Puis dans notre code, nous ajouterons un CameraHelper. Un CameraHelper dessine le frustum d'une Camera

-
const cameraHelper = new THREE.CameraHelper(camera);
-
-...
-
-scene.add(cameraHelper);
-
-

Maintenant, cherchons les 2 éléments de vue.

-
const view1Elem = document.querySelector('#view1');
-const view2Elem = document.querySelector('#view2');
-
-

Et nous configurerons nos OrbitControls existants pour qu'ils ne répondent qu'au premier -élément de vue.

-
-const controls = new OrbitControls(camera, canvas);
-+const controls = new OrbitControls(camera, view1Elem);
-
-

Créons une deuxième PerspectiveCamera et un deuxième OrbitControls. -Le deuxième OrbitControls est lié à la deuxième caméra et reçoit l'entrée -du deuxième élément de vue.

-
const camera2 = new THREE.PerspectiveCamera(
-  60,  // fov
-  2,   // aspect
-  0.1, // near
-  500, // far
-);
-camera2.position.set(40, 10, 30);
-camera2.lookAt(0, 5, 0);
-
-const controls2 = new OrbitControls(camera2, view2Elem);
-controls2.target.set(0, 5, 0);
-controls2.update();
-
-

Enfin, nous devons rendre la scène du point de vue de chaque -caméra en utilisant la fonction scissor pour ne rendre qu'une partie du canvas.

-

Voici une fonction qui, étant donné un élément, calculera le rectangle -de cet élément qui chevauche le canvas. Elle définira ensuite le scissor -et le viewport sur ce rectangle et renverra l'aspect pour cette taille.

-
function setScissorForElement(elem) {
-  const canvasRect = canvas.getBoundingClientRect();
-  const elemRect = elem.getBoundingClientRect();
-
-  // calculer un rectangle relatif au canvas
-  const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
-  const left = Math.max(0, elemRect.left - canvasRect.left);
-  const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
-  const top = Math.max(0, elemRect.top - canvasRect.top);
-
-  const width = Math.min(canvasRect.width, right - left);
-  const height = Math.min(canvasRect.height, bottom - top);
-
-  // configurer le scissor pour ne rendre que cette partie du canvas
-  const positiveYUpBottom = canvasRect.height - bottom;
-  renderer.setScissor(left, positiveYUpBottom, width, height);
-  renderer.setViewport(left, positiveYUpBottom, width, height);
-
-  // retourner l'aspect
-  return width / height;
-}
-
-

Et maintenant, nous pouvons utiliser cette fonction pour dessiner la scène deux fois dans notre fonction render.

-
  function render() {
-
--    if (resizeRendererToDisplaySize(renderer)) {
--      const canvas = renderer.domElement;
--      camera.aspect = canvas.clientWidth / canvas.clientHeight;
--      camera.updateProjectionMatrix();
--    }
-
-+    resizeRendererToDisplaySize(renderer);
-+
-+    // activer le scissor
-+    renderer.setScissorTest(true);
-+
-+    // rendre la vue originale
-+    {
-+      const aspect = setScissorForElement(view1Elem);
-+
-+      // ajuster la caméra pour cet aspect
-+      camera.aspect = aspect;
-+      camera.updateProjectionMatrix();
-+      cameraHelper.update();
-+
-+      // ne pas dessiner l'helper de caméra dans la vue originale
-+      cameraHelper.visible = false;
-+
-+      scene.background.set(0x000000);
-+
-+      // rendre
-+      renderer.render(scene, camera);
-+    }
-+
-+    // rendre depuis la 2ème caméra
-+    {
-+      const aspect = setScissorForElement(view2Elem);
-+
-+      // ajuster la caméra pour cet aspect
-+      camera2.aspect = aspect;
-+      camera2.updateProjectionMatrix();
-+
-+      // dessiner l'helper de caméra dans la 2ème vue
-+      cameraHelper.visible = true;
-+
-+      scene.background.set(0x000040);
-+
-+      renderer.render(scene, camera2);
-+    }
-
--    renderer.render(scene, camera);
-
-    requestAnimationFrame(render);
-  }
-
-  requestAnimationFrame(render);
-}
-
-

Le code ci-dessus définit la couleur de fond de la scène lors du rendu de la -deuxième vue sur bleu foncé juste pour faciliter la distinction entre les deux vues.

-

Nous pouvons également supprimer notre code updateCamera puisque nous mettons tout à jour -dans la fonction render.

-
-function updateCamera() {
--  camera.updateProjectionMatrix();
--}
-
-const gui = new GUI();
--gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
-+gui.add(camera, 'fov', 1, 180);
-const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
--gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
--gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
-+gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near');
-+gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far');
-
-

Et maintenant, vous pouvez utiliser une vue pour voir le frustum de l'autre.

-

- -

-

À gauche, vous pouvez voir la vue originale et à droite, vous pouvez -voir une vue montrant le frustum de la caméra de gauche. En ajustant -near, far, fov et en déplaçant la caméra avec la souris, vous pouvez voir que -seul ce qui se trouve à l'intérieur du frustum affiché à droite apparaît dans la scène de -gauche.

-

Ajustez near jusqu'à environ 20 et vous verrez facilement l'avant des objets -disparaître car ils ne sont plus dans le frustum. Ajustez far en dessous d'environ 35 -et vous commencerez à voir le plan au sol disparaître car il n'est plus dans -le frustum.

-

Cela soulève la question : pourquoi ne pas simplement régler near sur 0.0000000001 et far -sur 10000000000000 ou quelque chose de similaire afin de tout voir ? -La raison est que votre GPU n'a qu'une précision limitée pour décider si quelque chose -est devant ou derrière autre chose. Cette précision est répartie entre -near et far. Pire encore, par défaut, la précision près de la caméra est détaillée -et la précision loin de la caméra est grossière. Les unités commencent à near -et s'étendent lentement à mesure qu'elles s'approchent de far.

-

En partant de l'exemple du haut, changeons le code pour insérer 20 sphères d'affilée.

-
{
-  const sphereRadius = 3;
-  const sphereWidthDivisions = 32;
-  const sphereHeightDivisions = 16;
-  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
-  const numSpheres = 20;
-  for (let i = 0; i < numSpheres; ++i) {
-    const sphereMat = new THREE.MeshPhongMaterial();
-    sphereMat.color.setHSL(i * .73, 1, 0.5);
-    const mesh = new THREE.Mesh(sphereGeo, sphereMat);
-    mesh.position.set(-sphereRadius - 1, sphereRadius + 2, i * sphereRadius * -2.2);
-    scene.add(mesh);
-  }
-}
-
-

et réglons near sur 0.00001

-
const fov = 45;
-const aspect = 2;  // the canvas default
--const near = 0.1;
-+const near = 0.00001;
-const far = 100;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-
-

Nous devons également ajuster un peu le code de l'interface graphique pour permettre 0.00001 si la valeur est éditée.

-
-gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
-+gui.add(minMaxGUIHelper, 'min', 0.00001, 50, 0.00001).name('near').onChange(updateCamera);
-
-

Que pensez-vous qu'il va se passer ?

-

- -

-

C'est un exemple de z-fighting (chevauchement en Z) où le GPU de votre ordinateur n'a pas -assez de précision pour décider quels pixels sont devant et quels pixels sont derrière.

-

Juste au cas où le problème n'apparaîtrait pas sur votre machine, voici ce que je vois sur la mienne

-
- -

Une solution consiste à indiquer à three.js d'utiliser une méthode différente pour calculer quels -pixels sont devant et quels pixels sont derrière. Nous pouvons le faire en activant -logarithmicDepthBuffer lorsque nous créons le WebGLRenderer

-
-const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+const renderer = new THREE.WebGLRenderer({
-+  antialias: true,
-+  canvas,
-+  logarithmicDepthBuffer: true,
-+});
-
-

et avec cela, cela pourrait fonctionner

-

- -

-

Si cela n'a pas résolu le problème pour vous, alors vous avez rencontré l'une des raisons pour lesquelles -vous ne pouvez pas toujours utiliser cette solution. Cette raison est que seuls certains GPU -la supportent. En septembre 2018, presque aucun appareil mobile ne supportait cette -solution, alors que la plupart des ordinateurs de bureau le faisaient.

-

Une autre raison de ne pas choisir cette solution est qu'elle peut être significativement plus lente -que la solution standard.

-

Même avec cette solution, la résolution reste limitée. Rendez near encore -plus petit ou far encore plus grand et vous rencontrerez finalement les mêmes problèmes.

-

Ce que cela signifie, c'est que vous devriez toujours faire un effort pour choisir un paramètre near -et far qui convient à votre cas d'utilisation. Réglez near aussi loin de la caméra -que possible sans que les objets ne disparaissent. Réglez far aussi près de la caméra -que possible sans que les objets ne disparaissent. Si vous essayez de dessiner une scène géante -et de montrer un gros plan du visage de quelqu'un afin que vous puissiez voir ses cils -tout en voyant à l'arrière-plan des montagnes à 50 kilomètres -de distance, eh bien, vous devrez alors trouver d'autres solutions créatives que -nous aborderons peut-être plus tard. Pour l'instant, sachez simplement que vous devez faire attention -à choisir des valeurs near et far appropriées à vos besoins.

-

La deuxième caméra la plus courante est l'OrthographicCamera. Au lieu -de spécifier un frustum, elle spécifie une boîte avec les paramètres left, right -top, bottom, near et far. Comme elle projette une boîte, -il n'y a pas de perspective.

-

Changeons l'exemple à 2 vues ci-dessus pour utiliser une OrthographicCamera -dans la première vue.

-

Tout d'abord, configurons une OrthographicCamera.

-
const left = -1;
-const right = 1;
-const top = 1;
-const bottom = -1;
-const near = 5;
-const far = 50;
-const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
-camera.zoom = 0.2;
-
-

Nous réglons left et bottom à -1 et right et top à 1. Cela créerait -une boîte de 2 unités de large et 2 unités de haut, mais nous allons ajuster left et top -en fonction de l'aspect du rectangle dans lequel nous dessinons. Nous utiliserons la propriété -zoom pour faciliter l'ajustement du nombre d'unités réellement affichées par la caméra.

-

Ajoutons un paramètre GUI pour zoom.

-
const gui = new GUI();
-+gui.add(camera, 'zoom', 0.01, 1, 0.01).listen();
-
-

L'appel à listen indique à lil-gui de surveiller les changements. Ceci est ici car -les OrbitControls peuvent également contrôler le zoom. Par exemple, la molette de la souris effectuera -un zoom via les OrbitControls.

-

Enfin, il nous suffit de modifier la partie qui rend le côté -gauche pour mettre à jour l'OrthographicCamera.

-
{
-  const aspect = setScissorForElement(view1Elem);
-
-  // mettre à jour la caméra pour cet aspect
--  camera.aspect = aspect;
-+  camera.left   = -aspect;
-+  camera.right  =  aspect;
-  camera.updateProjectionMatrix();
-  cameraHelper.update();
-
-  // ne pas dessiner l'helper de caméra dans la vue originale
-  cameraHelper.visible = false;
-
-  scene.background.set(0x000000);
-  renderer.render(scene, camera);
-}
-
-

et maintenant vous pouvez voir une OrthographicCamera en action.

-

- -

-

Une autre utilisation courante pour une OrthographicCamera est de dessiner les -vues du dessus, du dessous, de gauche, de droite, de face, d'arrière d'un programme de modélisation -3D ou de l'éditeur d'un moteur de jeu.

-
- -

Dans la capture d'écran ci-dessus, vous pouvez voir qu'une vue est une vue en perspective et 3 vues sont -des vues orthographiques.

-

Une OrthographicCamera est le plus souvent utilisée si vous utilisez three.js -pour dessiner des éléments 2D. Vous décidez combien d'unités vous voulez que la caméra -affiche. Par exemple, si vous voulez qu'un pixel du canvas corresponde -à une unité dans la caméra, vous pourriez faire quelque chose comme

-

Pour placer l'origine au centre et avoir 1 pixel = 1 unité three.js, quelque chose comme

-
camera.left = -canvas.width / 2;
-camera.right = canvas.width / 2;
-camera.top = canvas.height / 2;
-camera.bottom = -canvas.height / 2;
-camera.near = -1;
-camera.far = 1;
-camera.zoom = 1;
-
-

Ou si nous voulions que l'origine soit en haut à gauche, comme sur un -canvas 2D, nous pourrions utiliser ceci

-
camera.left = 0;
-camera.right = canvas.width;
-camera.top = 0;
-camera.bottom = canvas.height;
-camera.near = -1;
-camera.far = 1;
-camera.zoom = 1;
-
-

Dans ce cas, le coin supérieur gauche serait 0,0, comme sur un canvas 2D.

-

Essayons ! Tout d'abord, configurons la caméra.

-
const left = 0;
-const right = 300;  // taille par défaut du canvas
-const top = 0;
-const bottom = 150;  // taille par défaut du canvas
-const near = -1;
-const far = 1;
-const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
-camera.zoom = 1;
-
-

Puis chargeons 6 textures et créons 6 plans, un pour chaque texture. -Nous associerons chaque plan à un THREE.Object3D pour faciliter le décalage -du plan afin que son centre apparaisse à son coin supérieur gauche.

-

Si vous l'exécutez localement, vous devrez également avoir effectué la configuration. -Vous pourriez également vouloir lire l'article sur l'utilisation des textures.

-
const loader = new THREE.TextureLoader();
-const textures = [
-  loader.load('resources/images/flower-1.jpg'),
-  loader.load('resources/images/flower-2.jpg'),
-  loader.load('resources/images/flower-3.jpg'),
-  loader.load('resources/images/flower-4.jpg'),
-  loader.load('resources/images/flower-5.jpg'),
-  loader.load('resources/images/flower-6.jpg'),
-];
-const planeSize = 256;
-const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
-const planes = textures.map((texture) => {
-  const planePivot = new THREE.Object3D();
-  scene.add(planePivot);
-  texture.magFilter = THREE.NearestFilter;
-  const planeMat = new THREE.MeshBasicMaterial({
-    map: texture,
-    side: THREE.DoubleSide,
-  });
-  const mesh = new THREE.Mesh(planeGeo, planeMat);
-  planePivot.add(mesh);
-  // déplacer le plan pour que le coin supérieur gauche soit l'origine
-  mesh.position.set(planeSize / 2, planeSize / 2, 0);
-  return planePivot;
-});
-
-

et nous devons mettre à jour la caméra si la taille du canvas -change.

-
function render() {
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    camera.right = canvas.width;
-    camera.bottom = canvas.height;
-    camera.updateProjectionMatrix();
-  }
-
-  ...
-
-

planes est un tableau de THREE.Mesh, un pour chaque plan. -Déplaçons-les en fonction du temps.

-
function render(time) {
-  time *= 0.001;  // convertir en secondes ;
-
-  ...
-
-  const distAcross = Math.max(20, canvas.width - planeSize);
-  const distDown = Math.max(20, canvas.height - planeSize);
-
-  // distance totale pour se déplacer en travers et en arrière
-  const xRange = distAcross * 2;
-  const yRange = distDown * 2;
-  const speed = 180;
-
-  planes.forEach((plane, ndx) => {
-    // calculer un temps unique pour chaque plan
-    const t = time * speed + ndx * 300;
-
-    // obtenir une valeur entre 0 et la plage
-    const xt = t % xRange;
-    const yt = t % yRange;
-
-    // définir notre position en avant si 0 à la moitié de la plage
-    // et en arrière si la moitié de la plage à la plage
-    const x = xt < distAcross ? xt : xRange - xt;
-    const y = yt < distDown   ? yt : yRange - yt;
-
-    plane.position.set(x, y, 0);
-  });
-
-  renderer.render(scene, camera);
-
-

Et vous pouvez voir les images rebondir parfaitement au pixel près sur les bords du -canvas en utilisant des calculs de pixels, tout comme un canvas 2D.

-

- -

-

Une autre utilisation courante pour une OrthographicCamera est de dessiner les -vues du dessus, du dessous, de gauche, de droite, de face, d'arrière d'un programme de modélisation -3D ou de l'éditeur d'un moteur de jeu.

-
- -

Dans la capture d'écran ci-dessus, vous pouvez voir qu'une vue est une vue en perspective et 3 vues sont -des vues orthographiques.

-

Voilà les bases des caméras. Nous aborderons quelques méthodes courantes pour déplacer les caméras -dans d'autres articles. Pour l'instant, passons aux ombres.

-

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/canvas-textures.html b/manual/fr/canvas-textures.html deleted file mode 100644 index f39829bcc97b9a..00000000000000 --- a/manual/fr/canvas-textures.html +++ /dev/null @@ -1,403 +0,0 @@ - - - Textures sur Canvas - - - - - - - - - - - - - -
-
-

Textures sur Canvas

-
-
-
-

Cet article fait suite à l'article sur les textures. -Si vous ne l'avez pas encore lu, vous devriez probablement commencer par là.

-

Dans l'article précédent sur les textures, nous avons principalement utilisé -des fichiers image pour les textures. Cependant, il arrive parfois que nous souhaitions générer une texture -à l'exécution. Une façon de procéder est d'utiliser une CanvasTexture.

-

Une texture sur canvas prend un <canvas> en entrée. Si vous ne savez pas comment -dessiner avec l'API canvas 2D sur un canvas, il existe un bon tutoriel sur MDN.

-

Créons un simple programme canvas. En voici un qui dessine des points à des endroits aléatoires et dans des couleurs aléatoires.

-
const ctx = document.createElement('canvas').getContext('2d');
-document.body.appendChild(ctx.canvas);
-ctx.canvas.width = 256;
-ctx.canvas.height = 256;
-ctx.fillStyle = '#FFF';
-ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
-
-function randInt(min, max) {
-  if (max === undefined) {
-    max = min;
-    min = 0;
-  }
-  return Math.random() * (max - min) + min | 0;
-}
-
-function drawRandomDot() {
-  ctx.fillStyle = `#${randInt(0x1000000).toString(16).padStart(6, '0')}`;
-  ctx.beginPath();
-
-  const x = randInt(256);
-  const y = randInt(256);
-  const radius = randInt(10, 64);
-  ctx.arc(x, y, radius, 0, Math.PI * 2);
-  ctx.fill();
-}
-
-function render() {
-  drawRandomDot();
-  requestAnimationFrame(render);
-}
-requestAnimationFrame(render);
-
-

C'est assez simple.

-

- -

-

Utilisons-le maintenant pour texturer quelque chose. Nous allons commencer avec l'exemple de texturation -d'un cube tiré de l'article précédent. -Nous allons supprimer le code qui charge une image et utiliser à la place -notre canvas en créant une CanvasTexture et en lui passant le canvas que nous avons créé.

-
const cubes = [];  // juste un tableau que nous pouvons utiliser pour faire tourner les cubes
--const loader = new THREE.TextureLoader();
--
-+const ctx = document.createElement('canvas').getContext('2d');
-+ctx.canvas.width = 256;
-+ctx.canvas.height = 256;
-+ctx.fillStyle = '#FFF';
-+ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
-+const texture = new THREE.CanvasTexture(ctx.canvas);
-
-const material = new THREE.MeshBasicMaterial({
--  map: loader.load('resources/images/wall.jpg'),
-+  map: texture,
-});
-const cube = new THREE.Mesh(geometry, material);
-scene.add(cube);
-cubes.push(cube);  // add to our list of cubes to rotate
-
-

Puis appelez le code pour dessiner un point aléatoire dans notre boucle de rendu.

-
function render(time) {
-  time *= 0.001;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-+  drawRandomDot();
-+  texture.needsUpdate = true;
-
-  cubes.forEach((cube, ndx) => {
-    const speed = .2 + ndx * .1;
-    const rot = time * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-

La seule chose supplémentaire que nous devons faire est de définir la propriété needsUpdate -de la CanvasTexture pour indiquer à three.js de mettre à jour la texture avec -le contenu le plus récent du canvas.

-

Et avec cela, nous avons un cube texturé sur canvas.

-

- -

-

Notez que si vous souhaitez utiliser three.js pour dessiner dans le canvas, vous êtes -il est préférable d'utiliser un RenderTarget, ce qui est abordé dans cet article.

-

Un cas d'utilisation courant pour les textures sur canvas est d'afficher du texte dans une scène. -Par exemple, si vous vouliez afficher le nom d'une personne sur le badge de son personnage, -vous pourriez utiliser une texture sur canvas pour texturer le badge.

-

Créons une scène avec 3 personnes et donnons à chaque personne un badge -ou une étiquette.

-

Reprenons l'exemple ci-dessus et supprimons tout ce qui est lié au cube. Ensuite, mettons le fond en blanc et ajoutons deux lumières.

-
const scene = new THREE.Scene();
-+scene.background = new THREE.Color('white');
-+
-+function addLight(position) {
-+  const color = 0xFFFFFF;
-+  const intensity = 1;
-+  const light = new THREE.DirectionalLight(color, intensity);
-+  light.position.set(...position);
-+  scene.add(light);
-+  scene.add(light.target);
-+}
-+addLight([-3, 1, 1]);
-+addLight([ 2, 1, .5]);
-
-

Écrivons du code pour créer une étiquette en utilisant le canvas 2D.

-
+function makeLabelCanvas(size, name) {
-+  const borderSize = 2;
-+  const ctx = document.createElement('canvas').getContext('2d');
-+  const font =  `${size}px bold sans-serif`;
-+  ctx.font = font;
-+  // mesurer la longueur du nom
-+  const doubleBorderSize = borderSize * 2;
-+  const width = ctx.measureText(name).width + doubleBorderSize;
-+  const height = size + doubleBorderSize;
-+  ctx.canvas.width = width;
-+  ctx.canvas.height = height;
-+
-+  // besoin de redéfinir la police après avoir redimensionné le canvas
-+  ctx.font = font;
-+  ctx.textBaseline = 'top';
-+
-+  ctx.fillStyle = 'blue';
-+  ctx.fillRect(0, 0, width, height);
-+  ctx.fillStyle = 'white';
-+  ctx.fillText(name, borderSize, borderSize);
-+
-+  return ctx.canvas;
-+}
-
-

Ensuite, nous allons créer des personnages simples à partir d'un cylindre pour le corps, d'une sphère -pour la tête et d'un plan pour l'étiquette.

-

Commençons par créer la géométrie partagée.

-
+const bodyRadiusTop = .4;
-+const bodyRadiusBottom = .2;
-+const bodyHeight = 2;
-+const bodyRadialSegments = 6;
-+const bodyGeometry = new THREE.CylinderGeometry(
-+    bodyRadiusTop, bodyRadiusBottom, bodyHeight, bodyRadialSegments);
-+
-+const headRadius = bodyRadiusTop * 0.8;
-+const headLonSegments = 12;
-+const headLatSegments = 5;
-+const headGeometry = new THREE.SphereGeometry(
-+    headRadius, headLonSegments, headLatSegments);
-+
-+const labelGeometry = new THREE.PlaneGeometry(1, 1);
-
-

Ensuite, créons une fonction pour construire une personne à partir de ces -éléments.

-
+function makePerson(x, size, name, color) {
-+  const canvas = makeLabelCanvas(size, name);
-+  const texture = new THREE.CanvasTexture(canvas);
-+  // parce que notre canvas n'est probablement pas une puissance de 2
-+  // dans les deux dimensions, définissez le filtrage de manière appropriée.
-+  texture.minFilter = THREE.LinearFilter;
-+  texture.wrapS = THREE.ClampToEdgeWrapping;
-+  texture.wrapT = THREE.ClampToEdgeWrapping;
-+
-+  const labelMaterial = new THREE.MeshBasicMaterial({
-+    map: texture,
-+    side: THREE.DoubleSide,
-+    transparent: true,
-+  });
-+  const bodyMaterial = new THREE.MeshPhongMaterial({
-+    color,
-+    flatShading: true,
-+  });
-+
-+  const root = new THREE.Object3D();
-+  root.position.x = x;
-+
-+  const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
-+  root.add(body);
-+  body.position.y = bodyHeight / 2;
-+
-+  const head = new THREE.Mesh(headGeometry, bodyMaterial);
-+  root.add(head);
-+  head.position.y = bodyHeight + headRadius * 1.1;
-+
-+  const label = new THREE.Mesh(labelGeometry, labelMaterial);
-+  root.add(label);
-+  label.position.y = bodyHeight * 4 / 5;
-+  label.position.z = bodyRadiusTop * 1.01;
-+
-+  // si les unités sont des mètres, alors 0.01 ici ajuste la taille
-+  // de l'étiquette en centimètres.
-+  const labelBaseScale = 0.01;
-+  label.scale.x = canvas.width  * labelBaseScale;
-+  label.scale.y = canvas.height * labelBaseScale;
-+
-+  scene.add(root);
-+  return root;
-+}
-
-

Vous pouvez voir ci-dessus que nous plaçons le corps, la tête et l'étiquette sur un Object3D racine et ajustons leurs positions. Cela nous permettrait de déplacer l'objet racine si nous voulions déplacer les personnages. Le corps mesure 2 unités de haut. Si 1 unité équivaut à 1 mètre, alors le code ci-dessus tente de créer l'étiquette en centimètres, de sorte qu'elle mesurera 'size' centimètres de haut et la largeur nécessaire pour contenir le texte.

-

Nous pouvons ensuite créer des personnages avec des étiquettes.

-
+makePerson(-3, 32, 'Purple People Eater', 'purple');
-+makePerson(-0, 32, 'Green Machine', 'green');
-+makePerson(+3, 32, 'Red Menace', 'red');
-
-

Il ne reste plus qu'à ajouter des OrbitControls -afin de pouvoir déplacer la caméra.

-
import * as THREE from 'three';
-+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-
-
const fov = 75;
-const aspect = 2;  // the canvas default
-const near = 0.1;
--const far = 5;
-+const far = 50;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
--camera.position.z = 2;
-+camera.position.set(0, 2, 5);
-
-+const controls = new OrbitControls(camera, canvas);
-+controls.target.set(0, 2, 0);
-+controls.update();
-
-

et nous obtenons des étiquettes simples.

-

- -

-

Quelques points à noter.

-
    -
  • Si vous zoomez, les étiquettes deviennent de faible résolution.
  • -
-

Il n'y a pas de solution simple. Il existe des techniques de rendu de police plus complexes, mais je ne connais pas de solutions sous forme de plugin. De plus, elles nécessiteraient que l'utilisateur télécharge les données de la police, ce qui serait lent.

-

Une solution consiste à augmenter la résolution des étiquettes. -Essayez de doubler la taille passée en paramètre et de diviser par deux la valeur actuelle de labelBaseScale.

-
    -
  • Les étiquettes s'allongent plus le nom est long.
  • -
-

Si vous vouliez résoudre ce problème, vous choisiriez plutôt une étiquette de taille fixe et compresseriez le texte.

-

C'est assez facile. Passez une largeur de base et adaptez le texte à cette largeur comme ceci :

-
-function makeLabelCanvas(size, name) {
-+function makeLabelCanvas(baseWidth, size, name) {
-  const borderSize = 2;
--  const ctx = document.createElement('canvas').getContext('2d');
-  const font =  `${size}px bold sans-serif`;
-  ctx.font = font;
-  // mesurer la longueur du nom
-+  const textWidth = ctx.measureText(name).width;
-
-  const doubleBorderSize = borderSize * 2;
--  const width = ctx.measureText(name).width + doubleBorderSize;
-+  const width = baseWidth + doubleBorderSize;
-  const height = size + doubleBorderSize;
-  ctx.canvas.width = width;
-  ctx.canvas.height = height;
-
-  // besoin de redéfinir la police après avoir redimensionné le canvas
-  ctx.font = font;
--  ctx.textBaseline = 'top';
-+  ctx.textBaseline = 'middle';
-+  ctx.textAlign = 'center';
-
-  ctx.fillStyle = 'blue';
-  ctx.fillRect(0, 0, width, height);
-
-+  // adapter à la taille mais ne pas étirer
-+  const scaleFactor = Math.min(1, baseWidth / textWidth);
-+  ctx.translate(width / 2, height / 2);
-+  ctx.scale(scaleFactor, 1);
-  ctx.fillStyle = 'white';
-  ctx.fillText(name, borderSize, borderSize);
-
-  return ctx.canvas;
-}
-
-

Nous pouvons ensuite passer une largeur pour les étiquettes.

-
-function makePerson(x, size, name, color) {
--  const canvas = makeLabelCanvas(size, name);
-+function makePerson(x, labelWidth, size, name, color) {
-+  const canvas = makeLabelCanvas(labelWidth, size, name);
-
-...
-
-}
-
--makePerson(-3, 32, 'Purple People Eater', 'purple');
--makePerson(-0, 32, 'Green Machine', 'green');
--makePerson(+3, 32, 'Red Menace', 'red');
-+makePerson(-3, 150, 32, 'Purple People Eater', 'purple');
-+makePerson(-0, 150, 32, 'Green Machine', 'green');
-+makePerson(+3, 150, 32, 'Red Menace', 'red');
-
-

et nous obtenons des étiquettes dont le texte est centré et adapté à la taille.

-

- -

-

Ci-dessus, nous avons utilisé un nouveau canvas pour chaque texture. Utiliser ou non un canvas par texture dépend de vous. Si vous avez besoin de les mettre à jour souvent, avoir un canvas par texture est probablement la meilleure option. Si elles sont rarement ou jamais mises à jour, vous pouvez choisir d'utiliser un seul canvas pour plusieurs textures en forçant three.js à utiliser la texture. Modifions le code ci-dessus pour faire exactement cela.

-
+const ctx = document.createElement('canvas').getContext('2d');
-
-function makeLabelCanvas(baseWidth, size, name) {
-  const borderSize = 2;
--  const ctx = document.createElement('canvas').getContext('2d');
-  const font =  `${size}px bold sans-serif`;
-
-  ...
-
-}
-
-+const forceTextureInitialization = function() {
-+  const material = new THREE.MeshBasicMaterial();
-+  const geometry = new THREE.PlaneGeometry();
-+  const scene = new THREE.Scene();
-+  scene.add(new THREE.Mesh(geometry, material));
-+  const camera = new THREE.Camera();
-+
-+  return function forceTextureInitialization(texture) {
-+    material.map = texture;
-+    renderer.render(scene, camera);
-+  };
-+}();
-
-function makePerson(x, labelWidth, size, name, color) {
-  const canvas = makeLabelCanvas(labelWidth, size, name);
-  const texture = new THREE.CanvasTexture(canvas);
-  // parce que notre canvas n'est probablement pas une puissance de 2
-  // dans les deux dimensions, définissez le filtrage de manière appropriée.
-  texture.minFilter = THREE.LinearFilter;
-  texture.wrapS = THREE.ClampToEdgeWrapping;
-  texture.wrapT = THREE.ClampToEdgeWrapping;
-+  forceTextureInitialization(texture);
-
-  ...
-
-

- -

-

Un autre problème est que les étiquettes ne font pas toujours face à la caméra. Si vous utilisez les étiquettes comme des badges, c'est probablement une bonne chose. Si vous utilisez les étiquettes pour afficher les noms des joueurs dans un jeu en 3D, vous pourriez vouloir que les étiquettes fassent toujours face à la caméra. Nous aborderons comment faire cela dans un article sur les billboards.

-

Pour les étiquettes en particulier, une autre solution consiste à utiliser le HTML. Les étiquettes dans cet article sont à l'intérieur du monde 3D, ce qui est bien si vous voulez qu'elles soient cachées par d'autres objets, tandis que les étiquettes HTML sont toujours au-dessus.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/cleanup.html b/manual/fr/cleanup.html deleted file mode 100644 index fb5f53a021082b..00000000000000 --- a/manual/fr/cleanup.html +++ /dev/null @@ -1,452 +0,0 @@ - - - Nettoyage - - - - - - - - - - - - - -
-
-

Nettoyage

-
-
-
-

Les applications Three.js utilisent souvent beaucoup de mémoire. Un modèle 3D -peut occuper de 1 à 20 Mo de mémoire pour l'ensemble de ses sommets. -Un modèle peut utiliser de nombreuses textures qui, même si elles sont -compressées en fichiers jpg, doivent être décompressées -pour être utilisées. Chaque texture 1024x1024 prend 4 à 6 Mo -de mémoire.

-

La plupart des applications three.js chargent les ressources au moment -de l'initialisation et les utilisent ensuite indéfiniment jusqu'à ce que la page soit -fermée. Mais que se passe-t-il si vous souhaitez charger et modifier des ressources -au fil du temps ?

-

Contrairement à la plupart des codes JavaScript, three.js ne peut pas nettoyer -automatiquement ces ressources. Le navigateur les nettoiera -si vous changez de page, mais sinon, c'est à vous -de les gérer. C'est un problème lié à la conception de WebGL, -et three.js n'a donc d'autre choix que de vous confier la -responsabilité de libérer les ressources.

-

Vous libérez les ressources three.js en appelant la fonction dispose sur -les textures, -les géométries, et les -matériaux.

-

Vous pourriez le faire manuellement. Au début, vous pourriez créer -certaines de ces ressources

-
const boxGeometry = new THREE.BoxGeometry(...);
-const boxTexture = textureLoader.load(...);
-const boxMaterial = new THREE.MeshPhongMaterial({map: texture});
-
-

puis, lorsque vous avez terminé avec elles, vous les libéreriez

-
boxGeometry.dispose();
-boxTexture.dispose();
-boxMaterial.dispose();
-
-

À mesure que vous utilisez de plus en plus de ressources, cela deviendrait de plus en -plus fastidieux.

-

Pour aider à réduire cette tâche fastidieuse, créons une classe pour suivre -les ressources. Nous demanderons ensuite à cette classe de faire le nettoyage -pour nous.

-

Voici une première ébauche d'une telle classe

-
class ResourceTracker {
-  constructor() {
-    this.resources = new Set();
-  }
-  track(resource) {
-    if (resource.dispose) {
-      this.resources.add(resource);
-    }
-    return resource;
-  }
-  untrack(resource) {
-    this.resources.delete(resource);
-  }
-  dispose() {
-    for (const resource of this.resources) {
-      resource.dispose();
-    }
-    this.resources.clear();
-  }
-}
-
-

Utilisons cette classe avec le premier exemple de l'article sur les textures. -Nous pouvons créer une instance de cette classe

-
const resTracker = new ResourceTracker();
-
-

et pour faciliter son utilisation, créons une fonction liée pour la méthode track

-
const resTracker = new ResourceTracker();
-+const track = resTracker.track.bind(resTracker);
-
-

Maintenant, pour l'utiliser, il suffit d'appeler track pour chaque géométrie, texture, et matériau -que nous créons

-
const boxWidth = 1;
-const boxHeight = 1;
-const boxDepth = 1;
--const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-+const geometry = track(new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth));
-
-const cubes = [];  // an array we can use to rotate the cubes
-const loader = new THREE.TextureLoader();
-
--const material = new THREE.MeshBasicMaterial({
--  map: loader.load('resources/images/wall.jpg'),
--});
-+const material = track(new THREE.MeshBasicMaterial({
-+  map: track(loader.load('resources/images/wall.jpg')),
-+}));
-const cube = new THREE.Mesh(geometry, material);
-scene.add(cube);
-cubes.push(cube);  // add to our list of cubes to rotate
-
-

Et ensuite, pour les libérer, nous voudrions retirer les cubes de la scène -et appeler resTracker.dispose

-
for (const cube of cubes) {
-  scene.remove(cube);
-}
-cubes.length = 0;  // clears the cubes array
-resTracker.dispose();
-
-

Cela fonctionnerait, mais je trouve fastidieux de devoir retirer les cubes de la -scène. Ajoutons cette fonctionnalité au ResourceTracker.

-
class ResourceTracker {
-  constructor() {
-    this.resources = new Set();
-  }
-  track(resource) {
--    if (resource.dispose) {
-+    if (resource.dispose || resource instanceof THREE.Object3D) {
-      this.resources.add(resource);
-    }
-    return resource;
-  }
-  untrack(resource) {
-    this.resources.delete(resource);
-  }
-  dispose() {
-    for (const resource of this.resources) {
--      resource.dispose();
-+      if (resource instanceof THREE.Object3D) {
-+        if (resource.parent) {
-+          resource.parent.remove(resource);
-+        }
-+      }
-+      if (resource.dispose) {
-+        resource.dispose();
-+      }
-+    }
-    this.resources.clear();
-  }
-}
-
-

Et maintenant nous pouvons suivre les cubes

-
const material = track(new THREE.MeshBasicMaterial({
-  map: track(loader.load('resources/images/wall.jpg')),
-}));
-const cube = track(new THREE.Mesh(geometry, material));
-scene.add(cube);
-cubes.push(cube);  // add to our list of cubes to rotate
-
-

Nous n'avons plus besoin du code pour retirer les cubes de la scène.

-
-for (const cube of cubes) {
--  scene.remove(cube);
--}
-cubes.length = 0;  // clears the cube array
-resTracker.dispose();
-
-

Organisons ce code afin de pouvoir rajouter le cube, -la texture et le matériau.

-
const scene = new THREE.Scene();
-*const cubes = [];  // just an array we can use to rotate the cubes
-
-+function addStuffToScene() {
-  const resTracker = new ResourceTracker();
-  const track = resTracker.track.bind(resTracker);
-
-  const boxWidth = 1;
-  const boxHeight = 1;
-  const boxDepth = 1;
-  const geometry = track(new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth));
-
-  const loader = new THREE.TextureLoader();
-
-  const material = track(new THREE.MeshBasicMaterial({
-    map: track(loader.load('resources/images/wall.jpg')),
-  }));
-  const cube = track(new THREE.Mesh(geometry, material));
-  scene.add(cube);
-  cubes.push(cube);  // add to our list of cubes to rotate
-+  return resTracker;
-+}
-
-

Et ensuite, écrivons du code pour ajouter et supprimer des choses au fil du temps.

-
function waitSeconds(seconds = 0) {
-  return new Promise(resolve => setTimeout(resolve, seconds * 1000));
-}
-
-async function process() {
-  for (;;) {
-    const resTracker = addStuffToScene();
-    await wait(2);
-    cubes.length = 0;  // remove the cubes
-    resTracker.dispose();
-    await wait(1);
-  }
-}
-process();
-
-

Ce code va créer le cube, la texture et le matériau, attendre 2 secondes, puis les libérer et attendre 1 seconde -et répéter.

-

- -

-

Cela semble donc fonctionner.

-

Cependant, pour un fichier chargé, le travail est un peu plus conséquent. La plupart des chargeurs ne renvoient qu'un Object3D -comme racine de la hiérarchie des objets qu'ils chargent, nous devons donc découvrir toutes les ressources -qu'il contient.

-

Mettons à jour notre ResourceTracker pour essayer de faire cela.

-

Nous allons d'abord vérifier si l'objet est un Object3D, puis suivre sa géométrie, son matériau et ses enfants.

-
class ResourceTracker {
-  constructor() {
-    this.resources = new Set();
-  }
-  track(resource) {
-    if (resource.dispose || resource instanceof THREE.Object3D) {
-      this.resources.add(resource);
-    }
-+    if (resource instanceof THREE.Object3D) {
-+      this.track(resource.geometry);
-+      this.track(resource.material);
-+      this.track(resource.children);
-+    }
-    return resource;
-  }
-  ...
-}
-
-

Maintenant, comme resource.geometry, resource.material et resource.children -peuvent être nuls ou indéfinis, nous allons vérifier en haut de track.

-
class ResourceTracker {
-  constructor() {
-    this.resources = new Set();
-  }
-  track(resource) {
-+    if (!resource) {
-+      return resource;
-+    }
-
-    if (resource.dispose || resource instanceof THREE.Object3D) {
-      this.resources.add(resource);
-    }
-    if (resource instanceof THREE.Object3D) {
-      this.track(resource.geometry);
-      this.track(resource.material);
-      this.track(resource.children);
-    }
-    return resource;
-  }
-  ...
-}
-
-

De plus, comme resource.children est un tableau et que resource.material peut être -un tableau, vérifions s'il s'agit de tableaux.

-
class ResourceTracker {
-  constructor() {
-    this.resources = new Set();
-  }
-  track(resource) {
-    if (!resource) {
-      return resource;
-    }
-
-*    // handle children and when material is an array of materials.
-*    // Gérer les enfants et lorsque le matériau est un tableau de matériaux.
-    if (Array.isArray(resource)) {
-      resource.forEach(resource => this.track(resource));
-      return resource;
-    }
-
-    if (resource.dispose || resource instanceof THREE.Object3D) {
-      this.resources.add(resource);
-    }
-    if (resource instanceof THREE.Object3D) {
-      this.track(resource.geometry);
-      this.track(resource.material);
-      this.track(resource.children);
-    }
-    return resource;
-  }
-  ...
-}
-
-

Et enfin, nous devons parcourir les propriétés et les uniformes -d'un matériau à la recherche de textures.

-
class ResourceTracker {
-  constructor() {
-    this.resources = new Set();
-  }
-  track(resource) {
-    if (!resource) {
-      return resource;
-    }
-
-*    // handle children and when material is an array of materials or
-*    // uniform is array of textures
-*    // Gérer les enfants et lorsque le matériau est un tableau de matériaux ou
-*    // qu'un uniforme est un tableau de textures
-    if (Array.isArray(resource)) {
-      resource.forEach(resource => this.track(resource));
-      return resource;
-    }
-
-    if (resource.dispose || resource instanceof THREE.Object3D) {
-      this.resources.add(resource);
-    }
-    if (resource instanceof THREE.Object3D) {
-      this.track(resource.geometry);
-      this.track(resource.material);
-      this.track(resource.children);
--    }
-+    } else if (resource instanceof THREE.Material) {
-+      // We have to check if there are any textures on the material
-+      // Nous devons vérifier s'il y a des textures sur le matériau
-+      for (const value of Object.values(resource)) {
-+        if (value instanceof THREE.Texture) {
-+          this.track(value);
-+        }
-+      }
-+      // We also have to check if any uniforms reference textures or arrays of textures
-+      // Nous devons aussi vérifier si des uniformes font référence à des textures ou à des tableaux de textures
-+      if (resource.uniforms) {
-+        for (const value of Object.values(resource.uniforms)) {
-+          if (value) {
-+            const uniformValue = value.value;
-+            if (uniformValue instanceof THREE.Texture ||
-+                Array.isArray(uniformValue)) {
-+              this.track(uniformValue);
-+            }
-+          }
-+        }
-+      }
-+    }
-    return resource;
-  }
-  ...
-}
-
-

Et avec cela, prenons un exemple de l'article sur le chargement de fichiers gltf -et faisons-le charger et libérer des fichiers.

-
const gltfLoader = new GLTFLoader();
-function loadGLTF(url) {
-  return new Promise((resolve, reject) => {
-    gltfLoader.load(url, resolve, undefined, reject);
-  });
-}
-
-function waitSeconds(seconds = 0) {
-  return new Promise(resolve => setTimeout(resolve, seconds * 1000));
-}
-
-const fileURLs = [
-  'resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf',
-  'resources/models/3dbustchallange_submission/scene.gltf',
-  'resources/models/mountain_landscape/scene.gltf',
-  'resources/models/simple_house_scene/scene.gltf',
-];
-
-async function loadFiles() {
-  for (;;) {
-    for (const url of fileURLs) {
-      const resMgr = new ResourceTracker();
-      const track = resMgr.track.bind(resMgr);
-      const gltf = await loadGLTF(url);
-      const root = track(gltf.scene);
-      scene.add(root);
-
-      // compute the box that contains all the stuff
-      // from root and below
-      // calculer la boîte qui contient tout le contenu
-      // à partir de la racine et en dessous
-      const box = new THREE.Box3().setFromObject(root);
-
-      const boxSize = box.getSize(new THREE.Vector3()).length();
-      const boxCenter = box.getCenter(new THREE.Vector3());
-
-      // set the camera to frame the box
-      // définir la caméra pour cadrer la boîte
-      frameArea(boxSize * 1.1, boxSize, boxCenter, camera);
-
-      await waitSeconds(2);
-      renderer.render(scene, camera);
-
-      resMgr.dispose();
-
-      await waitSeconds(1);
-
-    }
-  }
-}
-loadFiles();
-
-

et nous obtenons

-

- -

-

Quelques notes sur le code.

-

Si nous voulions charger 2 fichiers ou plus à la fois et les libérer à -tout moment, nous utiliserions un ResourceTracker par fichier.

-

Ci-dessus, nous suivons uniquement gltf.scene juste après le chargement. -Sur la base de notre implémentation actuelle de ResourceTracker, -cela suivra toutes les ressources juste chargées. Si nous ajoutions plus -d'éléments à la scène, nous devrions décider de les suivre ou non.

-

Par exemple, disons qu'après avoir chargé un personnage, nous mettons un outil -dans sa main en faisant de l'outil un enfant de sa main. Tel quel, -cet outil ne sera pas libéré. Je suppose que la plupart du temps, -c'est ce que nous voulons.

-

Cela soulève un point. À l'origine, lorsque j'ai écrit pour la première fois le ResourceTracker -ci-dessus, je parcourais tout à l'intérieur de la méthode dispose au lieu de track. -Ce n'est que plus tard, en réfléchissant au cas de l'outil en tant qu'enfant de la main ci-dessus, -qu'il est devenu clair que suivre exactement ce qu'il faut libérer dans track était plus -flexible et sans doute plus correct, car nous pouvions alors suivre ce qui avait été chargé -depuis le fichier plutôt que de simplement libérer l'état du graphe de scène plus tard.

-

Honnêtement, je ne suis pas satisfait à 100% de ResourceTracker. Faire les choses de cette -manière n'est pas courant dans les moteurs 3D. Nous ne devrions pas avoir à deviner quelles -ressources ont été chargées, nous devrions le savoir. Il serait bien que three.js -change de sorte que tous les chargeurs de fichiers renvoient un objet standard avec -des références à toutes les ressources chargées. Du moins pour l'instant, -three.js ne nous donne pas plus d'informations lors du chargement d'une scène, donc cette -solution semble fonctionner.

-

J'espère que vous trouverez cet exemple utile ou du moins une bonne référence pour ce qui est -nécessaire pour libérer des ressources dans three.js

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/color-management.html b/manual/fr/color-management.html deleted file mode 100644 index 1c45647e936a02..00000000000000 --- a/manual/fr/color-management.html +++ /dev/null @@ -1,354 +0,0 @@ - - - Gestion des couleurs - - - - - - - - - - - - - - -
-
-

Gestion des couleurs

-
-
-
- -

Qu'est-ce qu'un espace couleur ?

- -

- Chaque espace couleur est une collection de plusieurs décisions de conception, choisies ensemble pour prendre en charge une - vaste gamme de couleurs tout en satisfaisant aux contraintes techniques liées à la précision et aux technologies - d'affichage. Lors de la création d'un actif 3D, ou de l'assemblage d'actifs 3D dans une scène, il est - important de connaître ces propriétés et la façon dont les propriétés d'un espace couleur sont liées - aux autres espaces couleur de la scène. -

- -
- -
- Couleurs sRGB et point blanc (D65) affichés dans le diagramme de chromaticité de référence CIE 1931. - La région colorée représente une projection 2D du gamut sRGB, qui est un volume 3D. - Source : Wikipedia -
-
- -
    -
  • - Primaires de couleur : Les couleurs primaires (par exemple rouge, vert, bleu) ne sont pas absolues ; elles sont - sélectionnées à partir du spectre visible en fonction des contraintes de précision limitée et des capacités des - périphériques d'affichage disponibles. Les couleurs sont exprimées comme un ratio des couleurs primaires. -
  • -
  • - Point blanc : La plupart des espaces couleur sont conçus de telle sorte qu'une somme pondérée égale des - primaires R = G = B apparaisse sans couleur, ou "achromatique". L'apparence - des valeurs achromatiques (comme le blanc ou le gris) dépend de la perception humaine, qui à son tour dépend - fortement du contexte de l'observateur. Un espace couleur spécifie son "point blanc" pour équilibrer - ces besoins. Le point blanc défini par l'espace couleur sRGB est - [link:https://en.wikipedia.org/wiki/Illuminant_D65 D65]. -
  • -
  • - Fonctions de transfert : Après avoir choisi le gamut et un modèle de couleur, nous devons encore - définir des correspondances ("fonctions de transfert") des valeurs numériques vers/depuis l'espace couleur. Est-ce que r = 0.5 - représente 50 % moins d'éclairage physique que r = 1.0 ? Ou 50 % moins lumineux, tel que perçu - par un œil humain moyen ? Ce sont des choses différentes, et cette différence peut être représentée par - une fonction mathématique. Les fonctions de transfert peuvent être linéaires ou non linéaires, en fonction - des objectifs de l'espace couleur. sRGB définit des fonctions de transfert non linéaires. Ces - fonctions sont parfois approximées par des fonctions gamma, mais le terme "gamma" est - ambigu et doit être évité dans ce contexte. -
  • -
- - Ces trois paramètres — primaires de couleur, point blanc et fonctions de transfert — définissent un espace - couleur, chacun choisi pour des objectifs particuliers. Ayant défini les paramètres, quelques termes supplémentaires - sont utiles : - -
    -
  • - Modèle de couleur : Syntaxe pour identifier numériquement les couleurs dans le gamut choisi — - un système de coordonnées pour les couleurs. Dans three.js, nous sommes principalement concernés par le modèle de couleur - RGB, ayant trois coordonnées r, g, b ∈ [0,1] ("domaine fermé") ou - r, g, b ∈ [0,∞] ("domaine ouvert") représentant chacune une fraction d'une couleur - primaire. D'autres modèles de couleur (HSL, Lab, LCH) sont couramment utilisés pour le contrôle artistique. -
  • -
  • - Gamut de couleur : Une fois que les primaires de couleur et un point blanc ont été choisis, ceux-ci représentent - un volume dans le spectre visible (un "gamut"). Les couleurs ne se trouvant pas dans ce volume ("hors gamut") - ne peuvent pas être exprimées par des valeurs RGB [0,1] en domaine fermé. Dans le domaine ouvert [0,∞], le gamut est - techniquement infini. -
  • -
- -

- Considérez deux espaces couleur très courants : `SRGBColorSpace` ("sRGB") et - `LinearSRGBColorSpace` ("Linear-sRGB"). Les deux utilisent les mêmes primaires et le même point blanc, - et ont donc le même gamut de couleur. Les deux utilisent le modèle de couleur RGB. Ils ne diffèrent que par - les fonctions de transfert — Linear-sRGB est linéaire par rapport à l'intensité lumineuse physique. - sRGB utilise les fonctions de transfert sRGB non linéaires, et ressemble plus étroitement à la façon dont - l'œil humain perçoit la lumière et à la réactivité des périphériques d'affichage courants. -

- -

- Cette différence est importante. Les calculs d'éclairage et autres opérations de rendu doivent - généralement avoir lieu dans un espace couleur linéaire. Cependant, les couleurs linéaires sont moins efficaces pour - être stockées dans une image ou un framebuffer, et ne paraissent pas correctes lorsqu'elles sont visualisées par un observateur humain. - En conséquence, les textures d'entrée et l'image finale rendue utiliseront généralement l'espace couleur sRGB non linéaire. -

- -
-

- ℹ️ AVIS : Bien que certains écrans modernes prennent en charge des gamuts plus larges comme Display-P3, - les API graphiques de la plateforme web reposent largement sur sRGB. Les applications utilisant three.js - aujourd'hui utiliseront généralement uniquement les espaces couleur sRGB et Linear-sRGB. -

-
- -

Rôles des espaces couleur

- -

- Les flux de travail linéaires — requis pour les méthodes de rendu modernes — impliquent généralement plus d'un - espace couleur, chacun assigné à un rôle particulier. Les espaces couleur linéaires et non linéaires sont - appropriés pour différents rôles, expliqués ci-dessous. -

- -

Espace couleur d'entrée

- -

- Les couleurs fournies à three.js — à partir de sélecteurs de couleur, de textures, de modèles 3D et d'autres sources — - ont chacune un espace couleur associé. Celles qui ne sont pas déjà dans l'espace couleur de travail Linear-sRGB - doivent être converties, et les textures doivent recevoir l'affectation correcte texture.colorSpace. - Certaines conversions (pour les couleurs hexadécimales et CSS en sRGB) peuvent être effectuées automatiquement si - l'API THREE.ColorManagement est activée avant l'initialisation des couleurs : -

- - -THREE.ColorManagement.enabled = true; - - -

- THREE.ColorManagement est activé par défaut. -

- -
    -
  • - Matériaux, lumières et shaders : Les couleurs dans les matériaux, les lumières et les shaders stockent - les composants RGB dans l'espace couleur de travail Linear-sRGB. -
  • -
  • - Couleurs de sommet : `BufferAttribute` stockent les composants RGB dans l'espace couleur de travail - Linear-sRGB. -
  • -
  • - Textures de couleur : Les `Texture` PNG ou JPEG contenant des informations de couleur - (comme .map ou .emissiveMap) utilisent l'espace couleur sRGB en domaine fermé, et doivent être annotées avec - texture.colorSpace = SRGBColorSpace. Des formats comme OpenEXR (parfois utilisés pour .envMap ou - .lightMap) utilisent l'espace couleur Linear-sRGB indiqué par texture.colorSpace = LinearSRGBColorSpace, - et peuvent contenir des valeurs dans le domaine ouvert [0,∞]. -
  • -
  • - Textures non couleur : Les textures qui ne stockent pas d'informations de couleur (comme .normalMap - ou .roughnessMap) n'ont pas d'espace couleur associé, et utilisent généralement l'annotation de texture (par défaut) de - texture.colorSpace = NoColorSpace. Dans de rares cas, les données non couleur - peuvent être représentées avec d'autres encodages non linéaires pour des raisons techniques. -
  • -
- -
-

- ⚠️ AVERTISSEMENT : De nombreux formats de modèles 3D ne définissent pas correctement ou de manière cohérente - les informations d'espace couleur. Bien que three.js tente de gérer la plupart des cas, les problèmes - sont fréquents avec les anciens formats de fichiers. Pour de meilleurs résultats, utilisez glTF 2.0 (`GLTFLoader`) - et testez les modèles 3D dans des visualiseurs en ligne tôt pour confirmer que l'actif lui-même est correct. -

-
- -

Espace couleur de travail

- -

- Le rendu, l'interpolation et de nombreuses autres opérations doivent être effectuées dans un espace couleur de travail - linéaire en domaine ouvert, dans lequel les composants RGB sont proportionnels à l'illumination physique. - Dans three.js, l'espace couleur de travail est Linear-sRGB. -

- -

Espace couleur de sortie

- -

- La sortie vers un périphérique d'affichage, une image ou une vidéo peut impliquer une conversion de l'espace couleur de travail - Linear-sRGB en domaine ouvert vers un autre espace couleur. La conversion est définie par - (`WebGLRenderer.outputColorSpace`). Lors de l'utilisation du post-traitement, cela nécessite OutputPass. -

- -
    -
  • - Affichage : Les couleurs écrites sur un canevas WebGL pour l'affichage doivent être dans l'espace - couleur sRGB. -
  • -
  • - Image : Les couleurs écrites dans une image doivent utiliser l'espace couleur approprié pour - le format et l'utilisation. Les images entièrement rendues écrites dans des textures PNG ou JPEG utilisent généralement - l'espace couleur sRGB. Les images contenant de l'émission, des lightmaps ou d'autres données non confinées à la plage [0,1] - utiliseront généralement l'espace couleur Linear-sRGB en domaine ouvert, et un format d'image compatible comme OpenEXR. -
  • -
- -
-

- ⚠️ AVERTISSEMENT : Les cibles de rendu peuvent utiliser soit sRGB soit Linear-sRGB. sRGB utilise - mieux la précision limitée. Dans le domaine fermé, 8 bits suffisent souvent pour sRGB tandis que ≥12 bits - (half float) peuvent être nécessaires pour Linear-sRGB. Si les étapes ultérieures du pipeline nécessitent - une entrée Linear-sRGB, les conversions supplémentaires peuvent entraîner un léger coût de performance. -

-
- -

- Les matériaux personnalisés basés sur `ShaderMaterial` et `RawShaderMaterial` doivent implémenter leur propre conversion d'espace couleur de sortie. - Pour les instances de `ShaderMaterial`, ajouter le chunk de shader `colorspace_fragment` à la fonction `main()` du shader de fragment devrait être suffisant. -

- -

Utilisation des instances THREE.Color

- -

- Les méthodes lisant ou modifiant des instances `Color` supposent que les données sont déjà dans - l'espace couleur de travail de three.js, Linear-sRGB. Les composants RGB et HSL sont des - représentations directes des données stockées par l'instance Color, et ne sont jamais convertis - implicitement. Les données de couleur peuvent être explicitement converties avec .convertLinearToSRGB() - ou .convertSRGBToLinear(). -

- -
-// RGB components (no change).
-color.r = color.g = color.b = 0.5;
-console.log( color.r ); // → 0.5
-
-// Manual conversion.
-color.r = 0.5;
-color.convertSRGBToLinear();
-console.log( color.r ); // → 0.214041140
-
- -

- Avec ColorManagement.enabled = true (recommandé), certaines conversions - sont effectuées automatiquement. Comme les couleurs hexadécimales et CSS sont généralement sRGB, les méthodes `Color` - convertiront automatiquement ces entrées de sRGB vers Linear-sRGB dans les setters, ou convertiront de - Linear-sRGB vers sRGB lors du retour d'une sortie hexadécimale ou CSS à partir des getters. -

- -
-// Hexadecimal conversion.
-color.setHex( 0x808080 );
-console.log( color.r ); // → 0.214041140
-console.log( color.getHex() ); // → 0x808080
-
-// CSS conversion.
-color.setStyle( 'rgb( 0.5, 0.5, 0.5 )' );
-console.log( color.r ); // → 0.214041140
-
-// Override conversion with 'colorSpace' argument.
-color.setHex( 0x808080, LinearSRGBColorSpace );
-console.log( color.r ); // → 0.5
-console.log( color.getHex( LinearSRGBColorSpace ) ); // → 0x808080
-console.log( color.getHex( SRGBColorSpace ) ); // → 0xBCBCBC
-
- -

Erreurs courantes

- -

- Lorsqu'une couleur ou une texture individuelle est mal configurée, elle apparaîtra plus foncée ou plus claire que - prévu. Lorsque l'espace couleur de sortie du renderer est mal configuré, toute la scène peut apparaître - plus foncée (par exemple, conversion manquante vers sRGB) ou plus claire (par exemple, une double conversion vers sRGB avec - post-traitement). Dans chaque cas, le problème peut ne pas être uniforme, et simplement augmenter/diminuer - l'éclairage ne le résout pas. -

- -

- Un problème plus subtil apparaît lorsque à la fois les espaces couleur d'entrée et les espaces couleur de sortie - sont incorrects — les niveaux de luminosité globaux peuvent être corrects, mais les couleurs peuvent changer - de manière inattendue sous différents éclairages, ou l'ombrage peut sembler plus brûlé et moins doux - que prévu. Ces deux erreurs ne font pas une seule bonne chose, et il est important que l'espace - couleur de travail soit linéaire ("référé à la scène") et l'espace couleur de sortie soit non linéaire - ("référé à l'affichage"). -

- -

Pour aller plus loin

- - - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/creating-a-scene.html b/manual/fr/creating-a-scene.html deleted file mode 100644 index 53686a7267e264..00000000000000 --- a/manual/fr/creating-a-scene.html +++ /dev/null @@ -1,179 +0,0 @@ - - - Créer une scène - - - - - - - - - - - - - -
-
-

Créer une scène

-
-
-
- -

Le but de cette section est de donner une brève introduction à three.js. Nous commencerons par configurer une scène, avec un cube en rotation. Un exemple fonctionnel est fourni en bas de la page au cas où vous seriez bloqué et auriez besoin d'aide.

- -

Avant de commencer

- -

- Si vous ne l'avez pas encore fait, parcourez le guide `Installation`. Nous supposerons que vous avez déjà configuré la même structure de projet (incluant index.html et main.js), avez installé three.js, et utilisez soit un outil de build, soit un serveur local avec un CDN et des import maps. -

- -

Créer la scène

- -

Pour pouvoir afficher quoi que ce soit avec three.js, nous avons besoin de trois éléments : une scène, une caméra et un moteur de rendu (renderer), afin que nous puissions afficher la scène avec la caméra.

- -

main.js —

- -
-import * as THREE from 'three';
-
-const scene = new THREE.Scene();
-const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
-
-const renderer = new THREE.WebGLRenderer();
-renderer.setSize( window.innerWidth, window.innerHeight );
-document.body.appendChild( renderer.domElement );
-
- -

Prenons un instant pour expliquer ce qui se passe ici. Nous avons maintenant configuré la scène, notre caméra et le moteur de rendu.

- -

Il existe plusieurs caméras différentes dans three.js. Pour l'instant, utilisons une `PerspectiveCamera`.

- -

Le premier attribut est le `champ de vision`. Le FOV est l'étendue de la scène visible sur l'écran à un moment donné. La valeur est en degrés.

- -

Le deuxième est le `rapport d'aspect`. Vous voudrez presque toujours utiliser la largeur de l'élément divisée par la hauteur, sinon vous obtiendrez le même résultat que lorsque vous regardez de vieux films sur une télévision grand écran - l'image semble écrasée.

- -

Les deux attributs suivants sont les plans de découpe `near` (proche) et `far` (éloigné). Cela signifie que les objets plus éloignés de la caméra que la valeur de `far` ou plus proches que `near` ne seront pas rendus. Vous n'avez pas à vous en soucier maintenant, mais vous pourriez vouloir utiliser d'autres valeurs dans vos applications pour obtenir de meilleures performances.

- -

Vient ensuite le moteur de rendu (renderer). En plus de créer l'instance du moteur de rendu, nous devons également définir la taille à laquelle nous voulons qu'il affiche notre application. C'est une bonne idée d'utiliser la largeur et la hauteur de la zone que nous voulons remplir avec notre application - dans ce cas, la largeur et la hauteur de la fenêtre du navigateur. Pour les applications gourmandes en performances, vous pouvez également donner des valeurs plus petites à `setSize`, comme `window.innerWidth/2` et `window.innerHeight/2`, ce qui fera afficher l'application à un quart de la taille.

- -

Si vous souhaitez conserver la taille de votre application mais l'afficher à une résolution inférieure, vous pouvez le faire en appelant `setSize` avec `false` comme argument `updateStyle` (le troisième argument). Par exemple, `setSize(window.innerWidth/2, window.innerHeight/2, false)` affichera votre application à moitié résolution, étant donné que votre <canvas> a une largeur et une hauteur de 100%.

- -

Enfin, nous ajoutons l'élément `renderer` à notre document HTML. C'est un élément <canvas> que le moteur de rendu utilise pour nous afficher la scène.

- -

"Tout ça c'est bien beau, mais où est ce cube que vous avez promis ?" Ajoutons-le maintenant.

- -
-const geometry = new THREE.BoxGeometry( 1, 1, 1 );
-const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
-const cube = new THREE.Mesh( geometry, material );
-scene.add( cube );
-
-camera.position.z = 5;
-
- -

Pour créer un cube, nous avons besoin d'une `BoxGeometry`. C'est un objet qui contient tous les points (`vertices`) et le remplissage (`faces`) du cube. Nous explorerons cela plus en détail à l'avenir.

- -

En plus de la géométrie, nous avons besoin d'un matériau pour le colorer. Three.js est livré avec plusieurs matériaux, mais nous nous en tiendrons au `MeshBasicMaterial` pour l'instant. Tous les matériaux prennent un objet de propriétés qui leur seront appliquées. Pour simplifier les choses au maximum, nous fournissons seulement un attribut de couleur `0x00ff00`, qui est le vert. Cela fonctionne de la même manière que les couleurs dans CSS ou Photoshop (`couleurs hexadécimales`).

- -

La troisième chose dont nous avons besoin est un `Mesh`. Un mesh est un objet qui prend une géométrie et lui applique un matériau, que nous pouvons ensuite insérer dans notre scène et déplacer librement.

- -

Par défaut, lorsque nous appelons `scene.add()`, l'élément que nous ajoutons sera ajouté aux coordonnées `(0,0,0)`. Cela entraînerait la caméra et le cube à être l'un dans l'autre. Pour éviter cela, nous déplaçons simplement un peu la caméra.

- -

Afficher la scène

- -

Si vous copiez le code ci-dessus dans le fichier main.js que nous avons créé précédemment, vous ne pourrez rien voir. C'est parce que nous n'affichons encore rien. Pour cela, nous avons besoin de ce qu'on appelle une boucle de rendu ou d'animation.

- -
-function animate( time ) {
-  renderer.render( scene, camera );
-}
-renderer.setAnimationLoop( animate );
-
- -

Cela créera une boucle qui fait que le moteur de rendu dessine la scène à chaque rafraîchissement de l'écran (sur un écran typique, cela signifie 60 fois par seconde). Si vous débutez dans l'écriture de jeux dans le navigateur, vous pourriez dire "pourquoi ne pas simplement créer un setInterval ?" Le fait est que - nous pourrions, mais `requestAnimationFrame` qui est utilisé en interne dans `WebGLRenderer` présente un certain nombre d'avantages. Le plus important est peut-être qu'il se met en pause lorsque l'utilisateur navigue vers un autre onglet du navigateur, évitant ainsi de gaspiller sa précieuse puissance de traitement et l'autonomie de sa batterie.

- -

Animer le cube

- -

Si vous insérez tout le code ci-dessus dans le fichier que vous avez créé avant de commencer, vous devriez voir une boîte verte. Rendons le tout un peu plus intéressant en le faisant pivoter.

- -

Ajoutez le code suivant juste au-dessus de l'appel `renderer.render` dans votre fonction `animate` :

- -
-cube.rotation.x = time / 2000;
-cube.rotation.y = time / 1000;
-
- -

Cela sera exécuté à chaque image (normalement 60 fois par seconde) et donnera au cube une belle animation de rotation. En gros, tout ce que vous voulez déplacer ou modifier pendant que l'application est en cours d'exécution doit passer par la boucle d'animation. Vous pouvez bien sûr appeler d'autres fonctions à partir de là, afin de ne pas vous retrouver avec une fonction `animate` de plusieurs centaines de lignes.

- -

Le résultat

-

Félicitations ! Vous avez maintenant terminé votre première application three.js. C'est simple, mais il faut bien commencer quelque part.

- -

Le code complet est disponible ci-dessous et sous forme d'un [link:https://jsfiddle.net/zycqb61k/ exemple live] modifiable. Jouez avec pour mieux comprendre comment cela fonctionne.

- -

index.html —

- -
-<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta charset="utf-8">
-    <title>Ma première application three.js</title>
-    <style>
-      body { margin: 0; }
-    </style>
-  </head>
-  <body>
-    <script type="module" src="/main.js"></script>
-  </body>
-</html>
-
- -

main.js —

- -
-import * as THREE from 'three';
-
-const scene = new THREE.Scene();
-const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
-
-const renderer = new THREE.WebGLRenderer();
-renderer.setSize( window.innerWidth, window.innerHeight );
-renderer.setAnimationLoop( animate );
-document.body.appendChild( renderer.domElement );
-
-const geometry = new THREE.BoxGeometry( 1, 1, 1 );
-const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
-const cube = new THREE.Mesh( geometry, material );
-scene.add( cube );
-
-camera.position.z = 5;
-
-function animate( time ) {
-
-  cube.rotation.x = time / 2000;
-  cube.rotation.y = time / 1000;
-
-  renderer.render( scene, camera );
-
-}
-
- -
-
-
- - - - - - - - diff --git a/manual/fr/creating-text.html b/manual/fr/creating-text.html deleted file mode 100644 index ede41a986e21c2..00000000000000 --- a/manual/fr/creating-text.html +++ /dev/null @@ -1,170 +0,0 @@ - - - Création de texte - - - - - - - - - - - - - -
-
-

Création de texte

-
-
-
- -
-

- Il y a souvent des moments où vous pourriez avoir besoin d'utiliser du texte dans votre application three.js - voici - quelques façons de le faire. -

-
- -

1. DOM + CSS

-
-

- L'utilisation de HTML est généralement la manière la plus simple et la plus rapide d'ajouter du texte. C'est la méthode - utilisée pour les superpositions descriptives dans la plupart des exemples three.js. -

-

Vous pouvez ajouter du contenu à un

-
-<div id="info">Description</div>
-
-

- et utiliser du balisage CSS pour le positionner absolument à une position au-dessus de tous les autres avec un - z-index, surtout si vous exécutez three.js en plein écran. -

- -
-#info {
-  position: absolute;
-  top: 10px;
-  width: 100%;
-  text-align: center;
-  z-index: 100;
-  display:block;
-}
-
- -
- - -

2. Utiliser `CSS2DRenderer` ou `CSS3DRenderer`

-
-

- Utilisez ces moteurs de rendu pour dessiner du texte de haute qualité contenu dans des éléments DOM dans votre scène three.js. - C'est similaire à 1., sauf qu'avec ces moteurs de rendu, les éléments peuvent être intégrés de manière plus étroite et dynamique dans la scène. -

-
- - -

3. Dessiner du texte sur un canvas et l'utiliser comme `Texture`

-
-

Utilisez cette méthode si vous souhaitez dessiner facilement du texte sur un plan dans votre scène three.js.

-
- - -

4. Créer un modèle dans votre application 3D préférée et exporter vers three.js

-
-

Utilisez cette méthode si vous préférez travailler avec vos applications 3D et importer les modèles dans three.js.

-
- - -

5. Géométrie de Texte Procédurale

-
-

- Si vous préférez travailler purement en THREE.js ou créer des géométries de texte 3D procédurales et dynamiques, - vous pouvez créer un maillage dont la géométrie est une instance de THREE.TextGeometry : -

-

- new THREE.TextGeometry( text, parameters ); -

-

- Pour que cela fonctionne, cependant, votre TextGeometry aura besoin d'une instance de THREE.Font - à définir sur son paramètre « font ». - - Consultez la page `TextGeometry` pour plus d'informations sur la manière dont cela peut être fait, des descriptions de chaque - paramètre accepté, et une liste des polices JSON qui sont incluses dans la distribution THREE.js elle-même. -

- -

Exemples

- -

- [example:webgl_geometry_text WebGL / géométrie / texte]
- [example:webgl_shadowmap WebGL / shadowmap] -

- -

- Si Typeface est indisponible, ou si vous souhaitez utiliser une police qui ne s'y trouve pas, il existe un tutoriel - avec un script python pour blender qui vous permet d'exporter du texte au format JSON de Three.js : - [link:http://www.jaanga.com/2012/03/blender-to-threejs-create-3d-text-with.html] -

- -
- - -

6. Polices bitmap

-
-

- Les BMFonts (polices bitmap) permettent de regrouper les glyphes dans une seule BufferGeometry. Le rendu BMFont - prend en charge le retour à la ligne, l'espacement des lettres, le crénage, les champs de distance signés avec dérivées standard, - les champs de distance signés multicanaux, les polices multi-textures, et plus encore. - Voir [link:https://github.com/felixmariotto/three-mesh-ui three-mesh-ui] ou [link:https://github.com/Jam3/three-bmfont-text three-bmfont-text]. -

-

- Des polices standard sont disponibles dans des projets comme - [link:https://github.com/etiennepinchon/aframe-fonts A-Frame Fonts], ou vous pouvez créer les vôtres - à partir de n'importe quelle police .TTF, en optimisant pour n'inclure que les caractères requis pour un projet. -

-

- Quelques outils utiles : -

-
    -
  • [link:http://msdf-bmfont.donmccurdy.com/ msdf-bmfont-web] (basé sur le web)
  • -
  • [link:https://github.com/soimy/msdf-bmfont-xml msdf-bmfont-xml] (ligne de commande)
  • -
  • [link:https://github.com/libgdx/libgdx/wiki/Hiero hiero] (application de bureau)
  • -
-
- - -

7. Troika Text

-
-

- Le paquet [link:https://www.npmjs.com/package/troika-three-text troika-three-text] rend - du texte antialiasé de qualité en utilisant une technique similaire à celle des BMFonts, mais fonctionne directement - avec n'importe quel fichier de police .TTF ou .WOFF, vous n'avez donc pas à prégénérer une texture de glyphe hors ligne. Il ajoute - également des capacités, notamment : -

-
    -
  • Effets comme les contours, les ombres portées et la courbure
  • -
  • La possibilité d'appliquer n'importe quel Material three.js, même un ShaderMaterial personnalisé
  • -
  • Prise en charge des ligatures de police, des scripts avec lettres jointes et de la mise en page de droite à gauche/bidirectionnelle
  • -
  • Optimisation pour de grandes quantités de texte dynamique, effectuant la majeure partie du travail hors du thread principal dans un web worker
  • -
-
- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/custom-buffergeometry.html b/manual/fr/custom-buffergeometry.html deleted file mode 100644 index 30438ec64dd9e8..00000000000000 --- a/manual/fr/custom-buffergeometry.html +++ /dev/null @@ -1,447 +0,0 @@ - - - BufferGeometry Personnalisée - - - - - - - - - - - - - -
-
-

BufferGeometry Personnalisée

-
-
-
-

BufferGeometry est la manière dont three.js représente toute la géométrie. Une BufferGeometry -est essentiellement une collection nommée de BufferAttributes. -Chaque BufferAttribute représente un tableau d'un type de données : positions, -normales, couleurs, uv, etc... Ensemble, les BufferAttributes nommées représentent -des tableaux parallèles de toutes les données pour chaque sommet.

-
- -

Ci-dessus, vous pouvez voir que nous avons 4 attributs : position, normal, color, uv. -Ils représentent des tableaux parallèles, ce qui signifie que le N-ième ensemble de données de chaque -attribut appartient au même sommet. Le sommet à l'index = 4 est mis en évidence -pour montrer que les données parallèles à travers tous les attributs définissent un seul sommet.

-

Cela soulève un point, voici un diagramme d'un cube avec un coin mis en évidence.

-
- -

En y réfléchissant, ce coin unique nécessite une normale différente pour chaque face du -cube. Une normale est une information sur la direction vers laquelle quelque chose fait face. Dans le diagramme, -les normales sont représentées par les flèches autour du sommet d'angle, montrant que chaque -face qui partage cette position de sommet a besoin d'une normale qui pointe dans une direction différente.

-

Ce coin a également besoin d'UV différents pour chaque face. Les UV sont des coordonnées de texture -qui spécifient quelle partie d'une texture dessinée sur un triangle correspond à cette -position de sommet. Vous pouvez voir que la face verte a besoin que ce sommet ait une UV qui corresponde -au coin supérieur droit de la texture F, la face bleue a besoin d'une UV qui corresponde au -coin supérieur gauche de la texture F, et la face rouge a besoin d'une UV qui corresponde au coin -inférieur gauche de la texture F.

-

Un seul sommet est la combinaison de toutes ses parties. Si un sommet a besoin d'une -partie différente, alors il doit s'agir d'un sommet différent.

-

Comme exemple simple, créons un cube en utilisant BufferGeometry. Un cube est intéressant -car il semble partager des sommets aux coins mais ce n'est pas le cas en réalité. Pour notre exemple, nous allons lister tous les sommets avec toutes leurs données, -puis convertir ces données en tableaux parallèles et enfin les utiliser pour créer des -BufferAttributes et les ajouter à une BufferGeometry.

-

Nous commençons par une liste de toutes les données nécessaires pour le cube. Rappelez-vous encore une fois -que si un sommet a des parties uniques, il doit s'agir d'un sommet distinct. En conséquence, -pour faire un cube, il faut 36 sommets. 2 triangles par face, 3 sommets par triangle, -6 faces = 36 sommets.

-
const vertices = [
-  // avant
-  { pos: [-1, -1,  1], norm: [ 0,  0,  1], uv: [0, 0], },
-  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
-  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
-
-  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
-  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 0,  0,  1], uv: [1, 1], },
-  // droite
-  { pos: [ 1, -1,  1], norm: [ 1,  0,  0], uv: [0, 0], },
-  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
-
-  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
-  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
-  { pos: [ 1,  1, -1], norm: [ 1,  0,  0], uv: [1, 1], },
-  // arrière
-  { pos: [ 1, -1, -1], norm: [ 0,  0, -1], uv: [0, 0], },
-  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
-  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
-
-  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
-  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
-  { pos: [-1,  1, -1], norm: [ 0,  0, -1], uv: [1, 1], },
-  // gauche
-  { pos: [-1, -1, -1], norm: [-1,  0,  0], uv: [0, 0], },
-  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
-  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
-
-  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
-  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
-  { pos: [-1,  1,  1], norm: [-1,  0,  0], uv: [1, 1], },
-  // haut
-  { pos: [ 1,  1, -1], norm: [ 0,  1,  0], uv: [0, 0], },
-  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
-
-  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
-  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
-  { pos: [-1,  1,  1], norm: [ 0,  1,  0], uv: [1, 1], },
-  // bas
-  { pos: [ 1, -1,  1], norm: [ 0, -1,  0], uv: [0, 0], },
-  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
-  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
-
-  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
-  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
-  { pos: [-1, -1, -1], norm: [ 0, -1,  0], uv: [1, 1], },
-];
-
-

Nous pouvons ensuite traduire tout cela en 3 tableaux parallèles

-
const positions = [];
-const normals = [];
-const uvs = [];
-for (const vertex of vertices) {
-  positions.push(...vertex.pos);
-  normals.push(...vertex.norm);
-  uvs.push(...vertex.uv);
-}
-
-

Enfin, nous pouvons créer une BufferGeometry, puis une BufferAttribute pour chaque tableau -et l'ajouter à la BufferGeometry.

-
  const geometry = new THREE.BufferGeometry();
-  const positionNumComponents = 3;
-  const normalNumComponents = 3;
-  const uvNumComponents = 2;
-  geometry.setAttribute(
-      'position',
-      new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
-  geometry.setAttribute(
-      'normal',
-      new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
-  geometry.setAttribute(
-      'uv',
-      new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
-
-

Notez que les noms sont importants. Vous devez nommer vos attributs avec les noms -que three.js attend (sauf si vous créez un shader personnalisé). -Dans ce cas : position, normal, et uv. Si vous voulez des couleurs de sommet, -nommez votre attribut color.

-

Ci-dessus, nous avons créé 3 tableaux natifs JavaScript, positions, normals et uvs. -Nous les avons ensuite convertis en -TypedArrays -de type Float32Array. Une BufferAttribute nécessite un TypedArray, pas un tableau natif. -Une BufferAttribute exige également que vous lui indiquiez combien de composants il y a -par sommet. Pour les positions et les normales, nous avons 3 composants par sommet, -x, y et z. Pour les UV, nous en avons 2, u et v.

-

- -

-

C'est beaucoup de données. Une petite chose que nous pouvons faire est d'utiliser des indices pour référencer -les sommets. En revenant à nos données de cube, chaque face est composée de 2 triangles -avec 3 sommets chacun, soit 6 sommets au total, mais 2 de ces sommets sont exactement les mêmes ; -La même position, la même normale et la même uv. -Nous pouvons donc supprimer les sommets correspondants et les -référencer par index. Nous commençons par supprimer les sommets correspondants.

-
const vertices = [
-  // avant
-  { pos: [-1, -1,  1], norm: [ 0,  0,  1], uv: [0, 0], }, // 0
-  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], }, // 1
-  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], }, // 2
--
--  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
--  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 0,  0,  1], uv: [1, 1], }, // 3
-  // droite
-  { pos: [ 1, -1,  1], norm: [ 1,  0,  0], uv: [0, 0], }, // 4
-  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], }, // 5
--
--  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
--  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], }, // 6
-  { pos: [ 1,  1, -1], norm: [ 1,  0,  0], uv: [1, 1], }, // 7
-  // arrière
-  { pos: [ 1, -1, -1], norm: [ 0,  0, -1], uv: [0, 0], }, // 8
-  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], }, // 9
--
--  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
--  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
-  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], }, // 10
-  { pos: [-1,  1, -1], norm: [ 0,  0, -1], uv: [1, 1], }, // 11
-  // gauche
-  { pos: [-1, -1, -1], norm: [-1,  0,  0], uv: [0, 0], }, // 12
-  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], }, // 13
--
--  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
--  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
-  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], }, // 14
-  { pos: [-1,  1,  1], norm: [-1,  0,  0], uv: [1, 1], }, // 15
-  // haut
-  { pos: [ 1,  1, -1], norm: [ 0,  1,  0], uv: [0, 0], }, // 16
-  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], }, // 17
--
--  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
--  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], }, // 18
-  { pos: [-1,  1,  1], norm: [ 0,  1,  0], uv: [1, 1], }, // 19
-  // bas
-  { pos: [ 1, -1,  1], norm: [ 0, -1,  0], uv: [0, 0], }, // 20
-  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], }, // 21
--
--  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
--  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
-  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], }, // 22
-  { pos: [-1, -1, -1], norm: [ 0, -1,  0], uv: [1, 1], }, // 23
-];
-
-

Nous avons donc maintenant 24 sommets uniques. Ensuite, nous spécifions 36 indices -pour les 36 sommets que nous devons dessiner pour faire 12 triangles en appelant BufferGeometry.setIndex avec un tableau d'indices.

-
geometry.setAttribute(
-    'position',
-    new THREE.BufferAttribute(positions, positionNumComponents));
-geometry.setAttribute(
-    'normal',
-    new THREE.BufferAttribute(normals, normalNumComponents));
-geometry.setAttribute(
-    'uv',
-    new THREE.BufferAttribute(uvs, uvNumComponents));
-
-+geometry.setIndex([
-+   0,  1,  2,   2,  1,  3,  // avant
-+   4,  5,  6,   6,  5,  7,  // droite
-+   8,  9, 10,  10,  9, 11,  // arrière
-+  12, 13, 14,  14, 13, 15,  // gauche
-+  16, 17, 18,  18, 17, 19,  // haut
-+  20, 21, 22,  22, 21, 23,  // bas
-+]);
-
-

- -

-

BufferGeometry a une méthode computeVertexNormals pour calculer les normales si vous -ne les fournissez pas. Malheureusement, -étant donné que les positions ne peuvent pas être partagées si une autre partie d'un sommet est différente, -les résultats de l'appel à computeVertexNormals généreront des coutures si votre -géométrie est censée se connecter à elle-même comme une sphère ou un cylindre.

-
-
-
-
-
- -

Pour le cylindre ci-dessus, les normales ont été créées à l'aide de computeVertexNormals. -Si vous regardez attentivement, il y a une couture sur le cylindre. Cela est dû au fait qu'il -n'y a aucun moyen de partager les sommets au début et à la fin du cylindre, car ils -nécessitent des UV différents, de sorte que la fonction pour les calculer n'a aucune idée qu'il -s'agit des mêmes sommets à lisser. Juste une petite chose dont il faut être conscient. -La solution est de fournir vos propres normales.

-

Nous pouvons également utiliser des TypedArrays dès le début au lieu de tableaux JavaScript natifs. -L'inconvénient des TypedArrays est que vous devez spécifier leur taille à l'avance. Bien sûr, -ce n'est pas une si grande contrainte, mais avec les tableaux natifs, nous pouvons simplement -push des valeurs et voir la taille finale en vérifiant leur -length à la fin. Avec les TypedArrays, il n'y a pas de fonction push, nous devons donc -faire notre propre gestion des données lorsque nous y ajoutons des valeurs.

-

Dans cet exemple, connaître la longueur à l'avance est assez facile, car nous utilisons -un grand bloc de données statiques pour commencer.

-
-const positions = [];
--const normals = [];
--const uvs = [];
-+const numVertices = vertices.length;
-+const positionNumComponents = 3;
-+const normalNumComponents = 3;
-+const uvNumComponents = 2;
-+const positions = new Float32Array(numVertices * positionNumComponents);
-+const normals = new Float32Array(numVertices * normalNumComponents);
-+const uvs = new Float32Array(numVertices * uvNumComponents);
-+let posNdx = 0;
-+let nrmNdx = 0;
-+let uvNdx = 0;
-for (const vertex of vertices) {
--  positions.push(...vertex.pos);
--  normals.push(...vertex.norm);
--  uvs.push(...vertex.uv);
-+  positions.set(vertex.pos, posNdx);
-+  normals.set(vertex.norm, nrmNdx);
-+  uvs.set(vertex.uv, uvNdx);
-+  posNdx += positionNumComponents;
-+  nrmNdx += normalNumComponents;
-+  uvNdx += uvNumComponents;
-}
-
-geometry.setAttribute(
-    'position',
--    new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
-+    new THREE.BufferAttribute(positions, positionNumComponents));
-geometry.setAttribute(
-    'normal',
--    new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
-+    new THREE.BufferAttribute(normals, normalNumComponents));
-geometry.setAttribute(
-    'uv',
--    new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
-+    new THREE.BufferAttribute(uvs, uvNumComponents));
-
-geometry.setIndex([
-   0,  1,  2,   2,  1,  3,  // avant
-   4,  5,  6,   6,  5,  7,  // droite
-   8,  9, 10,  10,  9, 11,  // arrière
-  12, 13, 14,  14, 13, 15,  // gauche
-  16, 17, 18,  18, 17, 19,  // haut
-  20, 21, 22,  22, 21, 23,  // bas
-]);
-
-

- -

-

Une bonne raison d'utiliser des typedarrays est si vous souhaitez mettre à jour dynamiquement une partie -des sommets.

-

Je n'ai pas pu trouver un très bon exemple de mise à jour dynamique des sommets, alors j'ai décidé de faire une sphère et de déplacer chaque quadricône vers l'intérieur et l'extérieur depuis le centre. J'espère que c'est un exemple utile.

-

Voici le code pour générer les positions et les indices d'une sphère. Le code -partage les sommets au sein d'un quadricône, mais ne partage pas les sommets entre -les quadricônes, car nous voulons pouvoir déplacer chaque quadricône séparément.

-

Comme je suis paresseux, j'ai utilisé une petite hiérarchie de 3 objets Object3D pour calculer -les points de la sphère. La façon dont cela fonctionne est expliquée dans l'article sur l'optimisation de nombreux objets.

-
function makeSpherePositions(segmentsAround, segmentsDown) {
-  const numVertices = segmentsAround * segmentsDown * 6;
-  const numComponents = 3;
-  const positions = new Float32Array(numVertices * numComponents);
-  const indices = [];
-
-  const longHelper = new THREE.Object3D();
-  const latHelper = new THREE.Object3D();
-  const pointHelper = new THREE.Object3D();
-  longHelper.add(latHelper);
-  latHelper.add(pointHelper);
-  pointHelper.position.z = 1;
-  const temp = new THREE.Vector3();
-
-  function getPoint(lat, long) {
-    latHelper.rotation.x = lat;
-    longHelper.rotation.y = long;
-    longHelper.updateMatrixWorld(true);
-    return pointHelper.getWorldPosition(temp).toArray();
-  }
-
-  let posNdx = 0;
-  let ndx = 0;
-  for (let down = 0; down < segmentsDown; ++down) {
-    const v0 = down / segmentsDown;
-    const v1 = (down + 1) / segmentsDown;
-    const lat0 = (v0 - 0.5) * Math.PI;
-    const lat1 = (v1 - 0.5) * Math.PI;
-
-    for (let across = 0; across < segmentsAround; ++across) {
-      const u0 = across / segmentsAround;
-      const u1 = (across + 1) / segmentsAround;
-      const long0 = u0 * Math.PI * 2;
-      const long1 = u1 * Math.PI * 2;
-
-      positions.set(getPoint(lat0, long0), posNdx);  posNdx += numComponents;
-      positions.set(getPoint(lat1, long0), posNdx);  posNdx += numComponents;
-      positions.set(getPoint(lat0, long1), posNdx);  posNdx += numComponents;
-      positions.set(getPoint(lat1, long1), posNdx);  posNdx += numComponents;
-
-      indices.push(
-        ndx, ndx + 1, ndx + 2,
-        ndx + 2, ndx + 1, ndx + 3,
-      );
-      ndx += 4;
-    }
-  }
-  return {positions, indices};
-}
-
-

Nous pouvons ensuite l'appeler comme ceci

-
const segmentsAround = 24;
-const segmentsDown = 16;
-const {positions, indices} = makeSpherePositions(segmentsAround, segmentsDown);
-
-

Comme les positions retournées sont des positions de sphère unitaire, ce sont exactement les mêmes -valeurs dont nous avons besoin pour les normales, nous pouvons donc simplement les dupliquer pour les normales.

-
const normals = positions.slice();
-
-

Et ensuite nous configurons les attributs comme auparavant

-
const geometry = new THREE.BufferGeometry();
-const positionNumComponents = 3;
-const normalNumComponents = 3;
-
-+const positionAttribute = new THREE.BufferAttribute(positions, positionNumComponents);
-+positionAttribute.setUsage(THREE.DynamicDrawUsage);
-geometry.setAttribute(
-    'position',
-+    positionAttribute);
-geometry.setAttribute(
-    'normal',
-    new THREE.BufferAttribute(normals, normalNumComponents));
-geometry.setIndex(indices);
-
-

J'ai mis en évidence quelques différences. Nous sauvegardons une référence à l'attribut de position. -Nous le marquons également comme dynamique. C'est une indication pour THREE.js que nous allons modifier -souvent le contenu de l'attribut.

-

Dans notre boucle de rendu, nous mettons à jour les positions en fonction de leurs normales à chaque image.

-
const temp = new THREE.Vector3();
-
-...
-
-for (let i = 0; i < positions.length; i += 3) {
-  const quad = (i / 12 | 0);
-  const ringId = quad / segmentsAround | 0;
-  const ringQuadId = quad % segmentsAround;
-  const ringU = ringQuadId / segmentsAround;
-  const angle = ringU * Math.PI * 2;
-  temp.fromArray(normals, i);
-  temp.multiplyScalar(THREE.MathUtils.lerp(1, 1.4, Math.sin(time + ringId + angle) * .5 + .5));
-  temp.toArray(positions, i);
-}
-positionAttribute.needsUpdate = true;
-
-

Et nous réglons positionAttribute.needsUpdate à true pour dire à THREE.js d'utiliser nos changements.

-

- -

-

J'espère que ces exemples vous ont été utiles pour comprendre comment utiliser BufferGeometry directement pour -créer votre propre géométrie et comment mettre à jour dynamiquement le contenu d'un -BufferAttribute.

- -

-

- - - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/debugging-glsl.html b/manual/fr/debugging-glsl.html deleted file mode 100644 index 59320839eec095..00000000000000 --- a/manual/fr/debugging-glsl.html +++ /dev/null @@ -1,78 +0,0 @@ - - - Débogage - GLSL - - - - - - - - - - - - - -
-
-

Débogage - GLSL

-
-
-
-

Ce site ne vous enseigne pas le GLSL, tout comme il ne vous enseigne pas le JavaScript. Ce sont des sujets très vastes. Si vous souhaitez apprendre le GLSL, consultez -ces articles comme point de départ.

-

Si vous connaissez déjà le GLSL, voici quelques conseils pour le débogage.

-

Lorsque je crée un nouveau shader GLSL et que rien n'apparaît, la première chose que je fais généralement est de modifier le shader de fragment pour qu'il renvoie une couleur unie. Par exemple, tout en bas du shader, je pourrais mettre

-
void main() {
-
-  ...
-
-  gl_FragColor = vec4(1, 0, 0, 1);  // red
-}
-
-

Si je vois l'objet que j'essayais de dessiner, je sais que le problème est lié à mon shader de fragment. Cela peut être n'importe quoi, comme de mauvaises textures, des uniforms non initialisés, des uniforms avec les mauvaises valeurs, mais au moins j'ai une direction où chercher.

-

Pour tester certains de ces points, je pourrais commencer par essayer de dessiner certaines des entrées. Par exemple, si j'utilise des normales dans le shader de fragment, je pourrais ajouter

-
gl_FragColor = vec4(vNormal * 0.5 + 0.5, 1);
-
-

Les normales vont de -1 à +1, donc en multipliant par 0,5 et en ajoutant 0,5, nous obtenons des valeurs qui vont de 0,0 à 1,0, ce qui les rend utiles pour les couleurs.

-

Essayez ceci avec des choses dont vous savez qu'elles fonctionnent et vous commencerez à avoir une idée de ce à quoi ressemblent les normales normalement. Si vos normales ne semblent pas normales, vous avez une piste où chercher. Si vous manipulez les normales dans le shader de fragment, vous pouvez utiliser la même technique pour dessiner le résultat de cette manipulation.

-
- -

De même, si nous utilisons des textures, il y aura des coordonnées de texture et nous pouvons les dessiner avec quelque chose comme

-
gl_FragColor = vec4(fract(vUv), 0, 1);
-
-

Le fract est là au cas où nous utiliserions des coordonnées de texture qui dépassent la plage 0 à 1. C'est courant si texture.repeat est défini sur une valeur supérieure à 1.

-
- -

Vous pouvez faire des choses similaires pour toutes les valeurs de votre shader de fragment. Déterminez quelle est leur plage probable, ajoutez du code pour définir gl_FragColor avec cette plage mise à l'échelle de 0,0 à 1,0

-

Pour vérifier les textures, essayez un CanvasTexture ou un DataTexture dont vous savez qu'il fonctionne.

-

Inversement, si après avoir défini gl_FragColor sur rouge je ne vois toujours rien, alors j'ai une indication que mon problème pourrait être lié aux choses relatives au shader de vertex. Certaines matrices pourraient être incorrectes ou mes attributs pourraient contenir de mauvaises données ou être mal configurés.

-

Je regarderais d'abord les matrices. Je pourrais mettre un point d'arrêt juste après mon appel à renderer.render(scene, camera) et commencer ensuite à développer les éléments dans l'inspecteur. La matrice monde et la matrice de projection de la caméra ne sont-elles pas au moins pleines de NaNs ? En développant la scène et en regardant ses children, je vérifierais que les matrices monde semblent raisonnables (pas de NaNs) et que les 4 dernières valeurs de chaque matrice semblent raisonnables pour ma scène. Si j'attends que ma scène fasse 50x50x50 unités et qu'une matrice affiche 552352623.123, il est clair que quelque chose ne va pas.

-
- -

Tout comme nous l'avons fait pour le shader de fragment, nous pouvons également dessiner les valeurs du shader de vertex en les passant au shader de fragment. Déclarez une variable varying dans les deux et passez la valeur dont vous n'êtes pas sûr qu'elle soit correcte. En fait, si mon shader utilise des normales, je modifierai le shader de fragment pour les afficher comme mentionné ci-dessus, puis je définirai simplement vNormal à la valeur que je souhaite afficher, mais mise à l'échelle de sorte que les valeurs aillent de 0,0 à 1,0. Je regarde ensuite les résultats et vois s'ils correspondent à mes attentes.

-

Une autre bonne chose à faire est d'utiliser un shader plus simple. Pouvez-vous dessiner vos données avec un MeshBasicMaterial ? Si oui, essayez-le et assurez-vous qu'il s'affiche comme prévu.

-

Sinon, quel est le shader de vertex le plus simple qui vous permettra de visualiser votre géométrie ? Généralement, c'est aussi simple que

-
gl_Position = projection * modelView * vec4(position.xyz, 1);
-
-

Si cela fonctionne, commencez à ajouter vos modifications petit à petit.

-

Une autre chose que vous pouvez faire est d'utiliser l'extension Shader Editor pour Chrome ou similaire pour d'autres navigateurs. C'est un excellent moyen de voir comment fonctionnent d'autres shaders. C'est aussi pratique car vous pouvez apporter certaines des modifications suggérées ci-dessus en direct pendant que le code s'exécute.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/debugging-javascript.html b/manual/fr/debugging-javascript.html deleted file mode 100644 index 48f5aa6913f506..00000000000000 --- a/manual/fr/debugging-javascript.html +++ /dev/null @@ -1,373 +0,0 @@ - - - Débogage JavaScript - - - - - - - - - - - - - -
-
-

Débogage JavaScript

-
-
-
-

La majeure partie de cet article ne concerne pas directement THREE.js, mais plutôt le débogage de JavaScript en général. Cela semblait important car de nombreuses personnes qui débutent avec THREE.js commencent également avec JavaScript, j'espère donc que cela pourra les aider à résoudre plus facilement les problèmes qu'ils rencontrent.

-

Le débogage est un vaste sujet et je ne peux probablement pas couvrir tout ce qu'il y a à savoir, mais si vous débutez en JavaScript, voici quelques pistes. Je vous suggère fortement de prendre le temps de les apprendre. Elles vous aideront énormément dans votre apprentissage.

-

Apprenez à utiliser les outils de développement de votre navigateur

-

Tous les navigateurs disposent d'outils de développement. -Chrome, -Firefox, -Safari, -Edge.

-

Dans Chrome, vous pouvez cliquer sur l'icône , choisir Plus d'outils -> Outils de développement pour accéder aux outils de développement. Un raccourci clavier est également affiché.

-
- -

Dans Firefox, vous cliquez sur l'icône , choisissez "Développeur Web", puis "Activer/Désactiver les outils"

-
- -

Dans Safari, vous devez d'abord activer le menu Développement depuis les Préférences avancées de Safari.

-
- -

Puis, dans le menu Développement, vous pouvez choisir "Afficher/Connecter l'inspecteur web".

-
- -

Avec Chrome, vous pouvez également utiliser Chrome sur votre ordinateur pour déboguer des pages web s'exécutant dans Chrome sur votre téléphone ou tablette Android. De même avec Safari, vous pouvez -utiliser votre ordinateur pour déboguer des pages web s'exécutant dans Safari sur des iPhones et iPads.

-

Je suis plus familier avec Chrome, donc ce guide utilisera Chrome comme exemple pour faire référence aux outils, mais la plupart des navigateurs ont des fonctionnalités similaires, il devrait donc être facile d'appliquer ce qui est dit ici à tous les navigateurs.

-

Désactiver le cache

-

Les navigateurs essaient de réutiliser les données qu'ils ont déjà téléchargées. C'est excellent pour les utilisateurs, car si vous visitez un site web une deuxième fois, de nombreux fichiers utilisés pour afficher le site n'auront pas à être téléchargés à nouveau.

-

D'autre part, cela peut être problématique pour le développement web. Vous modifiez un fichier sur votre ordinateur, rechargez la page, et vous ne voyez pas les changements car le navigateur utilise la version qu'il a obtenue la dernière fois.

-

Une solution pendant le développement web est de désactiver le cache. Ainsi, le navigateur obtiendra toujours les versions les plus récentes de vos fichiers.

-

Choisissez d'abord les paramètres dans le menu du coin

-
- -

Puis choisissez "Désactiver le cache (lorsque les outils de développement sont ouverts)".

-
- -

Utiliser la console JavaScript

-

Dans tous les outils de développement se trouve une console. Elle affiche les avertissements et les messages d'erreur.

-

LISEZ LES MESSAGES !!

-

Typiquement, il ne devrait y avoir que 1 ou 2 messages.

-
- -

Si vous en voyez d'autres, LISEZ-LES. Par exemple :

-
- -

J'ai mal orthographié "three" en "threee"

-

Vous pouvez également afficher vos propres informations dans la console avec console.log, comme ceci :

-
console.log(someObject.position.x, someObject.position.y, someObject.position.z);
-
-

Encore mieux, si vous affichez un objet, vous pouvez l'inspecter. Par exemple, si nous affichons l'objet scène racine de l'article sur les gLTF

-
  {
-    const gltfLoader = new GLTFLoader();
-    gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
-      const root = gltf.scene;
-      scene.add(root);
-+      console.log(root);
-
-

Nous pouvons ensuite développer cet objet dans la console JavaScript

-
- -

Vous pouvez également utiliser console.error qui rapporte le message en rouge et inclut une trace de pile.

-

Afficher des données à l'écran

-

Une autre méthode évidente mais souvent négligée est d'ajouter des balises <div> ou <pre> et d'y insérer des données.

-

La manière la plus évidente est de créer des éléments HTML

-
<canvas id="c"></canvas>
-+<div id="debug">
-+  <div>x:<span id="x"></span></div>
-+  <div>y:<span id="y"></span></div>
-+  <div>z:<span id="z"></span></div>
-+</div>
-
-

Stylez-les pour qu'ils restent au-dessus du canevas. (en supposant que votre canevas remplisse la page)

-
<style>
-#debug {
-  position: absolute;
-  left: 1em;
-  top: 1em;
-  padding: 1em;
-  background: rgba(0, 0, 0, 0.8);
-  color: white;
-  font-family: monospace;
-}
-</style>
-
-

Puis recherchez les éléments et définissez leur contenu.

-
// au moment de l'initialisation
-const xElem = document.querySelector('#x');
-const yElem = document.querySelector('#y');
-const zElem = document.querySelector('#z');
-
-// au moment du rendu ou de la mise à jour
-xElem.textContent = someObject.position.x.toFixed(3);
-yElem.textContent = someObject.position.y.toFixed(3);
-zElem.textContent = someObject.position.z.toFixed(3);
-
-

C'est plus utile pour les valeurs en temps réel

-

- -

-

Une autre façon d'afficher des données à l'écran est de créer un logger à effacement. Je viens d'inventer ce terme, mais de nombreux jeux sur lesquels j'ai travaillé ont utilisé cette solution. L'idée est d'avoir un tampon qui affiche des messages pour une seule image. Toute partie de votre code qui souhaite afficher des données appelle une fonction pour ajouter des données à ce tampon à chaque image. C'est beaucoup moins de travail que de créer un élément par donnée comme ci-dessus.

-

Par exemple, modifions le HTML ci-dessus pour qu'il soit juste ceci :

-
<canvas id="c"></canvas>
-<div id="debug">
-  <pre></pre>
-</div>
-
-

Et créons une classe simple pour gérer ce tampon d'effacement arrière.

-
class ClearingLogger {
-  constructor(elem) {
-    this.elem = elem;
-    this.lines = [];
-  }
-  log(...args) {
-    this.lines.push([...args].join(' '));
-  }
-  render() {
-    this.elem.textContent = this.lines.join('\n');
-    this.lines = [];
-  }
-}
-
-

Ensuite, créons un exemple simple qui, chaque fois que nous cliquons avec la souris, crée un maillage qui se déplace dans une direction aléatoire pendant 2 secondes. Nous commencerons avec l'un des exemples de l'article sur rendre les choses réactives

-

Voici le code qui ajoute un nouveau Mesh chaque fois que nous cliquons avec la souris

-
const geometry = new THREE.SphereGeometry();
-const material = new THREE.MeshBasicMaterial({color: 'red'});
-
-const things = [];
-
-function rand(min, max) {
-  if (max === undefined) {
-    max = min;
-    min = 0;
-  }
-  return Math.random() * (max - min) + min;
-}
-
-function createThing() {
-  const mesh = new THREE.Mesh(geometry, material);
-  scene.add(mesh);
-  things.push({
-    mesh,
-    timer: 2,
-    velocity: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5)),
-  });
-}
-
-canvas.addEventListener('click', createThing);
-
-

Et voici le code qui déplace les maillages que nous avons créés, les enregistre, et les supprime lorsque leur minuterie est écoulée

-
const logger = new ClearingLogger(document.querySelector('#debug pre'));
-
-let then = 0;
-function render(now) {
-  now *= 0.001;  // convertir en secondes
-  const deltaTime = now - then;
-  then = now;
-
-  ...
-
-  logger.log('fps:', (1 / deltaTime).toFixed(1));
-  logger.log('num things:', things.length);
-  for (let i = 0; i < things.length;) {
-    const thing = things[i];
-    const mesh = thing.mesh;
-    const pos = mesh.position;
-    logger.log(
-        'timer:', thing.timer.toFixed(3),
-        'pos:', pos.x.toFixed(3), pos.y.toFixed(3), pos.z.toFixed(3));
-    thing.timer -= deltaTime;
-    if (thing.timer <= 0) {
-      // supprimer cet élément. Notez que nous n'avançons pas `i`
-      things.splice(i, 1);
-      scene.remove(mesh);
-    } else {
-      mesh.position.addScaledVector(thing.velocity, deltaTime);
-      ++i;
-    }
-  }
-
-  renderer.render(scene, camera);
-  logger.render();
-
-  requestAnimationFrame(render);
-}
-
-

Cliquez maintenant plusieurs fois avec la souris dans l'exemple ci-dessous

-

- -

-

Paramètres de requête

-

Une autre chose à retenir est que les pages web peuvent se voir passer des données soit via des paramètres de requête, soit via l'ancre, parfois appelée la recherche et le hachage.

-
https://domain/path/?query#anchor
-

Vous pouvez l'utiliser pour rendre des fonctionnalités optionnelles ou passer des paramètres.

-

Par exemple, prenons l'exemple précédent et faisons en sorte que les informations de débogage n'apparaissent que si nous mettons ?debug=true dans l'URL.

-

Nous avons d'abord besoin de code pour analyser la chaîne de requête

-
/**
-  * Renvoie les paramètres de requête sous forme d'objet clé/valeur.
-  * Exemple : Si les paramètres de requête sont
-  *
-  *    abc=123&def=456&name=gman
-  *
-  * Alors `getQuery()` renverra un objet comme
-  *
-  *    {
-  *      abc: '123',
-  *      def: '456',
-  *      name: 'gman',
-  *    }
-  */
-function getQuery() {
-  return Object.fromEntries(new URLSearchParams(window.location.search).entries());
-}
-
-

Nous pourrions ensuite faire en sorte que l'élément de débogage ne s'affiche pas par défaut

-
<canvas id="c"></canvas>
-+<div id="debug" style="display: none;">
-  <pre></pre>
-</div>
-
-

Ensuite, dans le code, nous lisons les paramètres et choisissons de rendre visible les informations de débogage si et seulement si ?debug=true est passé en paramètre

-
const query = getQuery();
-const debug = query.debug === 'true';
-const logger = debug
-   ? new ClearingLogger(document.querySelector('#debug pre'))
-   : new DummyLogger();
-if (debug) {
-  document.querySelector('#debug').style.display = '';
-}
-
-

Nous avons également créé un DummyLogger qui ne fait rien et avons choisi de l'utiliser si ?debug=true n'a pas été passé en paramètre.

-
class DummyLogger {
-  log() {}
-  render() {}
-}
-
-

Vous pouvez voir si nous utilisons cette URL :

-

debug-js-params.html

-

il n'y a pas d'informations de débogage, mais si nous utilisons cette URL :

-

debug-js-params.html?debug=true

-

il y a des informations de débogage.

-

Plusieurs paramètres peuvent être passés en les séparant par '&', comme dans somepage.html?someparam=somevalue&someotherparam=someothervalue. En utilisant des paramètres comme ceci, nous pouvons passer toutes sortes d'options. Peut-être speed=0.01 pour ralentir notre application afin de faciliter la compréhension de quelque chose, ou showHelpers=true pour indiquer s'il faut ajouter des helpers qui affichent les lumières, les ombres ou le frustum de la caméra vus dans d'autres leçons.

-

Apprenez à utiliser le Débogueur

-

Chaque navigateur dispose d'un débogueur où vous pouvez mettre votre programme en pause, l'exécuter pas à pas ligne par ligne et inspecter toutes les variables.

-

Vous apprendre à utiliser un débogueur est un sujet trop vaste pour cet article, mais voici quelques liens

- -

Vérifiez la présence de NaN dans le débogueur ou ailleurs

-

NaN est l'abréviation de Not A Number (Pas un Nombre). C'est la valeur que JavaScript attribuera lorsque vous faites quelque chose qui n'a pas de sens mathématique.

-

Voici un exemple simple :

-
- -

Souvent, quand je crée quelque chose et que rien n'apparaît à l'écran, je vérifie certaines valeurs et si je vois NaN, j'ai instantanément un point de départ pour chercher.

-

À titre d'exemple, lorsque j'ai commencé à créer le chemin pour l'article sur le chargement de fichiers gLTF, j'ai créé une courbe en utilisant la classe SplineCurve qui crée une courbe 2D.

-

J'ai ensuite utilisé cette courbe pour déplacer les voitures comme ceci :

-
curve.getPointAt(zeroToOnePointOnCurve, car.position);
-
-

En interne, curve.getPointAt appelle la fonction set sur l'objet passé comme deuxième argument. Dans ce cas, ce deuxième argument est car.position qui est un Vector3. La fonction set de Vector3 nécessite 3 arguments (x, y et z) mais SplineCurve est une courbe 2D, elle appelle donc car.position.set avec juste x et y.

-

Le résultat est que car.position.set définit x sur x, y sur y, et z sur undefined.

-

Un rapide coup d'œil dans le débogueur sur la matrixWorld de la voiture a montré un tas de valeurs NaN.

-
- -

Voir que la matrice contenait des NaN suggérait que quelque chose comme position, rotation, scale ou une autre fonction qui affecte cette matrice avait de mauvaises données. En remontant à partir de là, il a été facile de trouver le problème.

-

En plus de NaN, il y a aussi Infinity qui est un signe similaire qu'il y a un bug mathématique quelque part.

-

Regardez le code !

-

THREE.js est Open Source. N'ayez pas peur de regarder le code ! Vous pouvez regarder à l'intérieur sur github. Vous pouvez également regarder à l'intérieur en entrant dans les fonctions du débogueur.

-

Mettez requestAnimationFrame en bas de votre fonction de rendu.

-

Je vois souvent ce schéma

-
function render() {
-   requestAnimationFrame(render);
-
-   // -- faire des choses --
-
-   renderer.render(scene, camera);
-}
-requestAnimationFrame(render);
-
-

Je suggérerais de mettre l'appel à requestAnimationFrame en bas, comme ceci :

-
function render() {
-   // -- faire des choses --
-
-   renderer.render(scene, camera);
-
-   requestAnimationFrame(render);
-}
-requestAnimationFrame(render);
-
-

La raison principale est que cela signifie que votre code s'arrêtera si vous avez une erreur. Mettre requestAnimationFrame en haut signifie que votre code continuera de s'exécuter même si vous avez une erreur puisque vous avez déjà demandé une autre image. À mon avis, il vaut mieux trouver ces erreurs que les ignorer. Elles pourraient facilement être la raison pour laquelle quelque chose n'apparaît pas comme vous l'attendez, mais à moins que votre code ne s'arrête, vous pourriez même ne pas le remarquer.

-

Vérifiez vos unités !

-

Cela signifie essentiellement savoir, par exemple, quand utiliser des degrés plutôt que des radians. Il est regrettable que THREE.js n'utilise pas uniformément les mêmes unités partout. De mémoire, le champ de vision de la caméra est en degrés. Tous les autres angles sont en radians.

-

L'autre point à surveiller est la taille de vos unités mondiales. Jusqu'à récemment, les applications 3D pouvaient choisir la taille d'unité qu'elles voulaient. Une application pouvait choisir 1 unité = 1 cm. Une autre pouvait choisir 1 unité = 1 pied. Il est toujours vrai que vous pouvez choisir les unités que vous voulez pour certaines applications. Cela dit, THREE.js suppose 1 unité = 1 mètre. C'est important pour des choses comme le rendu basé sur la physique qui utilise des mètres pour calculer les effets d'éclairage. C'est également important pour la RA et la RV qui doivent gérer des unités du monde réel, comme l'emplacement de votre téléphone ou des contrôleurs VR.

-

Créer un Exemple Minimal, Complet et Vérifiable pour Stack Overflow

-

Si vous décidez de poser une question sur THREE.js, il est presque toujours requis de fournir un MCVE, qui signifie Exemple Minimal, Complet et Vérifiable.

-

La partie Minimale est importante. Disons que vous avez un problème avec le mouvement le long du chemin dans le dernier exemple de l'article sur le chargement de gLTF. Cet exemple contient de nombreuses parties. En les listant, il y a :

-
    -
  1. Beaucoup de HTML
  2. -
  3. Du CSS
  4. -
  5. Lumières
  6. -
  7. Ombres
  8. -
  9. Code lil-gui pour manipuler les ombres
  10. -
  11. Code pour charger un fichier .GLTF
  12. -
  13. Code pour redimensionner le canevas.
  14. -
  15. Code pour déplacer les voitures le long des chemins
  16. -
-

C'est assez énorme. Si votre question ne concerne que la partie suivant le chemin, vous pouvez retirer la majeure partie du HTML car vous n'avez besoin que d'une balise <canvas> et d'une balise <script> pour THREE.js. Vous pouvez retirer le code CSS et le code de redimensionnement. Vous pouvez retirer le code .GLTF car vous ne vous souciez que du chemin. Vous pouvez retirer les lumières et les ombres en utilisant un MeshBasicMaterial. Vous pouvez certainement retirer le code lil-gui. Le code crée un plan de sol avec une texture. Il serait plus simple d'utiliser un GridHelper. Enfin, si notre question concerne le déplacement d'objets sur un chemin, nous pourrions simplement utiliser des cubes sur le chemin au lieu de modèles de voitures chargés.

-

Voici un exemple plus minimal prenant en compte tout ce qui précède. Il est passé de 271 lignes à 135. Nous pourrions envisager de le réduire encore plus en simplifiant notre chemin. Peut-être qu'un chemin avec 3 ou 4 points fonctionnerait aussi bien que notre chemin avec 21 points.

-

- -

-

J'ai gardé l'OrbitController simplement parce qu'il est utile pour que d'autres personnes puissent déplacer la caméra et comprendre ce qui se passe, mais en fonction de votre problème, vous pourriez également être en mesure de le supprimer.

-

La meilleure chose à propos de la création d'un MCVE est que nous résolvons souvent notre propre problème. Le processus consistant à supprimer tout ce qui n'est pas nécessaire et à créer le plus petit exemple possible reproduisant le problème nous conduit le plus souvent à notre bug.

-

De plus, c'est respectueux du temps de toutes les personnes à qui vous demandez de regarder votre code sur Stack Overflow. En créant l'exemple minimal, vous leur facilitez grandement la tâche de vous aider. Vous apprendrez également au cours du processus.

-

Il est également important, lorsque vous allez sur Stack Overflow pour poster votre question, de mettre votre code dans un extrait (snippet). Bien sûr, vous êtes libre d'utiliser JSFiddle ou Codepen ou un site similaire pour tester votre MCVE, mais une fois que vous postez réellement votre question sur Stack Overflow, vous êtes tenu de mettre le code pour reproduire votre problème dans la question elle-même. En créant un extrait, vous remplissez cette exigence.

-

Notez également que tous les exemples en direct sur ce site devraient fonctionner comme des extraits. Copiez simplement les parties HTML, CSS et JavaScript dans les sections respectives de l'éditeur d'extraits. N'oubliez pas d'essayer de retirer les parties qui ne sont pas pertinentes pour votre problème et d'essayer de rendre votre code le plus minimal possible.

-

Suivez ces suggestions et vous aurez beaucoup plus de chances d'obtenir de l'aide pour votre problème.

-

Utilisez un MeshBasicMaterial

-

Étant donné que le MeshBasicMaterial n'utilise pas de lumières, c'est un moyen d'éliminer les raisons pour lesquelles quelque chose pourrait ne pas s'afficher. Si vos objets s'affichent en utilisant le MeshBasicMaterial mais pas avec les matériaux que vous utilisiez, alors vous savez que le problème vient probablement des matériaux ou des lumières et non d'une autre partie du code.

-

Vérifiez les paramètres near et far de votre caméra

-

Une PerspectiveCamera a des paramètres near et far qui sont couverts dans l'article sur les caméras. Assurez-vous qu'ils sont définis pour correspondre à l'espace qui contient vos objets. Vous pourriez même les définir temporairement à quelque chose de grand comme near = 0.001 et far = 1000000. Vous rencontrerez probablement des problèmes de résolution de profondeur, mais vous pourrez au moins voir vos objets à condition qu'ils soient devant la caméra.

-

Vérifiez que votre scène est devant la caméra

-

Parfois, les choses n'apparaissent pas parce qu'elles ne sont pas devant la caméra. Si votre caméra n'est pas contrôlable, essayez d'ajouter un contrôle de caméra comme l'OrbitController afin de pouvoir regarder autour de vous et trouver votre scène. Ou, essayez de cadrer la scène en utilisant du code, comme décrit dans cet article. Ce code trouve la taille d'une partie de la scène, puis déplace la caméra et ajuste les paramètres near et far pour la rendre visible. Vous pouvez ensuite regarder dans le débogueur ou ajouter des messages console.log pour afficher la taille et le centre de la scène.

-

Mettez quelque chose devant la caméra

-

C'est juste une autre façon de dire que si tout le reste échoue, commencez par quelque chose qui fonctionne, puis ajoutez progressivement des éléments. Si vous obtenez un écran vide, essayez de mettre quelque chose directement devant la caméra. Créez une sphère ou une boîte, donnez-lui un matériau simple comme le MeshBasicMaterial et assurez-vous que vous pouvez l'afficher à l'écran. Puis commencez à ajouter des éléments petit à petit et à tester. Finalement, vous reproduirez votre bug ou vous le trouverez en chemin.

-
-

Voici quelques conseils pour le débogage de JavaScript. Passons également en revue quelques conseils pour le débogage de GLSL.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/drawing-lines.html b/manual/fr/drawing-lines.html deleted file mode 100644 index 076e61978da2eb..00000000000000 --- a/manual/fr/drawing-lines.html +++ /dev/null @@ -1,91 +0,0 @@ - - - Dessiner des lignes - - - - - - - - - - - - - -
-
-

Dessiner des lignes

-
-
-
- -

- Disons que vous voulez dessiner une ligne ou un cercle, pas un `Mesh` en fil de fer. - Nous devons d'abord configurer le renderer, la scène et la caméra (voir la page Créer une scène). -

- -

Voici le code que nous allons utiliser :

-
-const renderer = new THREE.WebGLRenderer();
-renderer.setSize( window.innerWidth, window.innerHeight );
-document.body.appendChild( renderer.domElement );
-
-const camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 1, 500 );
-camera.position.set( 0, 0, 100 );
-camera.lookAt( 0, 0, 0 );
-
-const scene = new THREE.Scene();
-
-

Next thing we will do is define a material. For lines we have to use `LineBasicMaterial` or `LineDashedMaterial`.

-
-//créer un LineBasicMaterial bleu
-const material = new THREE.LineBasicMaterial( { color: 0x0000ff } );
-
- -

- Après le material, nous aurons besoin d'une géométrie avec quelques sommets : -

- -
-const points = [];
-points.push( new THREE.Vector3( - 10, 0, 0 ) );
-points.push( new THREE.Vector3( 0, 10, 0 ) );
-points.push( new THREE.Vector3( 10, 0, 0 ) );
-
-const geometry = new THREE.BufferGeometry().setFromPoints( points );
-
- -

Notez que les lignes sont tracées entre chaque paire consécutive de sommets, mais pas entre le premier et le dernier (la ligne n'est pas fermée).

- -

Maintenant que nous avons des points pour deux lignes et un material, nous pouvons les assembler pour former une ligne.

-
-const line = new THREE.Line( geometry, material );
-
-

Tout ce qui reste est de l'ajouter à la scène et d'appeler `renderer.render()`.

- -
-scene.add( line );
-renderer.render( scene, camera );
-
- -

Vous devriez maintenant voir une flèche pointant vers le haut, faite de deux lignes bleues.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/faq.html b/manual/fr/faq.html deleted file mode 100644 index 7ea4d7bd2a954d..00000000000000 --- a/manual/fr/faq.html +++ /dev/null @@ -1,93 +0,0 @@ - - - Foire aux questions - - - - - - - - - - - - - -
-
-

Foire aux questions

-
-
-
- -

Quel format de modèle 3D est le mieux supporté ?

-
-

- Le format recommandé pour l'importation et l'exportation d'assets est glTF (GL Transmission Format). Comme glTF est axé sur la livraison d'assets au moment de l'exécution, il est compact à transmettre et rapide à charger. -

-

- three.js fournit également des chargeurs pour de nombreux autres formats populaires comme FBX, Collada ou OBJ. Néanmoins, vous devriez toujours essayer d'établir d'abord un workflow basé sur glTF dans vos projets. -

-
- -

Pourquoi y a-t-il des balises meta viewport dans les exemples ?

-
-
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
- -

Ces balises contrôlent la taille et l'échelle de la fenêtre d'affichage (viewport) pour les navigateurs mobiles (où le contenu de la page peut être rendu à une taille différente de la fenêtre d'affichage visible).

- -

[link:https://developer.apple.com/library/content/documentation/AppleApplications/Reference/SafariWebContent/UsingtheViewport/UsingtheViewport.html Safari : Utiliser la fenêtre d'affichage]

- -

[link:https://developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag MDN : Utiliser la balise meta viewport]

-
- -

Comment l'échelle de la scène peut-elle être préservée lors du redimensionnement ?

-

- Nous voulons que tous les objets, quelle que soit leur distance par rapport à la caméra, apparaissent de la même taille, même lorsque la fenêtre est redimensionnée. - - L'équation clé pour résoudre cela est cette formule pour la hauteur visible à une distance donnée : - -

visible_height = 2 * Math.tan( ( Math.PI / 180 ) * camera.fov / 2 ) * distance_from_camera;
- Si nous augmentons la hauteur de la fenêtre d'un certain pourcentage, alors ce que nous voulons, c'est que la hauteur visible à toutes les distances augmente du même pourcentage. - - Cela ne peut pas être fait en changeant la position de la caméra. Au lieu de cela, vous devez changer le champ de vision de la caméra. - [link:http://jsfiddle.net/Q4Jpu/ Exemple]. -

- -

Pourquoi une partie de mon objet est-elle invisible ?

-

- Cela pourrait être dû au culling des faces (face culling). Les faces ont une orientation qui décide quel côté est lequel. Et le culling supprime le côté arrière dans des circonstances normales. - Pour voir si c'est votre problème, changez le côté du matériau en THREE.DoubleSide. -

material.side = THREE.DoubleSide
-

- -

Pourquoi three.js renvoie-t-il parfois des résultats étranges pour des entrées invalides ?

-

- Pour des raisons de performance, three.js ne valide pas les entrées dans la plupart des cas. Il est de la responsabilité de votre application de s'assurer que toutes les entrées sont valides. -

- -

Puis-je utiliser three.js dans Node.js ?

-

- Parce que three.js est conçu pour le web, il dépend d'APIs de navigateur et du DOM qui n'existent pas toujours dans Node.js. Certains de ces problèmes peuvent être évités en utilisant des shims comme - [link:https://github.com/stackgl/headless-gl headless-gl] et [link:https://github.com/rstacruz/jsdom-global jsdom-global], ou en remplaçant des composants comme `TextureLoader` - par des alternatives personnalisées. D'autres APIs du DOM peuvent être profondément liées au code qui les utilise, et seront plus difficiles à contourner. Nous accueillons favorablement les pull requests simples et maintenables pour améliorer le support de Node.js, mais recommandons d'ouvrir d'abord un issue pour discuter de vos améliorations. -

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/fog.html b/manual/fr/fog.html deleted file mode 100644 index d37face92f2bf9..00000000000000 --- a/manual/fr/fog.html +++ /dev/null @@ -1,279 +0,0 @@ - - - Brouillard - - - - - - - - - - - - - -
-
-

Brouillard

-
-
-
-

Cet article fait partie d'une série d'articles sur three.js. Le -premier article est les fondamentaux de three.js. Si -vous ne l'avez pas encore lu et que vous débutez avec three.js, vous devriez peut-être -envisager de commencer par là. Si vous n'avez pas lu sur les caméras, vous voudrez peut-être -commencer par cet article.

-

Le brouillard dans un moteur 3D est généralement une façon de s'estomper vers une couleur spécifique -en fonction de la distance par rapport à la caméra. Dans three.js, vous ajoutez du brouillard en -créant un objet Fog ou FogExp2 et en le définissant sur la -propriété fog de la scène.

-

Fog vous permet de choisir les paramètres near et far qui sont des distances -par rapport à la caméra. Tout ce qui est plus proche que near n'est pas affecté par le brouillard. -Tout ce qui est plus loin que far prend complètement la couleur du brouillard. Les parties entre -near et far s'estompent de leur couleur de matériau à la couleur du brouillard.

-

Il y a aussi FogExp2 qui augmente de manière exponentielle avec la distance par rapport à la caméra.

-

Pour utiliser l'un ou l'autre type de brouillard, vous en créez un et l'attribuez à la scène comme dans

-
const scene = new THREE.Scene();
-{
-  const color = 0xFFFFFF;  // white
-  const near = 10;
-  const far = 100;
-  scene.fog = new THREE.Fog(color, near, far);
-}
-
-

ou pour FogExp2 ce serait

-
const scene = new THREE.Scene();
-{
-  const color = 0xFFFFFF;
-  const density = 0.1;
-  scene.fog = new THREE.FogExp2(color, density);
-}
-
-

FogExp2 est plus proche de la réalité mais Fog est utilisé -plus couramment car il vous permet de choisir un endroit où appliquer -le brouillard afin que vous puissiez décider d'afficher une scène -claire jusqu'à une certaine distance, puis de s'estomper vers une couleur -au-delà de cette distance.

-
-
-
-
THREE.Fog
-
-
-
-
THREE.FogExp2
-
-
- -

Il est important de noter que le brouillard est appliqué aux choses qui sont rendues. -Il fait partie du calcul de chaque pixel de la couleur de l'objet. -Cela signifie que si vous voulez que votre scène s'estompe vers une certaine couleur, vous -devez définir le brouillard et la couleur de fond sur la même couleur. -La couleur de fond est définie à l'aide de la -propriété scene.background. Pour choisir une couleur de fond, vous y attachez un THREE.Color. Par exemple

-
scene.background = new THREE.Color('#F00');  // rouge
-
-
-
-
-
brouillard bleu, fond rouge
-
-
-
-
brouillard bleu, fond bleu
-
-
- -

Voici l'un de nos exemples précédents avec l'ajout de brouillard. La seule addition -est juste après la mise en place de la scène, nous ajoutons le brouillard et définissons la couleur de fond de la scène

-
const scene = new THREE.Scene();
-
-+{
-+  const near = 1;
-+  const far = 2;
-+  const color = 'lightblue';
-+  scene.fog = new THREE.Fog(color, near, far);
-+  scene.background = new THREE.Color(color);
-+}
-
-

Dans l'exemple ci-dessous, near de la caméra est de 0.1 et far est de 5. -La caméra est à z = 2. Les cubes mesurent 1 unité de large et sont à Z = 0. -Cela signifie qu'avec un réglage de brouillard de near = 1 et far = 2, les cubes -s'estomperont juste autour de leur centre.

-

- -

-

Ajoutons une interface pour pouvoir ajuster le brouillard. Encore une fois, nous allons utiliser -lil-gui. lil-gui prend -un objet et une propriété et crée automatiquement une interface -pour ce type de propriété. Nous pourrions simplement le laisser manipuler -les propriétés near et far du brouillard, mais il est invalide d'avoir -near supérieur à far, alors créons un assistant pour que lil-gui -puisse manipuler une propriété near et far, mais nous nous assurerons que near -est inférieur ou égal à far et que far est supérieur ou égal à near.

-
// Nous utilisons cette classe pour la passer à lil-gui
-// ainsi quand elle manipule near ou far
-// near n'est jamais > far et far n'est jamais < near
-class FogGUIHelper {
-  constructor(fog) {
-    this.fog = fog;
-  }
-  get near() {
-    return this.fog.near;
-  }
-  set near(v) {
-    this.fog.near = v;
-    this.fog.far = Math.max(this.fog.far, v);
-  }
-  get far() {
-    return this.fog.far;
-  }
-  set far(v) {
-    this.fog.far = v;
-    this.fog.near = Math.min(this.fog.near, v);
-  }
-}
-
-

Nous pouvons ensuite l'ajouter comme ceci

-
{
-  const near = 1;
-  const far = 2;
-  const color = 'lightblue';
-  scene.fog = new THREE.Fog(color, near, far);
-  scene.background = new THREE.Color(color);
-+
-+  const fogGUIHelper = new FogGUIHelper(scene.fog);
-+  gui.add(fogGUIHelper, 'near', near, far).listen();
-+  gui.add(fogGUIHelper, 'far', near, far).listen();
-}
-
-

Les paramètres near et far définissent les valeurs minimum et maximum -pour l'ajustement du brouillard. Ils sont définis lorsque nous configurons la caméra.

-

Le .listen() à la fin des 2 dernières lignes indique à lil-gui d'écouter -les changements. De cette façon, lorsque nous changeons near à cause d'une modification de far -ou que nous changeons far en réponse à une modification de near, lil-gui mettra à jour -l'interface utilisateur de l'autre propriété pour nous.

-

Il pourrait également être agréable de pouvoir changer la couleur du brouillard, mais comme mentionné -ci-dessus, nous devons synchroniser la couleur du brouillard et la couleur de fond. -Ajoutons donc une autre propriété virtuelle à notre assistant qui définira les deux couleurs -lorsque lil-gui la manipulera.

-

lil-gui peut manipuler les couleurs de 4 manières : comme une chaîne hexadécimale CSS à 6 chiffres (par exemple : #112233). Comme un objet teinte, saturation, valeur (par exemple : {h: 60, s: 1, v: }). -Comme un tableau RGB (par exemple : [255, 128, 64]). Ou, comme un tableau RGBA (par exemple : [127, 200, 75, 0.3]).

-

Le plus simple pour notre objectif est d'utiliser la version chaîne hexadécimale, car de cette façon, -lil-gui ne manipule qu'une seule valeur. Heureusement, THREE.Color -dispose d'une méthode getHexString que nous pouvons utiliser pour obtenir facilement une telle chaîne, -il suffit de faire précéder d'un '#' au début.

-
// Nous utilisons cette classe pour la passer à lil-gui
-// ainsi quand elle manipule near ou far
-// near n'est jamais > far et far n'est jamais < near
-// Aussi, lorsque lil-gui manipule la couleur, nous allons
-// mettre à jour les couleurs du brouillard et de l'arrière-plan.
-class FogGUIHelper {
-*  constructor(fog, backgroundColor) {
-    this.fog = fog;
-+    this.backgroundColor = backgroundColor;
-  }
-  get near() {
-    return this.fog.near;
-  }
-  set near(v) {
-    this.fog.near = v;
-    this.fog.far = Math.max(this.fog.far, v);
-  }
-  get far() {
-    return this.fog.far;
-  }
-  set far(v) {
-    this.fog.far = v;
-    this.fog.near = Math.min(this.fog.near, v);
-  }
-+  get color() {
-+    return `#${this.fog.color.getHexString()}`;
-+  }
-+  set color(hexString) {
-+    this.fog.color.set(hexString);
-+    this.backgroundColor.set(hexString);
-+  }
-}
-
-

Nous appelons ensuite gui.addColor pour ajouter une interface utilisateur de couleur pour la propriété virtuelle de notre assistant.

-
{
-  const near = 1;
-  const far = 2;
-  const color = 'lightblue';
-  scene.fog = new THREE.Fog(color, near, far);
-  scene.background = new THREE.Color(color);
-
-*  const fogGUIHelper = new FogGUIHelper(scene.fog, scene.background);
-  gui.add(fogGUIHelper, 'near', near, far).listen();
-  gui.add(fogGUIHelper, 'far', near, far).listen();
-+  gui.addColor(fogGUIHelper, 'color');
-}
-
-

- -

-

Vous pouvez voir que régler near à environ 1.9 et far à 2.0 donne -une transition très nette entre non-brouillardé et complètement brouillardé. -tandis que near = 1.1 et far = 2.9 devraient être à peu près -les plus lisses étant donné que nos cubes tournent à 2 unités de distance de la caméra.

-

Une dernière chose, il existe une propriété booléenne fog -sur un matériau indiquant si les objets rendus -avec ce matériau sont affectés par le brouillard. Elle est par défaut à true -pour la plupart des matériaux. À titre d'exemple de pourquoi vous pourriez vouloir -désactiver le brouillard, imaginez que vous créez un simulateur de véhicule 3D -avec une vue depuis le siège du conducteur ou le cockpit. -Vous voudrez probablement désactiver le brouillard pour tout ce qui se trouve à l'intérieur du véhicule lorsque -vous regardez depuis l'intérieur du véhicule.

-

Un meilleur exemple pourrait être une maison -et un brouillard épais à l'extérieur. Disons que le brouillard est configuré pour commencer -à 2 mètres de distance (near = 2) et complètement brouillardé à 4 mètres (far = 4). -Les pièces sont plus longues que 2 mètres et la maison est probablement plus longue -que 4 mètres, vous devez donc régler les matériaux de l'intérieur -de la maison pour ne pas appliquer de brouillard, sinon, en vous tenant à l'intérieur -de la maison et en regardant dehors le mur au fond de la pièce, cela aura l'air -d'être dans le brouillard.

-
-
-
-
brouillard : vrai, tout
-
-
- -

Remarquez que les murs et le plafond au fond de la pièce sont affectés par le brouillard. -En désactivant le brouillard sur les matériaux de la maison, nous pouvons corriger ce problème.

-
-
-
-
brouillard : vrai, seulement les matériaux extérieurs
-
-
- -

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/fundamentals.html b/manual/fr/fundamentals.html deleted file mode 100644 index 59d9b16f8767d6..00000000000000 --- a/manual/fr/fundamentals.html +++ /dev/null @@ -1,449 +0,0 @@ - - - Principes fondamentaux - - - - - - - - - - - - - -
-
-

Principes fondamentaux

-
-
-
-

Ceci est le premier article d'une série d'articles sur three.js. -Three.js est une bibliothèque 3D qui essaie de rendre -aussi facile que possible l'affichage de contenu 3D sur une page web.

-

Three.js est souvent confondu avec WebGL car la plupart du temps, -mais pas toujours, three.js utilise WebGL pour dessiner en 3D. -WebGL est un système de très bas niveau qui ne dessine que des points, des lignes et des triangles. -Pour faire quoi que ce soit d'utile avec WebGL, cela nécessite généralement beaucoup de -code et c'est là que three.js intervient. Il gère des choses -comme les scènes, les lumières, les ombres, les matériaux, les textures, les mathématiques 3D, -toutes choses que vous auriez à écrire vous-même si vous utilisiez WebGL directement.

-

Ces tutoriels supposent que vous connaissez déjà JavaScript et, pour la -plupart, ils utiliseront le style ES6. Voir ici pour une -liste concise des choses que vous êtes censé déjà connaître. -La plupart des navigateurs qui supportent three.js sont mis à jour automatiquement, donc la plupart des utilisateurs -devraient pouvoir exécuter ce code. Si vous souhaitez faire fonctionner ce code -sur de très vieux navigateurs, penchez-vous sur un transpiler comme Babel. -Bien sûr, les utilisateurs qui exécutent de très vieux navigateurs ont probablement des machines -qui ne peuvent pas exécuter three.js.

-

Lors de l'apprentissage de la plupart des langages de programmation, la première chose que les gens -font est de faire afficher "Hello World!" par l'ordinateur. Pour la 3D, -l'une des premières choses les plus courantes à faire est de créer un cube 3D. -Alors commençons par "Hello Cube !"

-

Avant de commencer, essayons de vous donner une idée de la structure -d'une application three.js. Une application three.js vous demande de créer un tas -d'objets et de les connecter ensemble. Voici un diagramme qui représente -une petite application three.js

-
- -

Points à noter concernant le diagramme ci-dessus.

-
    -
  • Il y a un Renderer. C'est sans doute l'objet principal de three.js. Vous passez une -Scene et une Camera à un Renderer et il rend (dessine) la partie -de la scène 3D qui se trouve à l'intérieur du frustum de la caméra en tant qu'image 2D sur un -canevas.

    -
  • -
  • Il y a un graphe de scène (scenegraph) qui est une structure arborescente, -composée de divers objets comme un objet Scene, plusieurs objets -Mesh, des objets Light, Group, Object3D, et des objets Camera. Un -objet Scene définit la racine du graphe de scène et contient des propriétés -comme la couleur de fond et le brouillard. Ces objets définissent une structure arborescente -hiérarchique parent/enfant et représentent où les objets apparaissent et comment ils sont -orientés. Les enfants sont positionnés et orientés par rapport à leur parent. Par -exemple, les roues d'une voiture pourraient être les enfants de la voiture de sorte que déplacer et -orienter l'objet voiture déplace automatiquement les roues. Vous pouvez en savoir plus -à ce sujet dans l'article sur les graphes de scène.

    -

    Notez dans le diagramme que la Camera est à moitié dedans et à moitié dehors du graphe de scène. Cela représente -qu'en three.js, contrairement aux autres objets, une Camera n'a pas besoin -d'être dans le graphe de scène pour fonctionner. Tout comme les autres objets, une Camera, en tant -qu'enfant d'un autre objet, se déplacera et s'orientera par rapport à son objet parent. -Il y a un exemple de mise en place de plusieurs objets Camera dans un graphe de scène à -la fin de l'article sur les graphes de scène.

    -
  • -
  • Les objets Mesh représentent le dessin d'une Geometry spécifique avec un - Material spécifique.

    -

    Les objets Material et les objets Geometry peuvent être utilisés par - plusieurs objets Mesh. Par exemple, pour dessiner deux cubes bleus à différents - endroits, nous aurions besoin de deux objets Mesh pour représenter la position et - l'orientation de chaque cube. Nous n'aurions besoin que d'une seule Geometry pour stocker les - données de sommet d'un cube et nous n'aurions besoin que d'un seul Material pour spécifier la couleur - bleue. Les deux objets Mesh pourraient référencer le même objet Geometry et le - même objet Material.

    -
  • -
  • Les objets Geometry représentent les données de sommet d'une pièce de géométrie - comme une sphère, un cube, un plan, un chien, un chat, un humain, un arbre, un bâtiment, etc... - Three.js fournit de nombreux types de - primitives de géométrie intégrées. Vous pouvez également - créer une géométrie personnalisée ainsi que - charger de la géométrie à partir de fichiers.

    -
  • -
  • Les objets Material représentent -les propriétés de surface utilisées pour dessiner la géométrie -y compris des choses comme la couleur à utiliser et à quel point elle est brillante. Un Material peut -également référencer un ou plusieurs objets Texture qui peuvent être utilisés, par exemple, -pour envelopper une image sur la surface d'une géométrie.

    -
  • -
  • Les objets Texture représentent généralement des images soit chargées à partir de fichiers image, -générées à partir d'un canevas, soit rendues à partir d'une autre scène.

    -
  • -
  • Les objets Light représentent différents types de lumières.

    -
  • -
-

Étant donné tout cela, nous allons créer la configuration *« Hello Cube »* la plus simple -qui ressemble à ceci

-
- -

Tout d'abord, chargeons three.js

-
<script type="module">
-import * as THREE from 'three';
-</script>
-
-

Il est important de mettre type="module" dans la balise script. Cela nous permet -d'utiliser le mot-clé import pour charger three.js. À partir de r147, c'est la -seule façon de charger properly three.js. Les modules ont l'avantage de pouvoir facilement -importer d'autres modules dont ils ont besoin. Cela nous évite d'avoir à -charger manuellement les scripts supplémentaires dont ils dépendent.

-

Ensuite, nous avons besoin d'une balise <canvas>, donc...

-
<body>
-  <canvas id="c"></canvas>
-</body>
-
-

Nous allons demander à three.js de dessiner dans ce canevas, nous devons donc le rechercher.

-
<script type="module">
-import * as THREE from 'three';
-
-+function main() {
-+  const canvas = document.querySelector('#c');
-+  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+  ...
-</script>
-
-

Après avoir trouvé le canevas, nous créons un WebGLRenderer. Le renderer -est la chose responsable de prendre toutes les données que vous fournissez -et de les rendre sur le canevas.

-

Notez qu'il y a quelques détails ésotériques ici. Si vous ne passez pas de canevas -à three.js, il en créera un pour vous, mais vous devrez ensuite l'ajouter -à votre document. L'endroit où l'ajouter peut changer en fonction de votre cas d'utilisation -et vous devrez changer votre code. Je trouve que passer un canevas -à three.js est un peu plus flexible. Je peux placer le canevas n'importe où -et le code le trouvera, alors que si j'avais du code pour insérer le canevas -dans le document, je devrais probablement changer ce code si mon cas d'utilisation changeait.

-

Ensuite, nous avons besoin d'une caméra. Nous allons créer une PerspectiveCamera.

-
const fov = 75;
-const aspect = 2;  // the canvas default
-const near = 0.1;
-const far = 5;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-
-

fov est l'abréviation de field of view (champ de vision). Dans ce cas, 75 degrés dans la dimension verticale. -Notez que la plupart des angles en three.js sont en radians, mais pour une raison quelconque, la caméra perspective prend des degrés.

-

aspect est le rapport d'aspect (display aspect) du canevas. Nous aborderons les détails -dans un autre article mais par défaut, un canevas est -de 300x150 pixels, ce qui donne un rapport d'aspect de 300/150, soit 2.

-

near et far représentent l'espace devant la caméra -qui sera rendu. Tout ce qui se trouve avant cette plage ou après cette plage -sera écrêté (non dessiné).

-

Ces quatre paramètres définissent un *« frustum »*.

-

Un *frustum* est le nom d'une forme 3D qui ressemble à une pyramide dont la pointe est tranchée.

-

En d'autres termes, considérez le mot "frustum" comme une autre forme 3D comme une sphère, -un cube, un prisme, un frustum.

-

-

La hauteur des plans near et far est déterminée par le champ de vision. -La largeur des deux plans est déterminée par le champ de vision et l'aspect.

-

Tout ce qui se trouve à l'intérieur du frustum défini sera dessiné. Tout ce qui se trouve à l'extérieur -ne le sera pas.

-

La caméra est orientée par défaut vers l'axe -Z avec +Y vers le haut. Nous allons placer notre cube -à l'origine, nous devons donc reculer légèrement la caméra par rapport à l'origine -afin de voir quelque chose.

-
camera.position.z = 2;
-
-

Voici ce que nous visons.

-

-

Dans le diagramme ci-dessus, nous pouvons voir que notre caméra est à z = 2. Elle regarde -vers l'axe -Z. Notre frustum commence à 0.1 unité de l'avant de la caméra -et va jusqu'à 5 unités devant la caméra. Parce que dans ce diagramme nous regardons vers le bas, -le champ de vision est affecté par l'aspect. Notre canevas est deux fois plus large -qu'il n'est haut, donc sur la largeur du canevas, le champ de vision sera beaucoup plus large que -nos 75 degrés spécifiés, qui correspondent au champ de vision vertical.

-

Ensuite, nous créons une Scene. Une Scene dans three.js est la racine d'une forme de graphe de scène. -Tout ce que vous voulez que three.js dessine doit être ajouté à la scène. Nous allons -couvrir plus de détails sur le fonctionnement des scènes dans un futur article.

-
const scene = new THREE.Scene();
-
-

Ensuite, nous créons une BoxGeometry qui contient les données pour une boîte. -Presque tout ce que nous voulons afficher dans Three.js nécessite une géométrie qui définit -les sommets qui composent notre objet 3D.

-
const boxWidth = 1;
-const boxHeight = 1;
-const boxDepth = 1;
-const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-
-

Nous créons ensuite un matériau de base et définissons sa couleur. Les couleurs peuvent -être spécifiées en utilisant les valeurs hexadécimales à 6 chiffres de style CSS standard.

-
const material = new THREE.MeshBasicMaterial({color: 0x44aa88});
-
-

Nous créons ensuite un Mesh. Un Mesh en three.js représente la combinaison -de trois choses

-
    -
  1. Une Geometry (la forme de l'objet)
  2. -
  3. Un Material (comment dessiner l'objet, brillant ou plat, quelle couleur, quelle(s) texture(s) appliquer. Etc.)
  4. -
  5. La position, l'orientation et l'échelle de cet objet dans la scène par rapport à son parent. Dans le code ci-dessous, ce parent est la scène.
  6. -
-
const cube = new THREE.Mesh(geometry, material);
-
-

Et enfin, nous ajoutons ce maillage à la scène

-
scene.add(cube);
-
-

Nous pouvons ensuite rendre la scène en appelant la fonction de rendu du renderer -et en lui passant la scène et la caméra

-
renderer.render(scene, camera);
-
-

Voici un exemple fonctionnel

-

- -

-

Il est un peu difficile de voir qu'il s'agit d'un cube 3D puisque nous le visualisons -directement le long de l'axe -Z et que le cube lui-même est aligné sur les axes, -donc nous ne voyons qu'une seule face.

-

Animons-le en rotation et, espérons-le, cela montrera -clairement qu'il est dessiné en 3D. Pour l'animer, nous allons le rendre dans une boucle de rendu en utilisant -requestAnimationFrame.

-

Voici notre boucle

-
function render(time) {
-  time *= 0.001;  // convert time to seconds
-
-  cube.rotation.x = time;
-  cube.rotation.y = time;
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-requestAnimationFrame(render);
-
-

requestAnimationFrame est une requête au navigateur indiquant que vous souhaitez animer quelque chose. -Vous lui passez une fonction à appeler. Dans notre cas, cette fonction est render. Le navigateur -appellera votre fonction et si vous mettez à jour quoi que ce soit lié à l'affichage de la -page, le navigateur re-rendrera la page. Dans notre cas, nous appelons la fonction -renderer.render de three, qui dessinera notre scène.

-

requestAnimationFrame passe le temps écoulé depuis le chargement de la page -à notre fonction. Ce temps est exprimé en millisecondes. Je trouve beaucoup -plus facile de travailler avec des secondes, donc ici nous convertissons cela en secondes.

-

Nous définissons ensuite les rotations X et Y du cube à l'heure actuelle. Ces rotations -sont en radians. Il y a 2 pi radians -dans un cercle, donc notre cube devrait faire un tour sur chaque axe en environ 6,28 -secondes.

-

Nous rendons ensuite la scène et demandons une autre frame d'animation pour continuer -notre boucle.

-

En dehors de la boucle, nous appelons requestAnimationFrame une seule fois pour démarrer la boucle.

-

- -

-

C'est un peu mieux, mais il est toujours difficile de voir le 3D. Ce qui aiderait, -c'est d'ajouter un peu d'éclairage, alors ajoutons une lumière. Il existe de nombreux types de lumières dans -three.js que nous aborderons dans un futur article. Pour l'instant, créons une lumière directionnelle.

-
const color = 0xFFFFFF;
-const intensity = 3;
-const light = new THREE.DirectionalLight(color, intensity);
-light.position.set(-1, 2, 4);
-scene.add(light);
-
-

Les lumières directionnelles ont une position et une cible. Les deux sont par défaut à 0, 0, 0. Dans notre -cas, nous définissons la position de la lumière à -1, 2, 4, de sorte qu'elle est légèrement sur la gauche, -au-dessus et derrière notre caméra. La cible est toujours 0, 0, 0, elle brillera donc -vers l'origine.

-

Nous devons également changer le matériau. Le MeshBasicMaterial n'est pas affecté par -les lumières. Changeons-le pour un MeshPhongMaterial qui est affecté par les lumières.

-
-const material = new THREE.MeshBasicMaterial({color: 0x44aa88});  // greenish blue
-+const material = new THREE.MeshPhongMaterial({color: 0x44aa88});  // greenish blue
-
-

Voici la structure de notre nouveau programme

-
- -

Et le voici en fonctionnement.

-

- -

-

Maintenant, il devrait être assez clairement en 3D.

-

Juste pour le plaisir, ajoutons 2 cubes de plus.

-

Nous utiliserons la même géométrie pour chaque cube mais créerons un matériau différent -afin que chaque cube puisse avoir une couleur différente.

-

Tout d'abord, nous allons créer une fonction qui crée un nouveau matériau -avec la couleur spécifiée. Ensuite, elle crée un maillage en utilisant -la géométrie spécifiée et l'ajoute à la scène et -définit sa position en X.

-
function makeInstance(geometry, color, x) {
-  const material = new THREE.MeshPhongMaterial({color});
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  cube.position.x = x;
-
-  return cube;
-}
-
-

Ensuite, nous l'appellerons 3 fois avec 3 couleurs et positions X différentes -en stockant les instances Mesh dans un tableau.

-
const cubes = [
-  makeInstance(geometry, 0x44aa88,  0),
-  makeInstance(geometry, 0x8844aa, -2),
-  makeInstance(geometry, 0xaa8844,  2),
-];
-
-

Enfin, nous allons faire tourner les 3 cubes dans notre fonction de rendu. Nous -calculons une rotation légèrement différente pour chacun.

-
function render(time) {
-  time *= 0.001;  // convert time to seconds
-
-  cubes.forEach((cube, ndx) => {
-    const speed = 1 + ndx * .1;
-    const rot = time * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
-  ...
-
-

et voici le résultat.

-

- -

-

Si vous le comparez au diagramme vu de dessus ci-dessus, vous pouvez voir -qu'il correspond à nos attentes.

-

Avec les cubes à X = -2 et X = +2, ils sont partiellement en dehors de notre frustum.

-

Ils sont également quelque peu exagérément déformés car le champ de vision -à travers le canevas est si extrême.

-

Notre programme a maintenant cette structure

-
- -

Comme vous pouvez le voir, nous avons 3 objets Mesh, chacun référençant la même BoxGeometry. -Chaque Mesh référence un MeshPhongMaterial unique afin que chaque cube puisse avoir -une couleur différente.

-

J'espère que cette courte introduction vous aidera à démarrer. Ensuite, nous verrons -comment rendre notre code réactif afin qu'il soit adaptable à plusieurs situations.

-
-

modules es6, three.js et structure de dossiers

-

À partir de la version r147, la manière préférée d'utiliser three.js est via les modules es6 et les cartes d'importation (import maps).

-

-Les modules es6 peuvent être chargés via le mot-clé import dans un script -ou en ligne via une balise <script type="module">. Voici un exemple -

-
<script type="module">
-import * as THREE from 'three';
-
-...
-
-</script>
-
-

-Notez le spécificateur 'three' ici. Si vous le laissez tel quel, il produira probablement une erreur. Une *carte d'importation* doit être utilisée pour indiquer au navigateur où trouver three.js -

-
<script type="importmap">
-{
-  "imports": {
-    "three": "./path/to/three.module.js"
-  }
-}
-</script>
-
-

-Notez que le spécificateur de chemin ne peut commencer qu'avec ./ ou ../. -

-

-Pour importer des extensions (addons) comme OrbitControls.js, utilisez ce qui suit -

-
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-
-

-N'oubliez pas d'ajouter les extensions (addons) à la carte d'importation comme ceci -

-
<script type="importmap">
-{
-  "imports": {
-    "three": "./path/to/three.module.js",
-    "three/addons/": "./different/path/to/examples/jsm/"
-  }
-}
-</script>
-
-

-Vous pouvez également utiliser un CDN -

-
<script type="importmap">
-{
-  "imports": {
-    "three": "https://cdn.jsdelivr.net/npm/three@<version>/build/three.module.js",
-    "three/addons/": "https://cdn.jsdelivr.net/npm/three@<version>/examples/jsm/"
-  }
-}
-</script>
-
-

-En conclusion, la manière recommandée d'utiliser three.js est -

-
-<script type="importmap">
-{
-  "imports": {
-    "three": "./path/to/three.module.js",
-    "three/addons/": "./different/path/to/examples/jsm/"
-  }
-}
-</script>
-
-<script type="module">
-import * as THREE from 'three';
-import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-
-...
-
-</script>
-
-
- - -

-

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/game.html b/manual/fr/game.html deleted file mode 100644 index 21b96c6d2c2823..00000000000000 --- a/manual/fr/game.html +++ /dev/null @@ -1,1756 +0,0 @@ - - - Créer un jeu - - - - - - - - - - - - - -
-
-

Créer un jeu

-
-
-
-

Beaucoup de gens veulent écrire des jeux en utilisant three.js. Cet article -vous donnera, je l'espère, quelques idées sur la façon de commencer.

-

Au moment où j'écris cet article, il s'agit probablement de l'article le plus long de ce site. -Il est possible que le code ici soit massivement sur-conçu, mais à mesure que j'écrivais chaque nouvelle fonctionnalité, je rencontrais un problème qui nécessitait une solution à laquelle je suis habitué depuis d'autres jeux que j'ai écrits. -En d'autres termes, chaque nouvelle solution semblait importante, je vais donc essayer de montrer pourquoi. -Bien sûr, plus votre jeu est petit, moins vous pourriez avoir besoin de certaines des solutions présentées ici, mais il s'agit d'un jeu assez petit et pourtant, avec les complexités des personnages 3D, beaucoup de choses demandent plus d'organisation qu'elles ne le feraient avec des personnages 2D.

-

Par exemple, si vous créez PacMan en 2D, lorsque PacMan tourne dans un coin, -cela se produit instantanément à 90 degrés. Il n'y a pas d'étape intermédiaire. -Mais dans un jeu 3D, nous avons souvent besoin que le personnage pivote sur plusieurs images. -Ce simple changement peut ajouter beaucoup de complexité et nécessiter des solutions différentes.

-

La majorité du code ici ne sera pas vraiment three.js et -c'est important à noter, three.js n'est pas un moteur de jeu. -Three.js est une bibliothèque 3D. Elle fournit un graphe de scène -et des fonctionnalités pour afficher les objets 3D ajoutés à ce graphe de scène, -mais elle ne fournit pas toutes les autres choses nécessaires pour créer un jeu. -Pas de collisions, pas de physique, pas de systèmes d'entrée, pas de recherche de chemin, etc., etc... -Donc, nous devrons fournir ces choses nous-mêmes.

-

J'ai fini par écrire pas mal de code pour créer cette simple chose inachevée -ressemblant à un jeu, et encore une fois, il est certainement possible que j'aie sur-conçu -et qu'il existe des solutions plus simples, mais j'ai l'impression de ne pas avoir écrit -assez de code et j'espère pouvoir expliquer ce qui, à mon avis, manque.

-

Beaucoup des idées ici sont fortement influencées par Unity. -Si vous n'êtes pas familier avec Unity, cela n'a probablement pas d'importance. -Je n'en parle que parce que des dizaines de milliers de jeux ont été publiés en utilisant -ces idées.

-

Commençons par les parties three.js. Nous devons charger des modèles pour notre jeu.

-

Sur opengameart.org j'ai trouvé ce modèle de chevalier -animé par quaternius

-
- -

quaternius a également créé ces animaux animés.

-
- -

Ceux-ci semblent être de bons modèles pour commencer, donc la première chose à -faire est de les charger.

-

Nous avons abordé le chargement de fichiers glTF auparavant. -La différence cette fois est que nous devons charger plusieurs modèles et -nous ne pouvons pas démarrer le jeu tant que tous les modèles ne sont pas chargés.

-

Heureusement, three.js fournit le LoadingManager juste à cette fin. -Nous créons un LoadingManager et le passons aux autres chargeurs. Le -LoadingManager fournit à la fois les propriétés onProgress et -onLoad auxquelles nous pouvons attacher des callbacks. -Le callback onLoad sera appelé lorsque -tous les fichiers auront été chargés. Le callback onProgress -est appelé après l'arrivée de chaque fichier individuel pour nous donner une chance de montrer -la progression du chargement.

-

En partant du code de chargement d'un fichier glTF, j'ai supprimé tout -le code lié au cadrage de la scène et ajouté ce code pour charger tous les modèles.

-
const manager = new THREE.LoadingManager();
-manager.onLoad = init;
-const models = {
-  pig:    { url: 'resources/models/animals/Pig.gltf' },
-  cow:    { url: 'resources/models/animals/Cow.gltf' },
-  llama:  { url: 'resources/models/animals/Llama.gltf' },
-  pug:    { url: 'resources/models/animals/Pug.gltf' },
-  sheep:  { url: 'resources/models/animals/Sheep.gltf' },
-  zebra:  { url: 'resources/models/animals/Zebra.gltf' },
-  horse:  { url: 'resources/models/animals/Horse.gltf' },
-  knight: { url: 'resources/models/knight/KnightCharacter.gltf' },
-};
-{
-  const gltfLoader = new GLTFLoader(manager);
-  for (const model of Object.values(models)) {
-    gltfLoader.load(model.url, (gltf) => {
-      model.gltf = gltf;
-    });
-  }
-}
-
-function init() {
-  // TBD
-}
-
-

Ce code chargera tous les modèles ci-dessus et le LoadingManager appellera -init une fois terminé. Nous utiliserons l'objet models plus tard pour accéder aux -modèles chargés, de sorte que le callback du GLTFLoader pour chaque modèle individuel attache -les données chargées aux informations de ce modèle.

-

Tous les modèles avec toutes leurs animations font actuellement environ 6,6 Mo. C'est un -téléchargement assez important. En supposant que votre serveur prenne en charge la compression (ce qui est le cas du serveur sur lequel ce site fonctionne), il peut les compresser à environ 1,4 Mo. C'est -nettement mieux que 6,6 Mo, mais ce n'est toujours pas une petite quantité de données. Il serait -probablement bon d'ajouter une barre de progression pour que l'utilisateur ait une idée du temps qu'il -lui reste à attendre.

-

Alors, ajoutons un callback onProgress. Il sera -appelé avec 3 arguments : l'url du dernier objet chargé, puis le nombre -d'éléments chargés jusqu'à présent, ainsi que le nombre total d'éléments.

-

Mettons en place du code HTML pour une barre de chargement

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="loading">
-+    <div>
-+      <div>...chargement...</div>
-+      <div class="progress"><div id="progressbar"></div></div>
-+    </div>
-+  </div>
-</body>
-
-

Nous allons rechercher la div #progressbar et nous pourrons définir la largeur de 0 % à 100 % -pour montrer notre progression. Tout ce que nous avons à faire est de définir cela dans notre callback.

-
const manager = new THREE.LoadingManager();
-manager.onLoad = init;
-
-+const progressbarElem = document.querySelector('#progressbar');
-+manager.onProgress = (url, itemsLoaded, itemsTotal) => {
-+  progressbarElem.style.width = `${itemsLoaded / itemsTotal * 100 | 0}%`;
-+};
-
-

Nous avons déjà configuré init pour être appelé lorsque tous les modèles sont chargés, nous pouvons donc -désactiver la barre de progression en masquant l'élément #loading.

-
function init() {
-+  // masquer la barre de chargement
-+  const loadingElem = document.querySelector('#loading');
-+  loadingElem.style.display = 'none';
-}
-
-

Voici un tas de CSS pour styler la barre. Le CSS rend la #loading <div> -de la taille totale de la page et centre ses enfants. Le CSS crée une zone .progress -pour contenir la barre de progression. Le CSS donne également à la barre de progression -une animation CSS de rayures diagonales.

-
#loading {
-  position: absolute;
-  left: 0;
-  top: 0;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  text-align: center;
-  font-size: xx-large;
-  font-family: sans-serif;
-}
-#loading>div>div {
-  padding: 2px;
-}
-.progress {
-  width: 50vw;
-  border: 1px solid black;
-}
-#progressbar {
-  width: 0;
-  transition: width ease-out .5s;
-  height: 1em;
-  background-color: #888;
-  background-image: linear-gradient(
-    -45deg,
-    rgba(255, 255, 255, .5) 25%,
-    transparent 25%,
-    transparent 50%,
-    rgba(255, 255, 255, .5) 50%,
-    rgba(255, 255, 255, .5) 75%,
-    transparent 75%,
-    transparent
-  );
-  background-size: 50px 50px;
-  animation: progressanim 2s linear infinite;
-}
-
-@keyframes progressanim {
-  0% {
-    background-position: 50px 50px;
-  }
-  100% {
-    background-position: 0 0;
-  }
-}
-
-

Maintenant que nous avons une barre de progression, occupons-nous des modèles. Ces modèles -ont des animations et nous voulons pouvoir y accéder. -Les animations sont stockées dans un tableau par défaut, mais nous aimerions pouvoir y accéder -facilement par leur nom. Configurons donc une propriété animations pour -chaque modèle afin de faire cela. Notez bien sûr que cela signifie que les animations doivent avoir des noms uniques.

-
+function prepModelsAndAnimations() {
-+  Object.values(models).forEach(model => {
-+    const animsByName = {};
-+    model.gltf.animations.forEach((clip) => {
-+      animsByName[clip.name] = clip;
-+    });
-+    model.animations = animsByName;
-+  });
-+}
-
-function init() {
-  // masquer la barre de chargement
-  const loadingElem = document.querySelector('#loading');
-  loadingElem.style.display = 'none';
-
-+  prepModelsAndAnimations();
-}
-
-

Affichons les modèles animés.

-

Contrairement à l'exemple précédent de chargement d'un fichier glTF, -cette fois-ci, nous voulons probablement pouvoir afficher plus d'une instance -de chaque modèle. Pour ce faire, au lieu d'ajouter -directement la scène glTF chargée, comme nous l'avons fait dans l'article sur le chargement d'un glTF, -nous voulons plutôt cloner la scène et, en particulier, nous voulons la cloner -pour les personnages animés avec skinning. Heureusement, il existe une fonction utilitaire, -SkeletonUtils.clone, que nous pouvons utiliser pour cela. Donc, nous devons d'abord inclure -les utilitaires.

-
import * as THREE from 'three';
-import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
-+import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
-
-

Ensuite, nous pouvons cloner les modèles que nous venons de charger

-
function init() {
-  // masquer la barre de chargement
-  const loadingElem = document.querySelector('#loading');
-  loadingElem.style.display = 'none';
-
-  prepModelsAndAnimations();
-
-+  Object.values(models).forEach((model, ndx) => {
-+    const clonedScene = SkeletonUtils.clone(model.gltf.scene);
-+    const root = new THREE.Object3D();
-+    root.add(clonedScene);
-+    scene.add(root);
-+    root.position.x = (ndx - 3) * 3;
-+  });
-}
-
-

Ci-dessus, pour chaque modèle, nous clonons la gltf.scene que nous avons chargée et -nous en faisons l'enfant d'un nouveau Object3D. Nous devons l'attacher à un autre objet, -car lorsque nous jouons des animations, l'animation appliquera des positions animées aux nœuds -de la scène chargée, ce qui signifie que nous n'aurons pas le contrôle sur ces positions.

-

Pour jouer les animations, chaque modèle que nous clonons a besoin d'un AnimationMixer. -Un AnimationMixer contient 1 ou plusieurs AnimationActions. Une -AnimationAction référence un AnimationClip. Les AnimationActions -ont toutes sortes de paramètres pour jouer, puis enchaîner avec une autre -action ou faire un crossfade entre les actions. Prenons simplement le premier -AnimationClip et créons une action pour celui-ci. La valeur par défaut est qu'une -action joue son clip en boucle indéfiniment.

-
+const mixers = [];
-
-function init() {
-  // masquer la barre de chargement
-  const loadingElem = document.querySelector('#loading');
-  loadingElem.style.display = 'none';
-
-  prepModelsAndAnimations();
-
-  Object.values(models).forEach((model, ndx) => {
-    const clonedScene = SkeletonUtils.clone(model.gltf.scene);
-    const root = new THREE.Object3D();
-    root.add(clonedScene);
-    scene.add(root);
-    root.position.x = (ndx - 3) * 3;
-
-+    const mixer = new THREE.AnimationMixer(clonedScene);
-+    const firstClip = Object.values(model.animations)[0];
-+    const action = mixer.clipAction(firstClip);
-+    action.play();
-+    mixers.push(mixer);
-  });
-}
-
-

Nous avons appelé play pour démarrer l'action et stocké -tous les AnimationMixers dans un tableau appelé mixers. Enfin, -nous devons mettre à jour chaque AnimationMixer dans notre boucle de rendu en calculant -le temps écoulé depuis la dernière image et en le passant à AnimationMixer.update.

-
+let then = 0;
-function render(now) {
-+  now *= 0.001;  // convertir en secondes
-+  const deltaTime = now - then;
-+  then = now;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-+  for (const mixer of mixers) {
-+    mixer.update(deltaTime);
-+  }
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-

Et avec cela, chaque modèle devrait être chargé et jouer sa première animation.

-

- -

-

Faisons en sorte que nous puissions vérifier toutes les animations. -Nous ajouterons tous les clips en tant qu'actions, puis nous n'en activerons qu'un -seul à la fois.

-
-const mixers = [];
-+const mixerInfos = [];
-
-function init() {
-  // masquer la barre de chargement
-  const loadingElem = document.querySelector('#loading');
-  loadingElem.style.display = 'none';
-
-  prepModelsAndAnimations();
-
-  Object.values(models).forEach((model, ndx) => {
-    const clonedScene = SkeletonUtils.clone(model.gltf.scene);
-    const root = new THREE.Object3D();
-    root.add(clonedScene);
-    scene.add(root);
-    root.position.x = (ndx - 3) * 3;
-
-    const mixer = new THREE.AnimationMixer(clonedScene);
--    const firstClip = Object.values(model.animations)[0];
--    const action = mixer.clipAction(firstClip);
--    action.play();
--    mixers.push(mixer);
-+    const actions = Object.values(model.animations).map((clip) => {
-+      return mixer.clipAction(clip);
-+    });
-+    const mixerInfo = {
-+      mixer,
-+      actions,
-+      actionNdx: -1,
-+    };
-+    mixerInfos.push(mixerInfo);
-+    playNextAction(mixerInfo);
-  });
-}
-
-+function playNextAction(mixerInfo) {
-+  const {actions, actionNdx} = mixerInfo;
-+  const nextActionNdx = (actionNdx + 1) % actions.length;
-+  mixerInfo.actionNdx = nextActionNdx;
-+  actions.forEach((action, ndx) => {
-+    const enabled = ndx === nextActionNdx;
-+    action.enabled = enabled;
-+    if (enabled) {
-+      action.play();
-+    }
-+  });
-+}
-
-

Le code ci-dessus crée un tableau de AnimationActions, -une pour chaque AnimationClip. Il crée un tableau d'objets, mixerInfos, -avec des références au AnimationMixer et à toutes les AnimationActions -pour chaque modèle. Il appelle ensuite playNextAction qui définit la propriété enabled à -l'exception d'une seule action pour ce mixeur.

-

Nous devons mettre à jour la boucle de rendu pour le nouveau tableau

-
-for (const mixer of mixers) {
-+for (const {mixer} of mixerInfos) {
-  mixer.update(deltaTime);
-}
-
-

Faisons en sorte qu'en appuyant sur une touche de 1 à 8, l'animation suivante soit jouée -pour chaque modèle

-
window.addEventListener('keydown', (e) => {
-  const mixerInfo = mixerInfos[e.keyCode - 49];
-  if (!mixerInfo) {
-    return;
-  }
-  playNextAction(mixerInfo);
-});
-
-

Maintenant, vous devriez pouvoir cliquer sur l'exemple, puis appuyer sur les touches 1 à 8 -pour faire défiler chaque modèle à travers ses animations disponibles.

-

- -

-

On peut donc dire que c'est la somme totale de la partie three.js de cet article. -Nous avons abordé le chargement de plusieurs fichiers, le clonage de modèles skinnés, -et la lecture d'animations sur ceux-ci. Dans un vrai jeu, vous auriez beaucoup plus -de manipulations à faire sur les objets AnimationAction.

-

Commençons à créer une infrastructure de jeu

-

Un modèle courant pour créer un jeu moderne est d'utiliser un -Entity Component System. -Dans un Entity Component System, un objet dans un jeu est appelé une entité qui -se compose d'un ensemble de composants. Vous construisez des entités en décidant quels composants -leur attacher. Alors, créons un Entity Component System.

-

Nous appellerons nos entités GameObject. C'est effectivement juste une collection -de composants et un Object3D de three.js.

-
function removeArrayElement(array, element) {
-  const ndx = array.indexOf(element);
-  if (ndx >= 0) {
-    array.splice(ndx, 1);
-  }
-}
-
-class GameObject {
-  constructor(parent, name) {
-    this.name = name;
-    this.components = [];
-    this.transform = new THREE.Object3D();
-    parent.add(this.transform);
-  }
-  addComponent(ComponentType, ...args) {
-    const component = new ComponentType(this, ...args);
-    this.components.push(component);
-    return component;
-  }
-  removeComponent(component) {
-    removeArrayElement(this.components, component);
-  }
-  getComponent(ComponentType) {
-    return this.components.find(c => c instanceof ComponentType);
-  }
-  update() {
-    for (const component of this.components) {
-      component.update();
-    }
-  }
-}
-
-

L'appel de GameObject.update appelle la fonction update sur tous les composants.

-

J'ai inclus un nom uniquement pour faciliter le débogage, de sorte que si j'examine un GameObject -dans le débogueur, je puisse voir un nom pour l'aider à l'identifier.

-

Quelques choses qui pourraient sembler un peu étranges :

-

GameObject.addComponent est utilisé pour créer des composants. Que ce -soit une bonne ou une mauvaise idée, je ne suis pas sûr. Ma pensée était qu'il n'a aucun sens -pour un composant d'exister en dehors d'un gameobject, alors j'ai pensé -qu'il pourrait être bon que la création d'un composant ajoute automatiquement ce composant -au gameobject et passe le gameobject au constructeur du composant. -En d'autres termes, pour ajouter un composant, vous faites ceci

-
const gameObject = new GameObject(scene, 'foo');
-gameObject.addComponent(TypeOfComponent);
-
-

Si je ne le faisais pas de cette façon, vous feriez plutôt quelque chose comme ceci

-
const gameObject = new GameObject(scene, 'foo');
-const component = new TypeOfComponent(gameObject);
-gameObject.addComponent(component);
-
-

Est-ce mieux que la première méthode soit plus courte et plus automatisée, ou est-ce pire -parce que cela sort de l'ordinaire ? Je ne sais pas.

-

GameObject.getComponent recherche les composants par type. Cela -implique que vous ne pouvez pas avoir 2 composants du même -type sur un seul objet de jeu, ou du moins si vous en avez, vous ne pouvez -rechercher que le premier sans ajouter une autre API.

-

Il est courant qu'un composant en recherche un autre, et lors de cette recherche, ils -doivent correspondre par type, sinon vous pourriez obtenir le mauvais. Nous pourrions à la place -donner un nom à chaque composant et vous pourriez les rechercher par leur nom. Ce serait -plus flexible car vous pourriez avoir plus d'un composant du même type, mais ce -serait aussi plus fastidieux. Encore une fois, je ne suis pas sûr de ce qui est le mieux.

-

Passons aux composants eux-mêmes. Voici leur classe de base.

-
// Base pour tous les composants
-class Component {
-  constructor(gameObject) {
-    this.gameObject = gameObject;
-  }
-  update() {
-  }
-}
-
-

Les composants ont-ils besoin d'une classe de base ? JavaScript n'est pas comme la plupart des langages strictement -typés, donc en pratique, nous pourrions ne pas avoir de classe de base et laisser -chaque composant faire ce qu'il veut dans son constructeur, sachant que le premier -argument est toujours le gameobject du composant. S'il ne se soucie pas du gameobject, il -ne le stockerait pas. J'ai un peu l'impression que cette base commune est bonne cependant. -Cela signifie que si vous avez une référence à un -composant, vous savez que vous pouvez toujours trouver son gameobject parent, et à partir de son -parent, vous pouvez facilement rechercher d'autres composants ainsi que regarder sa -transformation.

-

Pour gérer les gameobjects, nous avons probablement besoin d'une sorte de gestionnaire de gameobjects. Vous -pourriez penser que nous pourrions simplement garder un tableau de gameobjects, mais dans un vrai jeu, les -composants d'un gameobject pourraient ajouter et supprimer d'autres gameobjects pendant l'exécution. -Par exemple, un gameobject d'arme pourrait ajouter un gameobject de balle chaque fois que l'arme -tire. Un gameobject de monstre pourrait se supprimer s'il a été tué. Nous -aurions alors un problème : nous pourrions avoir du code comme ceci

-
for (const gameObject of globalArrayOfGameObjects) {
-  gameObject.update();
-}
-
-

La boucle ci-dessus échouerait ou ferait des choses inattendues si -des gameobjects étaient ajoutés ou supprimés de globalArrayOfGameObjects -au milieu de la boucle dans la fonction update d'un composant.

-

Pour essayer de prévenir ce problème, nous avons besoin de quelque chose d'un peu plus sûr. -Voici une tentative.

-
class SafeArray {
-  constructor() {
-    this.array = [];
-    this.addQueue = [];
-    this.removeQueue = new Set();
-  }
-  get isEmpty() {
-    return this.addQueue.length + this.array.length > 0;
-  }
-  add(element) {
-    this.addQueue.push(element);
-  }
-  remove(element) {
-    this.removeQueue.add(element);
-  }
-  forEach(fn) {
-    this._addQueued();
-    this._removeQueued();
-    for (const element of this.array) {
-      if (this.removeQueue.has(element)) {
-        continue;
-      }
-      fn(element);
-    }
-    this._removeQueued();
-  }
-  _addQueued() {
-    if (this.addQueue.length) {
-      this.array.splice(this.array.length, 0, ...this.addQueue);
-      this.addQueue = [];
-    }
-  }
-  _removeQueued() {
-    if (this.removeQueue.size) {
-      this.array = this.array.filter(element => !this.removeQueue.has(element));
-      this.removeQueue.clear();
-    }
-  }
-}
-
-

La classe ci-dessus vous permet d'ajouter ou de supprimer des éléments du SafeArray -sans altérer le tableau lui-même pendant qu'il est parcouru. Au lieu -de cela, les nouveaux éléments sont ajoutés à addQueue et les éléments supprimés -à removeQueue, puis ajoutés ou supprimés en dehors de la boucle.

-

En utilisant cela, voici notre classe pour gérer les gameobjects.

-
class GameObjectManager {
-  constructor() {
-    this.gameObjects = new SafeArray();
-  }
-  createGameObject(parent, name) {
-    const gameObject = new GameObject(parent, name);
-    this.gameObjects.add(gameObject);
-    return gameObject;
-  }
-  removeGameObject(gameObject) {
-    this.gameObjects.remove(gameObject);
-  }
-  update() {
-    this.gameObjects.forEach(gameObject => gameObject.update());
-  }
-}
-
-

Avec tout cela, créons maintenant notre premier composant. Ce composant -gérera simplement un objet three.js skinné comme ceux que nous venons de créer. -Pour rester simple, il n'aura qu'une seule méthode, setAnimation, qui -prend le nom de l'animation à jouer et la lance.

-
class SkinInstance extends Component {
-  constructor(gameObject, model) {
-    super(gameObject);
-    this.model = model;
-    this.animRoot = SkeletonUtils.clone(this.model.gltf.scene);
-    this.mixer = new THREE.AnimationMixer(this.animRoot);
-    gameObject.transform.add(this.animRoot);
-    this.actions = {};
-  }
-  setAnimation(animName) {
-    const clip = this.model.animations[animName];
-    // turn off all current actions
-    for (const action of Object.values(this.actions)) {
-      action.enabled = false;
-    }
-    // get or create existing action for clip
-    const action = this.mixer.clipAction(clip);
-    action.enabled = true;
-    action.reset();
-    action.play();
-    this.actions[animName] = action;
-  }
-  update() {
-    this.mixer.update(globals.deltaTime);
-  }
-}
-
-

Vous pouvez voir qu'il s'agit essentiellement du code que nous avions auparavant qui clone la scène que nous avons chargée, -puis configure un AnimationMixer. setAnimation ajoute une AnimationAction pour un -AnimationClip particulier s'il n'existe pas déjà, et désactive toutes -les actions existantes.

-

Le code référence globals.deltaTime. Créons un objet globals

-
const globals = {
-  time: 0,
-  deltaTime: 0,
-};
-
-

Et mettons-le à jour dans la boucle de rendu

-
let then = 0;
-function render(now) {
-  // convertir en secondes
-  globals.time = now * 0.001;
-  // s'assurer que le temps delta n'est pas trop grand.
-  globals.deltaTime = Math.min(globals.time - then, 1 / 20);
-  then = globals.time;
-
-

La vérification ci-dessus pour s'assurer que deltaTime ne dépasse pas 1/20ème -de seconde est due au fait que, sinon, nous obtiendrions une valeur énorme pour deltaTime -si nous masquions l'onglet. Nous pourrions le masquer pendant des secondes ou des minutes, et ensuite, -lorsque notre onglet serait ramené au premier plan, deltaTime serait énorme -et pourrait téléporter des personnages à travers notre monde de jeu si nous avions du code comme

-
position += velocity * deltaTime;
-
-

En limitant le maximum deltaTime, ce problème est évité.

-

Créons maintenant un composant pour le joueur.

-
+const kForward = new THREE.Vector3(0, 0, 1);
-const globals = {
-  time: 0,
-  deltaTime: 0,
-+  moveSpeed: 16,
-};
-
-class Player extends Component {
-  constructor(gameObject) {
-    super(gameObject);
-    const model = models.knight;
-    this.skinInstance = gameObject.addComponent(SkinInstance, model);
-    this.skinInstance.setAnimation('Run');
-+    this.turnSpeed = globals.moveSpeed / 4;
-  }
-+  update() {
-+    const {deltaTime, moveSpeed} = globals;
-+    const {transform} = this.gameObject;
-+    const delta = (inputManager.keys.left.down  ?  1 : 0) +
-+                  (inputManager.keys.right.down ? -1 : 0);
-+    transform.rotation.y += this.turnSpeed * delta * deltaTime;
-+    transform.translateOnAxis(kForward, moveSpeed * deltaTime);
-+  }
-}
-
-

Le code ci-dessus utilise Object3D.transformOnAxis pour faire avancer le joueur. -Object3D.transformOnAxis fonctionne dans l'espace local, il ne fonctionne donc que -si l'objet en question est à la racine de la scène, pas s'il est un enfant de quelque chose d'autre 1

-

Nous avons également ajouté une vitesse de déplacement globale (moveSpeed) et basé une vitesse de rotation (turnSpeed) sur la vitesse de déplacement. -La vitesse de rotation est basée sur la vitesse de déplacement pour essayer de s'assurer qu'un personnage -peut tourner assez brusquement pour atteindre sa cible. Si turnSpeed est trop faible, -un personnage tournera en rond autour de sa cible sans jamais l'atteindre. -Je n'ai pas pris la peine de faire les calculs pour déterminer la vitesse de rotation requise -pour une vitesse de déplacement donnée. J'ai juste deviné.

-

Le code jusqu'à présent fonctionnerait, mais si le joueur sort de l'écran, il n'y a -aucun moyen de savoir où il se trouve. Faisons en sorte que s'il est hors écran -pendant plus d'un certain temps, il soit téléporté à l'origine. -Nous pouvons le faire en utilisant la classe Frustum de three.js pour vérifier si un point -est à l'intérieur du frustum de vue de la caméra.

-

Nous devons construire un frustum à partir de la caméra. Nous pourrions le faire dans le composant Player, -mais d'autres objets pourraient vouloir l'utiliser également, alors ajoutons un autre -gameobject avec un composant pour gérer un frustum.

-
class CameraInfo extends Component {
-  constructor(gameObject) {
-    super(gameObject);
-    this.projScreenMatrix = new THREE.Matrix4();
-    this.frustum = new THREE.Frustum();
-  }
-  update() {
-    const {camera} = globals;
-    this.projScreenMatrix.multiplyMatrices(
-        camera.projectionMatrix,
-        camera.matrixWorldInverse);
-    this.frustum.setFromProjectionMatrix(this.projScreenMatrix);
-  }
-}
-
-

Configurons ensuite un autre gameobject au moment de l'initialisation.

-
function init() {
-  // masquer la barre de chargement
-  const loadingElem = document.querySelector('#loading');
-  loadingElem.style.display = 'none';
-
-  prepModelsAndAnimations();
-
-+  {
-+    const gameObject = gameObjectManager.createGameObject(camera, 'camera');
-+    globals.cameraInfo = gameObject.addComponent(CameraInfo);
-+  }
-
-  {
-    const gameObject = gameObjectManager.createGameObject(scene, 'player');
-    gameObject.addComponent(Player);
-  }
-}
-
-

et maintenant nous pouvons l'utiliser dans le composant Player.

-
class Player extends Component {
-  constructor(gameObject) {
-    super(gameObject);
-    const model = models.knight;
-    this.skinInstance = gameObject.addComponent(SkinInstance, model);
-    this.skinInstance.setAnimation('Run');
-    this.turnSpeed = globals.moveSpeed / 4;
-+    this.offscreenTimer = 0;
-+    this.maxTimeOffScreen = 3;
-  }
-  update() {
--    const {deltaTime, moveSpeed} = globals;
-+    const {deltaTime, moveSpeed, cameraInfo} = globals;
-    const {transform} = this.gameObject;
-    const delta = (inputManager.keys.left.down  ?  1 : 0) +
-                  (inputManager.keys.right.down ? -1 : 0);
-    transform.rotation.y += this.turnSpeed * delta * deltaTime;
-    transform.translateOnAxis(kForward, moveSpeed * deltaTime);
-
-+    const {frustum} = cameraInfo;
-+    if (frustum.containsPoint(transform.position)) {
-+      this.offscreenTimer = 0;
-+    } else {
-+      this.offscreenTimer += deltaTime;
-+      if (this.offscreenTimer >= this.maxTimeOffScreen) {
-+        transform.position.set(0, 0, 0);
-+      }
-+    }
-  }
-}
-
-

Une dernière chose avant d'essayer, ajoutons le support des écrans tactiles -pour mobile. Tout d'abord, ajoutons un peu de code HTML pour le toucher

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="ui">
-+    <div id="left"><img src="../resources/images/left.svg"></div>
-+    <div style="flex: 0 0 40px;"></div>
-+    <div id="right"><img src="../resources/images/right.svg"></div>
-+  </div>
-  <div id="loading">
-    <div>
-      <div>...chargement...</div>
-      <div class="progress"><div id="progressbar"></div></div>
-    </div>
-  </div>
-+  <div id="labels"></div>
-</body>
-
-

et un peu de CSS pour le styler

-
#ui {
-  position: absolute;
-  left: 0;
-  top: 0;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  justify-items: center;
-  align-content: stretch;
-}
-#ui>div {
-  display: flex;
-  align-items: flex-end;
-  flex: 1 1 auto;
-}
-.bright {
-  filter: brightness(2);
-}
-#left {
-  justify-content: flex-end;
-}
-#right {
-  justify-content: flex-start;
-}
-#ui img {
-  padding: 10px;
-  width: 80px;
-  height: 80px;
-  display: block;
-}
-#labels {
-  position: absolute;  /* nous permet de nous positionner à l'intérieur du conteneur */
-  left: 0;             /* fait que notre position est en haut à gauche du conteneur */
-  top: 0;
-  color: white;
-  width: 100%;
-  height: 100%;
-  overflow: hidden;
-  pointer-events: none;
-}
-#labels>div {
-  position: absolute;  /* nous permet de les positionner à l'intérieur du conteneur */
-  left: 0;             /* fait que leur position par défaut est en haut à gauche du conteneur */
-  top: 0;
-  font-size: large;
-  font-family: monospace;
-  user-select: none;   /* n'autorise pas la sélection du texte */
-  text-shadow:         /* crée un contour noir */
-    -1px -1px 0 #000,
-     0   -1px 0 #000,
-     1px -1px 0 #000,
-     1px  0   0 #000,
-     1px  1px 0 #000,
-     0    1px 0 #000,
-    -1px  1px 0 #000,
-    -1px  0   0 #000;
-}
-
-

L'idée ici est d'avoir une div, #ui, qui -couvre toute la page. À l'intérieur, il y aura 2 divs, #left et #right, -chacune occupant près de la moitié de la largeur de la page et toute la hauteur de l'écran. -Entre les deux, il y a un séparateur de 40px. Si l'utilisateur glisse son doigt -sur le côté gauche ou droit, nous devons mettre à jour keys.left et keys.right -dans l'InputManager. Cela rend tout l'écran sensible au toucher, -ce qui semble mieux que de simples petites flèches.

-
class InputManager {
-  constructor() {
-    this.keys = {};
-    const keyMap = new Map();
-
-    const setKey = (keyName, pressed) => {
-      const keyState = this.keys[keyName];
-      keyState.justPressed = pressed && !keyState.down;
-      keyState.down = pressed;
-    };
-
-    const addKey = (keyCode, name) => {
-      this.keys[name] = { down: false, justPressed: false };
-      keyMap.set(keyCode, name);
-    };
-
-    const setKeyFromKeyCode = (keyCode, pressed) => {
-      const keyName = keyMap.get(keyCode);
-      if (!keyName) {
-        return;
-      }
-      setKey(keyName, pressed);
-    };
-
-    addKey(37, 'left');
-    addKey(39, 'right');
-    addKey(38, 'up');
-    addKey(40, 'down');
-    addKey(90, 'a');
-    addKey(88, 'b');
-
-    window.addEventListener('keydown', (e) => {
-      setKeyFromKeyCode(e.keyCode, true);
-    });
-    window.addEventListener('keyup', (e) => {
-      setKeyFromKeyCode(e.keyCode, false);
-    });
-
-+    const sides = [
-+      { elem: document.querySelector('#left'),  key: 'left'  },
-+      { elem: document.querySelector('#right'), key: 'right' },
-+    ];
-+
-+    const clearKeys = () => {
-+      for (const {key} of sides) {
-+          setKey(key, false);
-+      }
-+    };
-+
-+    const handleMouseMove = (e) => {
-+      e.preventDefault();
-+      // ceci est nécessaire car nous appelons preventDefault();
-+      // nous avons également donné au canvas un tabindex afin qu'il puisse
-+      // obtenir le focus
-+      canvas.focus();
-+      window.addEventListener('pointermove', handleMouseMove);
-+      window.addEventListener('pointerup', handleMouseUp);
-+
-+      for (const {elem, key} of sides) {
-+        let pressed = false;
-+        const rect = elem.getBoundingClientRect();
-+        const x = e.clientX;
-+        const y = e.clientY;
-+        const inRect = x >= rect.left && x < rect.right &&
-+                       y >= rect.top && y < rect.bottom;
-+        if (inRect) {
-+          pressed = true;
-+        }
-+        setKey(key, pressed);
-+      }
-+    };
-+
-+    function handleMouseUp() {
-+      clearKeys();
-+      window.removeEventListener('pointermove', handleMouseMove, {passive: false});
-+      window.removeEventListener('pointerup', handleMouseUp);
-+    }
-+
-+    const uiElem = document.querySelector('#ui');
-+    uiElem.addEventListener('pointerdown', handleMouseMove, {passive: false});
-+
-+    uiElem.addEventListener('touchstart', (e) => {
-+      // empêcher le défilement
-+      e.preventDefault();
-+    }, {passive: false});
-  }
-  update() {
-    for (const keyState of Object.values(this.keys)) {
-      if (keyState.justPressed) {
-        keyState.justPressed = false;
-      }
-    }
-  }
-}
-
-

Et maintenant, nous devrions pouvoir contrôler le personnage avec les touches curseur gauche et droite -ou avec nos doigts sur un écran tactile.

-

- -

-

Idéalement, nous ferions quelque chose d'autre si le joueur sortait de l'écran, comme déplacer -la caméra ou peut-être considérer que hors écran = mort, mais cet article va déjà être -trop long, donc pour l'instant, se téléporter au milieu était la chose la plus simple.

-

Ajoutons quelques animaux. Nous pouvons commencer de manière similaire au Player en créant -un composant Animal.

-
class Animal extends Component {
-  constructor(gameObject, model) {
-    super(gameObject);
-    const skinInstance = gameObject.addComponent(SkinInstance, model);
-    skinInstance.mixer.timeScale = globals.moveSpeed / 4;
-    skinInstance.setAnimation('Idle');
-  }
-}
-
-

Le code ci-dessus définit le AnimationMixer.timeScale pour régler la vitesse de lecture -des animations par rapport à la vitesse de déplacement. De cette façon, si nous -ajustons la vitesse de déplacement, l'animation accélérera ou ralentira également.

-

Pour commencer, nous pourrions configurer un animal de chaque type

-
function init() {
-  // masquer la barre de chargement
-  const loadingElem = document.querySelector('#loading');
-  loadingElem.style.display = 'none';
-
-  prepModelsAndAnimations();
-  {
-    const gameObject = gameObjectManager.createGameObject(camera, 'camera');
-    globals.cameraInfo = gameObject.addComponent(CameraInfo);
-  }
-
-  {
-    const gameObject = gameObjectManager.createGameObject(scene, 'player');
-    globals.player = gameObject.addComponent(Player);
-    globals.congaLine = [gameObject];
-  }
-
-+  const animalModelNames = [
-+    'pig',
-+    'cow',
-+    'llama',
-+    'pug',
-+    'sheep',
-+    'zebra',
-+    'horse',
-+  ];
-+  animalModelNames.forEach((name, ndx) => {
-+    const gameObject = gameObjectManager.createGameObject(scene, name);
-+    gameObject.addComponent(Animal, models[name]);
-+    gameObject.transform.position.x = (ndx + 1) * 5;
-+  });
-}
-
-

Et cela nous donnerait des animaux debout à l'écran, mais nous voulons qu'ils fassent quelque chose.

-

Faisons en sorte qu'ils suivent le joueur en file indienne, mais seulement si le joueur s'approche suffisamment. -Pour cela, nous avons besoin de plusieurs états.

-
    -
  • Inactif :

    -

    L'animal attend que le joueur s'approche

    -
  • -
  • Attendre la fin de la ligne :

    -

    L'animal a été "tagué" par le joueur, mais doit maintenant attendre que l'animal -au bout de la ligne arrive pour pouvoir rejoindre la fin de la ligne.

    -
  • -
  • Aller au dernier :

    -

    L'animal doit marcher jusqu'à l'endroit où se trouvait l'animal qu'il suit, -tout en enregistrant un historique de la position actuelle de l'animal qu'il suit.

    -
  • -
  • Suivre

    -

    L'animal doit continuer à enregistrer un historique de la position de l'animal qu'il suit -tout en se déplaçant vers l'endroit où se trouvait cet animal auparavant.

    -
  • -
-

Il existe de nombreuses façons de gérer différents états comme ceux-ci. Une méthode courante consiste à utiliser -une machine à états finis (Finite State Machine) et -à construire une classe pour nous aider à gérer l'état.

-

Alors, faisons cela.

-
class FiniteStateMachine {
-  constructor(states, initialState) {
-    this.states = states;
-    this.transition(initialState);
-  }
-  get state() {
-    return this.currentState;
-  }
-  transition(state) {
-    const oldState = this.states[this.currentState];
-    if (oldState && oldState.exit) {
-      oldState.exit.call(this);
-    }
-    this.currentState = state;
-    const newState = this.states[state];
-    if (newState.enter) {
-      newState.enter.call(this);
-    }
-  }
-  update() {
-    const state = this.states[this.currentState];
-    if (state.update) {
-      state.update.call(this);
-    }
-  }
-}
-
-

Voici une classe simple. Nous lui passons un objet contenant un ensemble d'états. -Chaque état a 3 fonctions optionnelles : enter, update et exit. -Pour changer d'état, nous appelons FiniteStateMachine.transition et lui passons -le nom du nouvel état. Si l'état actuel a une fonction exit, -elle est appelée. Ensuite, si le nouvel état a une fonction enter, -elle est appelée. Enfin, à chaque image, FiniteStateMachine.update appelle la fonction update -de l'état actuel.

-

Utilisons-le pour gérer les états des animaux.

-
// Retourne vrai si obj1 et obj2 sont proches
-function isClose(obj1, obj1Radius, obj2, obj2Radius) {
-  const minDist = obj1Radius + obj2Radius;
-  const dist = obj1.position.distanceTo(obj2.position);
-  return dist < minDist;
-}
-
-// maintient v entre -min et +min
-function minMagnitude(v, min) {
-  return Math.abs(v) > min
-      ? min * Math.sign(v)
-      : v;
-}
-
-const aimTowardAndGetDistance = function() {
-  const delta = new THREE.Vector3();
-
-  return function aimTowardAndGetDistance(source, targetPos, maxTurn) {
-    delta.subVectors(targetPos, source.position);
-    // calculer la direction dans laquelle nous voulons faire face
-    const targetRot = Math.atan2(delta.x, delta.z) + Math.PI * 1.5;
-    // tourner dans la direction la plus courte
-    const deltaRot = (targetRot - source.rotation.y + Math.PI * 1.5) % (Math.PI * 2) - Math.PI;
-    // s'assurer que nous ne tournons pas plus vite que maxTurn
-    const deltaRotation = minMagnitude(deltaRot, maxTurn);
-    // maintenir la rotation entre 0 et Math.PI * 2
-    source.rotation.y = THREE.MathUtils.euclideanModulo(
-        source.rotation.y + deltaRotation, Math.PI * 2);
-    // retourner la distance à la cible
-    return delta.length();
-  };
-}();
-
-class Animal extends Component {
-  constructor(gameObject, model) {
-    super(gameObject);
-+    const hitRadius = model.size / 2;
-    const skinInstance = gameObject.addComponent(SkinInstance, model);
-    skinInstance.mixer.timeScale = globals.moveSpeed / 4;
-+    const transform = gameObject.transform;
-+    const playerTransform = globals.player.gameObject.transform;
-+    const maxTurnSpeed = Math.PI * (globals.moveSpeed / 4);
-+    const targetHistory = [];
-+    let targetNdx = 0;
-+
-+    function addHistory() {
-+      const targetGO = globals.congaLine[targetNdx];
-+      const newTargetPos = new THREE.Vector3();
-+      newTargetPos.copy(targetGO.transform.position);
-+      targetHistory.push(newTargetPos);
-+    }
-+
-+    this.fsm = new FiniteStateMachine({
-+      idle: {
-+        enter: () => {
-+          skinInstance.setAnimation('Idle');
-+        },
-+        update: () => {
-+          // vérifier si le joueur est proche
-+          if (isClose(transform, hitRadius, playerTransform, globals.playerRadius)) {
-+            this.fsm.transition('waitForEnd');
-+          }
-+        },
-+      },
-+      waitForEnd: {
-+        enter: () => {
-+          skinInstance.setAnimation('Jump');
-+        },
-+        update: () => {
-+          // obtenir le gameObject à la fin de la file indienne
-+          const lastGO = globals.congaLine[globals.congaLine.length - 1];
-+          const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
-+          const targetPos = lastGO.transform.position;
-+          aimTowardAndGetDistance(transform, targetPos, deltaTurnSpeed);
-+          // vérifier si le dernier élément de la file indienne est proche
-+          if (isClose(transform, hitRadius, lastGO.transform, globals.playerRadius)) {
-+            this.fsm.transition('goToLast');
-+          }
-+        },
-+      },
-+      goToLast: {
-+        enter: () => {
-+          // se souvenir de qui nous suivons
-+          targetNdx = globals.congaLine.length - 1;
-+          // nous ajouter à la file indienne
-+          globals.congaLine.push(gameObject);
-+          skinInstance.setAnimation('Walk');
-+        },
-+        update: () => {
-+          addHistory();
-+          // marcher jusqu'au point le plus ancien de l'historique
-+          const targetPos = targetHistory[0];
-+          const maxVelocity = globals.moveSpeed * globals.deltaTime;
-+          const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
-+          const distance = aimTowardAndGetDistance(transform, targetPos, deltaTurnSpeed);
-+          const velocity = distance;
-+          transform.translateOnAxis(kForward, Math.min(velocity, maxVelocity));
-+          if (distance <= maxVelocity) {
-+            this.fsm.transition('follow');
-+          }
-+        },
-+      },
-+      follow: {
-+        update: () => {
-+          addHistory();
-+          // supprimer l'historique le plus ancien et nous placer simplement là.
-+          const targetPos = targetHistory.shift();
-+          transform.position.copy(targetPos);
-+          const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
-+          aimTowardAndGetDistance(transform, targetHistory[0], deltaTurnSpeed);
-+        },
-+      },
-+    }, 'idle');
-+  }
-+  update() {
-+    this.fsm.update();
-+  }
-}
-
-

C'était un gros morceau de code, mais il fait ce qui a été décrit ci-dessus. -J'espère que si vous parcourez chaque état, ce sera clair.

-

Quelques choses que nous devons ajouter. Nous devons faire en sorte que le joueur s'ajoute -aux variables globales afin que les animaux puissent le trouver, et nous devons commencer la -file indienne avec le GameObject du joueur.

-
function init() {
-
-  ...
-
-  {
-    const gameObject = gameObjectManager.createGameObject(scene, 'player');
-+    globals.player = gameObject.addComponent(Player);
-+    globals.congaLine = [gameObject];
-  }
-
-}
-
-

Nous devons également calculer une taille pour chaque modèle

-
function prepModelsAndAnimations() {
-+  const box = new THREE.Box3();
-+  const size = new THREE.Vector3();
-  Object.values(models).forEach(model => {
-+    box.setFromObject(model.gltf.scene);
-+    box.getSize(size);
-+    model.size = size.length();
-    const animsByName = {};
-    model.gltf.animations.forEach((clip) => {
-      animsByName[clip.name] = clip;
-      // Devrait vraiment être corrigé dans le fichier .blend
-      if (clip.name === 'Walk') {
-        clip.duration /= 2;
-      }
-    });
-    model.animations = animsByName;
-  });
-}
-
-

Et nous avons besoin que le joueur enregistre sa taille

-
class Player extends Component {
-  constructor(gameObject) {
-    super(gameObject);
-    const model = models.knight;
-+    globals.playerRadius = model.size / 2;
-
-

En y pensant maintenant, il aurait probablement été plus judicieux -que les animaux ciblent simplement la tête de la file indienne -au lieu du joueur spécifiquement. Peut-être que je reviendrai dessus -et modifierai cela plus tard.

-

Lorsque j'ai commencé cela, j'ai utilisé un seul rayon pour tous les animaux, -mais bien sûr, ce n'était pas bon, car le carlin est beaucoup plus petit que le cheval. -J'ai donc ajouté les différentes tailles, mais je voulais pouvoir visualiser -les choses. Pour ce faire, j'ai créé un composant StateDisplayHelper.

-

J'utilise un PolarGridHelper pour dessiner un cercle autour de chaque personnage, -et il utilise des éléments html pour permettre à chaque personnage d'afficher un certain statut en utilisant -les techniques couvertes dans l'article sur l'alignement des éléments html en 3D.

-

Nous devons d'abord ajouter du code HTML pour héberger ces éléments

-
<body>
-  <canvas id="c"></canvas>
-  <div id="ui">
-    <div id="left"><img src="../resources/images/left.svg"></div>
-    <div style="flex: 0 0 40px;"></div>
-    <div id="right"><img src="../resources/images/right.svg"></div>
-  </div>
-  <div id="loading">
-    <div>
-      <div>...chargement...</div>
-      <div class="progress"><div id="progressbar"></div></div>
-    </div>
-  </div>
-+  <div id="labels"></div>
-</body>
-
-

Et ajouter du CSS pour eux

-
#ui {
-  position: absolute;
-  left: 0;
-  top: 0;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  justify-items: center;
-  align-content: stretch;
-}
-#ui>div {
-  display: flex;
-  align-items: flex-end;
-  flex: 1 1 auto;
-}
-.bright {
-  filter: brightness(2);
-}
-#left {
-  justify-content: flex-end;
-}
-#right {
-  justify-content: flex-start;
-}
-#ui img {
-  padding: 10px;
-  width: 80px;
-  height: 80px;
-  display: block;
-}
-#labels {
-  position: absolute;  /* nous permet de nous positionner à l'intérieur du conteneur */
-  left: 0;             /* fait que notre position est en haut à gauche du conteneur */
-  top: 0;
-  color: white;
-  width: 100%;
-  height: 100%;
-  overflow: hidden;
-  pointer-events: none;
-}
-#labels>div {
-  position: absolute;  /* nous permet de les positionner à l'intérieur du conteneur */
-  left: 0;             /* fait que leur position par défaut est en haut à gauche du conteneur */
-  top: 0;
-  font-size: large;
-  font-family: monospace;
-  user-select: none;   /* n'autorise pas la sélection du texte */
-  text-shadow:         /* crée un contour noir */
-    -1px -1px 0 #000,
-     0   -1px 0 #000,
-     1px -1px 0 #000,
-     1px  0   0 #000,
-     1px  1px 0 #000,
-     0    1px 0 #000,
-    -1px  1px 0 #000,
-    -1px  0   0 #000;
-}
-
-

Voici ensuite le composant

-
const labelContainerElem = document.querySelector('#labels');
-
-class StateDisplayHelper extends Component {
-  constructor(gameObject, size) {
-    super(gameObject);
-    this.elem = document.createElement('div');
-    labelContainerElem.appendChild(this.elem);
-    this.pos = new THREE.Vector3();
-
-    this.helper = new THREE.PolarGridHelper(size / 2, 1, 1, 16);
-    gameObject.transform.add(this.helper);
-  }
-  setState(s) {
-    this.elem.textContent = s;
-  }
-  setColor(cssColor) {
-    this.elem.style.color = cssColor;
-    this.helper.material.color.set(cssColor);
-  }
-  update() {
-    const {pos} = this;
-    const {transform} = this.gameObject;
-    const {canvas} = globals;
-    pos.copy(transform.position);
-
-    // obtenir les coordonnées d'écran normalisées de cette position
-    // x et y seront dans la plage -1 à +1, avec x = -1 étant
-    // à gauche et y = -1 étant en bas
-    pos.project(globals.camera);
-
-    // convertir la position normalisée en coordonnées CSS
-    const x = (pos.x *  .5 + .5) * canvas.clientWidth;
-    const y = (pos.y * -.5 + .5) * canvas.clientHeight;
-
-    // déplacer l'élément à cette position
-    this.elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
-  }
-}
-
-

Et nous pouvons ensuite les ajouter aux animaux comme ceci

-
class Animal extends Component {
-  constructor(gameObject, model) {
-    super(gameObject);
-+    this.helper = gameObject.addComponent(StateDisplayHelper, model.size);
-
-     ...
-
-  }
-  update() {
-    this.fsm.update();
-+    const dir = THREE.MathUtils.radToDeg(this.gameObject.transform.rotation.y);
-+    this.helper.setState(`${this.fsm.state}:${dir.toFixed(0)}`);
-  }
-}
-
-

Pendant que nous y sommes, faisons en sorte que nous puissions les activer/désactiver en utilisant lil-gui, comme -nous l'avons fait ailleurs.

-
import * as THREE from 'three';
-import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
-import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
-+import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
-
-
+const gui = new GUI();
-+gui.add(globals, 'debug').onChange(showHideDebugInfo);
-+showHideDebugInfo();
-
-const labelContainerElem = document.querySelector('#labels');
-+function showHideDebugInfo() {
-+  labelContainerElem.style.display = globals.debug ? '' : 'none';
-+}
-+showHideDebugInfo();
-
-class StateDisplayHelper extends Component {
-
-  ...
-
-  update() {
-+    this.helper.visible = globals.debug;
-+    if (!globals.debug) {
-+      return;
-+    }
-
-    ...
-  }
-}
-
-

Et avec cela, nous obtenons une sorte de début de jeu.

-

- -

-

À l'origine, j'avais l'intention de créer un jeu de serpent -où, à mesure que vous ajoutez des animaux à votre ligne, cela devient plus difficile car vous devez éviter -de les heurter. J'aurais également placé des obstacles dans la scène et peut-être une clôture ou une sorte -de barrière autour du périmètre.

-

Malheureusement, les animaux sont longs et minces. Vu d'en haut, voici le zèbre.

-
- -

Le code jusqu'à présent utilise des collisions circulaires, ce qui signifie que si nous avions des obstacles comme une clôture, -cela serait considéré comme une collision

-
- -

Ce n'est pas bon. Même d'animal à animal, nous aurions le même problème.

-

J'ai pensé à écrire un système de collision rectangle à rectangle en 2D, mais j'ai -rapidement réalisé que cela pourrait vraiment représenter beaucoup de code. Vérifier si 2 boîtes -orientées arbitrairement se chevauchent n'est pas trop de code, et pour notre jeu avec seulement quelques -objets, cela pourrait fonctionner, mais en y regardant de plus près, après quelques objets, vous -commencez rapidement à avoir besoin d'optimiser la vérification des collisions. Tout d'abord, vous pourriez parcourir tous -les objets qui peuvent potentiellement entrer en collision les uns avec les autres et vérifier leurs -sphères englobantes, leurs cercles englobants ou leurs boîtes englobantes alignées sur les axes. Une fois que vous -savez quels objets pourraient entrer en collision, vous devez faire plus de travail pour vérifier s'ils -entrent réellement en collision. Souvent, même la vérification des sphères englobantes est trop -de travail, et vous avez besoin d'une sorte de meilleure structure spatiale pour les objets afin de pouvoir -vérifier plus rapidement uniquement les objets potentiellement proches les uns des autres.

-

Ensuite, une fois que vous avez écrit le code pour vérifier si 2 objets entrent en collision, vous voulez généralement -créer un système de collision plutôt que de demander manuellement "est-ce que je collisionne avec ces -objets". Un système de collision émet des événements ou appelle des callbacks en relation avec -les collisions. L'avantage est qu'il peut vérifier toutes les collisions en une seule fois, de sorte qu'aucun -objet n'est vérifié plus d'une fois, alors que si vous appelez manuellement une fonction -"est-ce que je collisionne", les objets sont souvent vérifiés plus d'une fois, ce qui fait perdre du temps.

-

Créer ce système de collision ne représenterait probablement pas plus de 100 à 300 lignes de code -pour vérifier uniquement les rectangles orientés arbitrairement, mais c'est toujours beaucoup plus de code, -il a donc semblé préférable de l'omettre.

-

Une autre solution aurait été d'essayer de trouver d'autres personnages qui sont majoritairement -circulaires vus du dessus. D'autres personnages humanoïdes par exemple, au lieu -d'animaux, auquel cas la vérification circulaire pourrait fonctionner d'animal à animal. -Cela ne fonctionnerait pas d'animal à clôture ; eh bien, nous devrions ajouter une vérification de cercle à rectangle. J'ai -pensé à faire de la clôture une clôture de buissons ou de poteaux, quelque chose de circulaire, -mais alors il me faudrait probablement 120 à 200 d'entre eux pour entourer la zone de jeu, -ce qui entraînerait les problèmes d'optimisation mentionnés ci-dessus.

-

Ce sont des raisons pour lesquelles de nombreux jeux utilisent une solution existante. Souvent, ces solutions -font partie d'une bibliothèque de physique. La bibliothèque de physique a besoin de savoir si les objets -entrent en collision les uns avec les autres, donc en plus de fournir la physique, elles peuvent également être utilisées -pour détecter les collisions.

-

Si vous cherchez une solution, certains exemples three.js utilisent -ammo.js, cela pourrait donc être une option.

-

Une autre solution aurait pu être de placer les obstacles sur une grille -et d'essayer de faire en sorte que chaque animal et le joueur n'aient qu'à regarder -la grille. Bien que cela serait performant, j'ai estimé qu'il valait mieux laisser -cela comme un exercice pour le lecteur 😜

-

Une chose de plus, de nombreux systèmes de jeu ont ce qu'on appelle des coroutines. -Les coroutines sont des routines qui peuvent se mettre en pause pendant l'exécution et reprendre plus tard.

-

Faisons en sorte que le personnage principal émette des notes de musique comme s'il dirigeait -la ligne en chantant. Il existe de nombreuses façons de mettre cela en œuvre, mais pour l'instant, -faisons-le en utilisant des coroutines.

-

Tout d'abord, voici une classe pour gérer les coroutines

-
function* waitSeconds(duration) {
-  while (duration > 0) {
-    duration -= globals.deltaTime;
-    yield;
-  }
-}
-
-class CoroutineRunner {
-  constructor() {
-    this.generatorStacks = [];
-    this.addQueue = [];
-    this.removeQueue = new Set();
-  }
-  isBusy() {
-    return this.addQueue.length + this.generatorStacks.length > 0;
-  }
-  add(generator, delay = 0) {
-    const genStack = [generator];
-    if (delay) {
-      genStack.push(waitSeconds(delay));
-    }
-    this.addQueue.push(genStack);
-  }
-  remove(generator) {
-    this.removeQueue.add(generator);
-  }
-  update() {
-    this._addQueued();
-    this._removeQueued();
-    for (const genStack of this.generatorStacks) {
-      const main = genStack[0];
-      // Gérer si une coroutine en supprime une autre
-      if (this.removeQueue.has(main)) {
-        continue;
-      }
-      while (genStack.length) {
-        const topGen = genStack[genStack.length - 1];
-        const {value, done} = topGen.next();
-        if (done) {
-          if (genStack.length === 1) {
-            this.removeQueue.add(topGen);
-            break;
-          }
-          genStack.pop();
-        } else if (value) {
-          genStack.push(value);
-        } else {
-          break;
-        }
-      }
-    }
-    this._removeQueued();
-  }
-  _addQueued() {
-    if (this.addQueue.length) {
-      this.generatorStacks.splice(this.generatorStacks.length, 0, ...this.addQueue);
-      this.addQueue = [];
-    }
-  }
-  _removeQueued() {
-    if (this.removeQueue.size) {
-      this.generatorStacks = this.generatorStacks.filter(genStack => !this.removeQueue.has(genStack[0]));
-      this.removeQueue.clear();
-    }
-  }
-}
-
-

Il fait des choses similaires à SafeArray pour s'assurer qu'il est sûr d'ajouter ou de supprimer -des coroutines pendant que d'autres coroutines s'exécutent. Il gère également les coroutines imbriquées.

-

Pour créer une coroutine, vous créez une fonction génératrice JavaScript. -Une fonction génératrice est précédée du mot-clé function* (l'astérisque est important !)

-

Les fonctions génératrices peuvent yield (céder). Par exemple

-
function* count0To9() {
-  for (let i = 0; i < 10; ++i) {
-    console.log(i);
-    yield;
-  }
-}
-
-

Si nous ajoutions cette fonction au CoroutineRunner ci-dessus, elle imprimerait -chaque nombre, de 0 à 9, une fois par image, ou plutôt une fois par appel de runner.update.

-
const runner = new CoroutineRunner();
-runner.add(count0To9);
-while(runner.isBusy()) {
-  runner.update();
-}
-
-

Les coroutines sont supprimées automatiquement lorsqu'elles sont terminées.

-

Pour supprimer une coroutine prématurément, avant qu'elle n'atteigne la fin, vous devez conserver -une référence à son générateur comme ceci

-
const gen = count0To9();
-runner.add(gen);
-
-// plus tard
-
-runner.remove(gen);
-
-

En tout cas, dans le joueur, utilisons une coroutine pour émettre une note toutes les demi-secondes à 1 seconde.

-
class Player extends Component {
-  constructor(gameObject) {
-
-    ...
-
-+    this.runner = new CoroutineRunner();
-+
-+    function* emitNotes() {
-+      for (;;) {
-+        yield waitSeconds(rand(0.5, 1));
-+        const noteGO = gameObjectManager.createGameObject(scene, 'note');
-+        noteGO.transform.position.copy(gameObject.transform.position);
-+        noteGO.transform.position.y += 5;
-+        noteGO.addComponent(Note);
-+      }
-+    }
-+
-+    this.runner.add(emitNotes());
-  }
-  update() {
-+    this.runner.update();
-
-  ...
-
-  }
-}
-
-function rand(min, max) {
-  if (max === undefined) {
-    max = min;
-    min = 0;
-  }
-  return Math.random() * (max - min) + min;
-}
-
-

Vous pouvez voir que nous créons un CoroutineRunner et ajoutons une coroutine emitNotes. -Cette fonction s'exécutera indéfiniment, attendant 0,5 à 1 seconde, puis créant un objet de jeu -avec un composant Note.

-

Pour le composant Note, créons d'abord une texture avec une note dessus et, -au lieu de charger une image de note, créons-en une à l'aide d'un canvas, comme nous l'avons vu dans l'article sur les textures de canvas.

-
function makeTextTexture(str) {
-  const ctx = document.createElement('canvas').getContext('2d');
-  ctx.canvas.width = 64;
-  ctx.canvas.height = 64;
-  ctx.font = '60px sans-serif';
-  ctx.textAlign = 'center';
-  ctx.textBaseline = 'middle';
-  ctx.fillStyle = '#FFF';
-  ctx.fillText(str, ctx.canvas.width / 2, ctx.canvas.height / 2);
-  return new THREE.CanvasTexture(ctx.canvas);
-}
-const noteTexture = makeTextTexture('♪');
-
-

La texture que nous créons ci-dessus est blanche, ce qui signifie que lorsque nous l'utilisons, -nous pouvons définir la couleur du matériau et obtenir une note de n'importe quelle couleur.

-

Maintenant que nous avons une texture de note, voici le composant Note. -Il utilise SpriteMaterial et un Sprite, comme nous l'avons vu dans -l'article sur les billboards

-
class Note extends Component {
-  constructor(gameObject) {
-    super(gameObject);
-    const {transform} = gameObject;
-    const noteMaterial = new THREE.SpriteMaterial({
-      color: new THREE.Color().setHSL(rand(1), 1, 0.5),
-      map: noteTexture,
-      side: THREE.DoubleSide,
-      transparent: true,
-    });
-    const note = new THREE.Sprite(noteMaterial);
-    note.scale.setScalar(3);
-    transform.add(note);
-    this.runner = new CoroutineRunner();
-    const direction = new THREE.Vector3(rand(-0.2, 0.2), 1, rand(-0.2, 0.2));
-
-    function* moveAndRemove() {
-      for (let i = 0; i < 60; ++i) {
-        transform.translateOnAxis(direction, globals.deltaTime * 10);
-        noteMaterial.opacity = 1 - (i / 60);
-        yield;
-      }
-      transform.parent.remove(transform);
-      gameObjectManager.removeGameObject(gameObject);
-    }
-
-    this.runner.add(moveAndRemove());
-  }
-  update() {
-    this.runner.update();
-  }
-}
-
-

Tout ce qu'il fait est de configurer un Sprite, puis de choisir une vitesse aléatoire et de déplacer -la transformation à cette vitesse pendant 60 images, tout en estompant la note -en définissant l'opacity du matériau. -Après la boucle, il supprime la transformation -de la scène et la note elle-même des gameobjects actifs.

-

Une dernière chose, ajoutons quelques animaux supplémentaires.

-
function init() {
-
-   ...
-
-  const animalModelNames = [
-    'pig',
-    'cow',
-    'llama',
-    'pug',
-    'sheep',
-    'zebra',
-    'horse',
-  ];
-+  const base = new THREE.Object3D();
-+  const offset = new THREE.Object3D();
-+  base.add(offset);
-+
-+  // positionner les animaux en spirale.
-+  const numAnimals = 28;
-+  const arc = 10;
-+  const b = 10 / (2 * Math.PI);
-+  let r = 10;
-+  let phi = r / b;
-+  for (let i = 0; i < numAnimals; ++i) {
-+    const name = animalModelNames[rand(animalModelNames.length) | 0];
-    const gameObject = gameObjectManager.createGameObject(scene, name);
-    gameObject.addComponent(Animal, models[name]);
-+    base.rotation.y = phi;
-+    offset.position.x = r;
-+    offset.updateWorldMatrix(true, false);
-+    offset.getWorldPosition(gameObject.transform.position);
-+    phi += arc / r;
-+    r = b * phi;
-  }
-
-

- -

-

Vous pourriez vous demander, pourquoi ne pas utiliser setTimeout ? Le problème avec setTimeout -est qu'il n'est pas lié à l'horloge du jeu. Par exemple, ci-dessus, nous avons défini le temps -maximum autorisé à s'écouler entre les images à 1/20ème de seconde. -Notre système de coroutine respectera cette limite, mais setTimeout ne le ferait pas.

-

Bien sûr, nous aurions pu créer un simple minuteur nous-mêmes

-
class Player ... {
-  update() {
-    this.noteTimer -= globals.deltaTime;
-    if (this.noteTimer <= 0) {
-      // réinitialiser le minuteur
-      this.noteTimer = rand(0.5, 1);
-      // créer un gameobject avec un composant de note
-    }
-  }
-
-

Et pour ce cas particulier, cela aurait peut-être été mieux, mais à mesure que vous ajoutez -de plus en plus de choses, vous aurez de plus en plus de variables ajoutées à vos classes, -alors qu'avec les coroutines, vous pouvez souvent simplement lancer et oublier.

-

Étant donné les états simples de nos animaux, nous aurions également pu les implémenter -avec une coroutine sous la forme de

-
// pseudo-code !
-function* animalCoroutine() {
-   setAnimation('Idle');
-   while(playerIsTooFar()) {
-     yield;
-   }
-   const target = endOfLine;
-   setAnimation('Jump');
-   while(targetIsTooFar()) {
-     aimAt(target);
-     yield;
-   }
-   setAnimation('Walk')
-   while(notAtOldestPositionOfTarget()) {
-     addHistory();
-     aimAt(target);
-     yield;
-   }
-   for(;;) {
-     addHistory();
-     const pos = history.unshift();
-     transform.position.copy(pos);
-     aimAt(history[0]);
-     yield;
-   }
-}
-
-

Cela aurait fonctionné, mais bien sûr, dès que nos états n'auraient pas été si linéaires, -nous aurions dû passer à une FiniteStateMachine.

-

Il ne m'était pas non plus clair si les coroutines devaient s'exécuter indépendamment de leurs -composants. Nous aurions pu créer un CoroutineRunner global et y placer toutes -les coroutines. Cela rendrait leur nettoyage plus difficile. En l'état actuel, -si le gameobject est supprimé, tous ses composants sont supprimés et -donc les CoroutineRunner créés ne sont plus appelés, et tout sera collecté par le -ramasse-miettes. Si nous avions un CoroutineRunner global, il incomberait alors -à chaque composant de supprimer toute coroutine qu'il aurait ajoutée, ou bien un autre -mécanisme d'enregistrement des coroutines auprès d'un composant ou d'un gameobject particulier -serait nécessaire afin que la suppression de l'un supprime les autres.

-

Il y a beaucoup d'autres problèmes qu'un -moteur de jeu normal gérerait. En l'état actuel, il n'y a pas d'ordre dans la façon dont -les gameobjects ou leurs composants sont exécutés. Ils sont simplement exécutés dans l'ordre -d'ajout. De nombreux systèmes de jeu ajoutent une priorité pour que l'ordre puisse être défini ou modifié.

-

Un autre problème que nous avons rencontré est que le composant Note supprime la transformation de son gameobject de la scène. -Cela semble être quelque chose qui devrait se produire dans GameObject puisque c'est GameObject -qui a ajouté la transformation en premier lieu. Peut-être que GameObject devrait avoir -une méthode dispose qui est appelée par GameObjectManager.removeGameObject ?

-

Encore un autre problème est la façon dont nous appelons manuellement gameObjectManager.update et inputManager.update. -Peut-être qu'il devrait y avoir un SystemManager auquel ces services globaux pourraient s'ajouter, -et chaque service aurait sa fonction update appelée. De cette façon, si nous ajoutions un nouveau -service comme CollisionManager, nous pourrions simplement l'ajouter au gestionnaire de système sans -avoir à modifier la boucle de rendu.

-

Je vous laisse le soin de régler ce genre de problèmes. -J'espère que cet article vous a donné quelques idées pour votre propre moteur de jeu.

-

Peut-être devrais-je promouvoir un game jam. Si vous cliquez sur les boutons jsfiddle ou codepen -au-dessus du dernier exemple, ils s'ouvriront sur ces sites prêts à être modifiés. Ajoutez des fonctionnalités, -changez le jeu pour qu'un carlin mène un groupe de chevaliers. Utilisez l'animation de roulade du chevalier -comme boule de bowling et faites un jeu de bowling avec des animaux. Faites une course de relais avec des animaux. -Si vous créez un jeu sympa, postez un lien dans les commentaires ci-dessous.

-
-[1] : techniquement, cela fonctionnerait toujours si aucun des parents n'a de translation, de rotation ou d'échelle §. -
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/how-to-create-vr-content.html b/manual/fr/how-to-create-vr-content.html deleted file mode 100644 index 355d550b5b4e2e..00000000000000 --- a/manual/fr/how-to-create-vr-content.html +++ /dev/null @@ -1,100 +0,0 @@ - - - Comment créer du contenu VR - - - - - - - - - - - - - -
-
-

Comment créer du contenu VR

-
-
-
- -

- Ce guide fournit un bref aperçu des composants de base d'une application VR basée sur le web réalisée avec three.js. -

- -

Flux de travail

- -

- Tout d'abord, vous devez inclure [link:https://github.com/mrdoob/three.js/blob/master/examples/jsm/webxr/VRButton.js VRButton.js] - dans votre projet. -

- -
-import { VRButton } from 'three/addons/webxr/VRButton.js';
-
- -

- *VRButton.createButton()* fait deux choses importantes : il crée un bouton qui indique la compatibilité VR. De plus, il initialise une session VR si l'utilisateur active le bouton. La seule chose que vous avez à faire est d'ajouter la ligne de code suivante à votre application. -

- -
-document.body.appendChild( VRButton.createButton( renderer ) );
-
- -

- Ensuite, vous devez indiquer à votre instance de `WebGLRenderer` d'activer le rendu XR. -

- -
-renderer.xr.enabled = true;
-
- -

- Enfin, vous devez ajuster votre boucle d'animation car nous ne pouvons pas utiliser notre fonction bien connue *window.requestAnimationFrame()*. Pour les projets VR, nous utilisons `renderer.setAnimationLoop()`. Le code minimal ressemble à ceci : -

- -
-renderer.setAnimationLoop( function () {
-
-  renderer.render( scene, camera );
-
-} );
-
- -

Étapes suivantes

- -

- Jetez un œil à l'un des exemples officiels de WebVR pour voir ce flux de travail en action.

- - [example:webxr_xr_ballshooter WebXR / XR / tireur de balles]
- [example:webxr_xr_cubes WebXR / XR / cubes]
- [example:webxr_xr_dragging WebXR / XR / glisser-déposer]
- [example:webxr_xr_marchingcubes WebXR / XR / marching cubes]
- [example:webxr_xr_paint WebXR / XR / peinture]
- [example:webxr_vr_panorama_depth WebXR / VR / panorama-profondeur]
- [example:webxr_vr_panorama WebXR / VR / panorama]
- [example:webxr_vr_rollercoaster WebXR / VR / montagnes russes]
- [example:webxr_vr_sandbox WebXR / VR / bac à sable]
- [example:webxr_vr_video WebXR / VR / vidéo] -

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/how-to-dispose-of-objects.html b/manual/fr/how-to-dispose-of-objects.html deleted file mode 100644 index 0ec48728ecd91c..00000000000000 --- a/manual/fr/how-to-dispose-of-objects.html +++ /dev/null @@ -1,169 +0,0 @@ - - - Comment se débarrasser des objets - - - - - - - - - - - - - -
-
-

Comment se débarrasser des objets

-
-
-
- -

- Un aspect important pour améliorer les performances et éviter les fuites de mémoire dans votre application est la libération des entités de la librairie inutilisées. - Chaque fois que vous créez une instance d'un type *three.js*, vous allouez une certaine quantité de mémoire. Cependant, *three.js* crée pour des objets spécifiques - comme les géométries ou les matériaux des entités liées à WebGL comme des tampons (buffers) ou des programmes de shaders qui sont nécessaires au rendu. Il est important de - souligner que ces objets ne sont pas libérés automatiquement. Au lieu de cela, l'application doit utiliser une API spéciale afin de libérer de telles ressources. - Ce guide donne un bref aperçu de la manière dont cette API est utilisée et des objets pertinents dans ce contexte. -

- -

Géométries

- -

- Une géométrie représente généralement les informations de vertex définies comme une collection d'attributs. *three.js* crée en interne un objet de type [link:https://developer.mozilla.org/en-US/docs/Web/API/WebGLBuffer WebGLBuffer] - pour chaque attribut. Ces entités ne sont supprimées que si vous appelez `BufferGeometry.dispose()`. Si une géométrie devient obsolète dans votre application, - exécutez la méthode pour libérer toutes les ressources associées. -

- -

Matériaux

- -

- Un matériau définit la manière dont les objets sont rendus. *three.js* utilise les informations d'une définition de matériau afin de construire un programme de shader pour le rendu. - Les programmes de shader ne peuvent être supprimés que si le matériau respectif est libéré. Pour des raisons de performance, *three.js* essaie de réutiliser les - programmes de shader existants si possible. Ainsi, un programme de shader n'est supprimé que si tous les matériaux associés sont libérés. Vous pouvez indiquer la libération d'un matériau en - exécutant `Material.dispose()`. -

- -

Textures

- -

- La libération d'un matériau n'a aucun effet sur les textures. Elles sont gérées séparément car une seule texture peut être utilisée par plusieurs matériaux en même temps. - Chaque fois que vous créez une instance de `Texture`, three.js crée en interne une instance de [link:https://developer.mozilla.org/en-US/docs/Web/API/WebGLTexture WebGLTexture]. - Comme pour les tampons (buffers), cet objet ne peut être supprimé qu'en appelant `Texture.dispose()`. -

- -

- Si vous utilisez un `ImageBitmap` comme source de données de la texture, vous devez appeler [link:https://developer.mozilla.org/en-US/docs/Web/API/ImageBitmap/close ImageBitmap.close]() au niveau de l'application pour libérer toutes les ressources côté CPU. - Un appel automatique de `ImageBitmap.close()` dans `Texture.dispose()` n'est pas possible, car l'image bitmap devient inutilisable, et le moteur n'a aucun moyen de savoir si l'image bitmap est utilisée ailleurs. -

- -

Cibles de rendu

- -

- Les objets de type `WebGLRenderTarget` allouent non seulement une instance de [link:https://developer.mozilla.org/en-US/docs/Web/API/WebGLTexture WebGLTexture] mais aussi - des [link:https://developer.mozilla.org/en-US/docs/Web/API/WebGLFramebuffer WebGLFramebuffer] et des [link:https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderbuffer WebGLRenderbuffer] - pour réaliser des destinations de rendu personnalisées. Ces objets ne sont désalloués qu'en exécutant `WebGLRenderTarget.dispose()`. -

- -

Mesh skinné

- -

- Les meshes skinnés représentent leur hiérarchie d'os comme des squelettes. Si vous n'avez plus besoin d'un mesh skinné, envisagez d'appeler `Skeleton.dispose()` sur le squelette pour libérer les ressources internes. - Gardez à l'esprit que les squelettes peuvent être partagés entre plusieurs meshes skinnés, n'appelez donc `dispose()` que si le squelette n'est pas utilisé par d'autres meshes skinnés actifs. -

- -

Divers

- -

- Il existe d'autres classes dans le répertoire d'exemples, comme les contrôles ou les passes de post-traitement, qui fournissent des méthodes `dispose()` afin de supprimer les écouteurs d'événements internes - ou les cibles de rendu. En général, il est recommandé de vérifier l'API ou la documentation d'une classe et de rechercher `dispose()`. Si présent, vous devriez l'utiliser lors du nettoyage. -

- -

FAQ

- -

Pourquoi *three.js* ne peut-il pas libérer les objets automatiquement ?

- -

- Cette question a été posée de nombreuses fois par la communauté, il est donc important de clarifier ce point. Le fait est que *three.js* ne connaît pas la durée de vie ou la portée - des entités créées par l'utilisateur, comme les géométries ou les matériaux. C'est la responsabilité de l'application. Par exemple, même si un matériau n'est actuellement pas utilisé pour le rendu, - il pourrait être nécessaire pour la prochaine image. Donc, si l'application décide qu'un certain objet peut être supprimé, elle doit en informer le moteur en appelant la méthode - `dispose()` respective. -

- -

La suppression d'un mesh de la scène libère-t-elle également sa géométrie et son matériau ?

- -

- Non, vous devez explicitement libérer la géométrie et le matériau via *dispose()*. Gardez à l'esprit que les géométries et les matériaux peuvent être partagés entre des objets 3D comme les meshes. -

- -

*three.js* fournit-il des informations sur la quantité d'objets mis en cache ?

- -

- Oui. Il est possible d'évaluer `renderer.info`, une propriété spéciale du renderer avec une série d'informations statistiques sur la mémoire de la carte graphique - et le processus de rendu. Entre autres choses, elle vous indique combien de textures, de géométries et de programmes de shader sont stockés en interne. Si vous remarquez des problèmes de performance - dans votre application, c'est une bonne idée de déboguer cette propriété afin d'identifier facilement une fuite de mémoire. -

- -

Que se passe-t-il lorsque vous appelez `dispose()` sur une texture mais que l'image n'est pas encore chargée ?

- -

- Les ressources internes d'une texture ne sont allouées que si l'image est entièrement chargée. Si vous libérez une texture avant que l'image ne soit chargée, - rien ne se passe. Aucune ressource n'a été allouée, il n'y a donc pas besoin de nettoyage. -

- -

Que se passe-t-il si j'appelle `dispose()` puis utilise l'objet respectif ultérieurement ?

- -

- Cela dépend. Pour les géométries, les matériaux, les textures, les cibles de rendu et les passes de post-traitement, les ressources internes supprimées peuvent être recréées par le moteur. - Aucune erreur d'exécution ne se produira donc, mais vous pourriez remarquer un impact négatif sur les performances pour l'image actuelle, surtout lorsque les programmes de shader doivent être compilés. - - Les contrôles et les renderers sont une exception. Les instances de ces classes ne peuvent pas être utilisées après que `dispose()` a été appelée. Vous devez créer de nouvelles instances dans ce cas. -

- -

Comment gérer les objets *three.js* dans mon application ? Quand savoir comment libérer les choses ?

- -

- En général, il n'y a pas de recommandation définitive pour cela. Cela dépend fortement du cas d'utilisation spécifique pour savoir quand appeler `dispose()` est approprié. Il est important de souligner que - il n'est pas toujours nécessaire de libérer les objets en permanence. Un bon exemple est un jeu composé de plusieurs niveaux. Un bon moment pour la libération des objets est lors du changement de niveau. - L'application pourrait parcourir l'ancienne scène et libérer tous les matériaux, géométries et textures obsolètes. Comme mentionné dans la section précédente, cela ne produit pas - d'erreur d'exécution si vous libérez un objet qui est en fait toujours utilisé. Le pire qui puisse arriver est une chute de performance pour une seule image. -

- -

Pourquoi `renderer.info.memory` rapporte toujours des géométries et des textures après avoir parcouru la scène et libéré toutes les textures et géométries accessibles ?

- -

- Dans certains cas, certaines textures et géométries utilisées en interne par Three.js - ne sont pas accessibles lors de la traversée du graphe de scène afin d'être libérées. - Il est prévu que `renderer.info.memory` les signale toujours même après un nettoyage complet de la scène. - Cependant, elles ne fuient pas, mais sont réutilisées lors des cycles consécutifs de nettoyage/repopulation de la scène. - - Ces cas peuvent être liés à l'utilisation de `material.envMap`, `scene.background`, `scene.environment`, - ou d'autres contextes qui nécessitent que le moteur crée des textures ou des géométries pour un usage interne. -

- -

Exemples illustrant l'utilisation de dispose()

- -

- [example:webgl_test_memory WebGL / test / mémoire]
- [example:webgl_test_memory2 WebGL / test / mémoire2]
-

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/how-to-update-things.html b/manual/fr/how-to-update-things.html deleted file mode 100644 index 8be9e69ff74eb0..00000000000000 --- a/manual/fr/how-to-update-things.html +++ /dev/null @@ -1,275 +0,0 @@ - - - Comment mettre à jour les éléments - - - - - - - - - - - - - -
-
-

Comment mettre à jour les éléments

-
-
-
- -
-

Par défaut, tous les objets mettent automatiquement à jour leurs matrices s'ils ont été ajoutés à la scène avec

-
-const object = new THREE.Object3D();
-scene.add( object );
-
- ou s'ils sont l'enfant d'un autre objet qui a été ajouté à la scène : -
-const object1 = new THREE.Object3D();
-const object2 = new THREE.Object3D();
-
-object1.add( object2 );
-scene.add( object1 ); //object1 et object2 mettront automatiquement à jour leurs matrices
-
-
- -

Cependant, si vous savez que l'objet sera statique, vous pouvez désactiver cela et mettre à jour la matrice de transformation manuellement uniquement lorsque nécessaire.

- -
-object.matrixAutoUpdate = false;
-object.updateMatrix();
-
- -

BufferGeometry

-
-

- Les BufferGeometries stockent des informations (telles que les positions des sommets, les indices des faces, les normales, les couleurs, - les UV et tout attribut personnalisé) dans des tampons d'attributs - c'est-à-dire des - [link:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Typed_arrays tableaux typés]. - Cela les rend généralement plus rapides que les Geometries standard, au prix d'être un peu plus difficiles à - utiliser. -

-

- En ce qui concerne la mise à jour des BufferGeometries, la chose la plus importante à comprendre est que - vous ne pouvez pas redimensionner les tampons (c'est très coûteux, c'est fondamentalement l'équivalent de la création d'une nouvelle géométrie). - Vous pouvez cependant mettre à jour le contenu des tampons. -

-

- Cela signifie que si vous savez qu'un attribut de votre BufferGeometry va croître, par exemple le nombre de sommets, - vous devez pré-allouer un tampon suffisamment grand pour contenir tous les nouveaux sommets qui pourraient être créés. Bien sûr, - cela signifie également qu'il y aura une taille maximale pour votre BufferGeometry - il n'y a - aucun moyen de créer une BufferGeometry qui puisse être étendue efficacement indéfiniment. -

-

- Nous utiliserons l'exemple d'une ligne qui s'étend au moment du rendu. Nous allouerons de l'espace - dans le tampon pour 500 sommets, mais n'en dessinerons que deux au début, en utilisant `BufferGeometry.drawRange`. -

-
-const MAX_POINTS = 500;
-
-// geometry
-const geometry = new THREE.BufferGeometry();
-
-// attributes
-const positions = new Float32Array( MAX_POINTS * 3 ); // 3 floats (x, y et z) par point
-geometry.setAttribute( 'position', new THREE.BufferAttribute( positions, 3 ) );
-
-// draw range
-const drawCount = 2; // dessine seulement les 2 premiers points, seulement
-geometry.setDrawRange( 0, drawCount );
-
-// material
-const material = new THREE.LineBasicMaterial( { color: 0xff0000 } );
-
-// line
-const line = new THREE.Line( geometry, material );
-scene.add( line );
-
-

- Ensuite, nous ajouterons aléatoirement des points à la ligne en utilisant un modèle comme : -

-
-const positionAttribute = line.geometry.getAttribute( 'position' );
-
-let x = 0, y = 0, z = 0;
-
-for ( let i = 0; i < positionAttribute.count; i ++ ) {
-
-    positionAttribute.setXYZ( i, x, y, z );
-
-    x += ( Math.random() - 0.5 ) * 30;
-    y += ( Math.random() - 0.5 ) * 30;
-    z += ( Math.random() - 0.5 ) * 30;
-
-}
-
-

- Si vous souhaitez modifier le nombre de points rendus après le premier rendu, faites ceci : -

-
-line.geometry.setDrawRange( 0, newValue );
-
-

- Si vous souhaitez modifier les valeurs des données de position après le premier rendu, vous devez - définir le drapeau needsUpdate comme suit : -

-
-positionAttribute.needsUpdate = true; // requis après le premier rendu
-
- -

- Si vous modifiez les valeurs des données de position après le rendu initial, vous pourriez avoir besoin de recalculer - les volumes englobants afin que d'autres fonctionnalités du moteur comme le culling par frustum de vue ou les assistants fonctionnent correctement. -

-
-line.geometry.computeBoundingBox();
-line.geometry.computeBoundingSphere();
-
- -

- [link:https://jsfiddle.net/t4m85pLr/1/ Voici un fiddle] montrant une ligne animée que vous pouvez adapter à votre cas d'utilisation. -

- -

Exemples

- -

- [example:webgl_custom_attributes WebGL / personnalisé / attributs]
- [example:webgl_buffergeometry_custom_attributes_particles WebGL / buffergeometry / personnalisé / attributs / particules] -

- -
- -

Matériaux

-
-

Toutes les valeurs des uniforms peuvent être modifiées librement (par exemple couleurs, textures, opacité, etc.), les valeurs sont envoyées au shader à chaque image.

- -

De plus, les paramètres liés à l'état GL peuvent changer à tout moment (depthTest, blending, polygonOffset, etc.).

- -

Les propriétés suivantes ne peuvent pas être facilement modifiées à l'exécution (une fois que le matériau a été rendu au moins une fois) :

-
    -
  • nombre et types des uniforms
  • -
  • présence ou non de -
      -
    • texture
    • -
    • brouillard
    • -
    • couleurs de sommet
    • -
    • morphing
    • -
    • shadow map
    • -
    • test alpha
    • -
    • transparent
    • -
    -
  • -
- -

Les modifications de ces éléments nécessitent la construction d'un nouveau programme de shader. Vous devrez définir

- material.needsUpdate = true - -

Gardez à l'esprit que cela peut être assez lent et provoquer des à-coups dans la cadence d'images (surtout sous Windows, car la compilation des shaders est plus lente en DirectX qu'en OpenGL).

- -

Pour une expérience plus fluide, vous pouvez émuler dans une certaine mesure les modifications de ces fonctionnalités en utilisant des valeurs "factices" comme des lumières d'intensité nulle, des textures blanches ou un brouillard de densité nulle.

- -

Vous pouvez modifier librement le matériau utilisé pour les morceaux de géométrie, cependant, vous ne pouvez pas modifier la façon dont un objet est divisé en morceaux (selon les matériaux des faces).

- -

Si vous avez besoin d'avoir différentes configurations de matériaux pendant l'exécution :

-

Si le nombre de matériaux / morceaux est faible, vous pouvez pré-diviser l'objet à l'avance (par exemple cheveux / visage / corps / vêtements du haut / pantalon pour un humain, avant / côtés / haut / verre / pneu / intérieur pour une voiture).

- -

Si le nombre est élevé (par exemple, chaque face pourrait être potentiellement différente), envisagez une solution différente, telle que l'utilisation d'attributs / textures pour piloter un aspect différent par face.

- -

Exemples

-

- [example:webgl_materials_car WebGL / matériaux / voiture]
- [example:webgl_postprocessing_dof WebGL / webgl_postprocessing / dof] -

-
- - -

Textures

-
-

Les textures d'image, de canevas, de vidéo et de données doivent avoir le drapeau suivant défini si elles sont modifiées :

- - texture.needsUpdate = true; - -

Les cibles de rendu se mettent à jour automatiquement.

- -

Exemples

-

- [example:webgl_materials_video WebGL / matériaux / vidéo]
- [example:webgl_rtt WebGL / rtt] -

- -
- -

Caméras

-
-

La position et la cible d'une caméra sont mises à jour automatiquement. Si vous avez besoin de changer

-
    -
  • - fov -
  • -
  • - aspect -
  • -
  • - near -
  • -
  • - far -
  • -
-

- alors vous devrez recalculer la matrice de projection : -

-
-camera.aspect = window.innerWidth / window.innerHeight;
-camera.updateProjectionMatrix();
-
-
- -

InstancedMesh

-
-

- InstancedMesh est une classe permettant d'accéder facilement au rendu instancié dans three.js. Certaines fonctionnalités de la bibliothèque comme le culling par frustum de vue ou - le ray casting dépendent de volumes englobants à jour (sphère englobante et boîte englobante). En raison de la façon dont InstancedMesh fonctionne, la classe - possède ses propres propriétés boundingBox et boundingSphere qui remplacent les volumes englobants au niveau de la géométrie. -

-

- Similaire aux géométries, vous devez recalculer la boîte englobante et la sphère chaque fois que vous modifiez les données sous-jacentes. Dans le contexte de InstancedMesh, cela - se produit lorsque vous transformez des instances via setMatrixAt(). Vous pouvez utiliser le même modèle qu'avec les géométries. -

-
-instancedMesh.computeBoundingBox();
-instancedMesh.computeBoundingSphere();
-
- -
- -

SkinnedMesh

-
-

- SkinnedMesh suit les mêmes principes que InstancedMesh en ce qui concerne les volumes englobants. Cela signifie que la classe a sa propre version de - boundingBox et boundingSphere pour enfermer correctement les maillages animés. - Lors de l'appel de computeBoundingBox() et computeBoundingSphere(), la classe calcule les volumes englobants respectifs en fonction de la transformation actuelle des os (ou en d'autres termes, de l'état d'animation actuel). -

-
- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/how-to-use-post-processing.html b/manual/fr/how-to-use-post-processing.html deleted file mode 100644 index 60881ee8aae0f3..00000000000000 --- a/manual/fr/how-to-use-post-processing.html +++ /dev/null @@ -1,142 +0,0 @@ - - - Comment utiliser le post-traitement - - - - - - - - - - - - - -
-
-

Comment utiliser le post-traitement

-
-
-
- -

- De nombreuses applications three.js rendent leurs objets 3D directement à l'écran. Parfois, cependant, vous souhaitez appliquer un ou plusieurs effets graphiques - tels que la profondeur de champ, le Bloom, le grain de film ou divers types d'anti-aliasing. Le post-traitement est une approche largement utilisée - pour implémenter de tels effets. D'abord, la scène est rendue sur une cible de rendu qui représente un tampon dans la mémoire de la carte graphique. - À l'étape suivante, une ou plusieurs passes de post-traitement appliquent des filtres et des effets au tampon d'image avant qu'il ne soit finalement rendu à - l'écran. -

-

- three.js fournit une solution complète de post-traitement via `EffectComposer` pour implémenter un tel flux de travail. -

- -

Flux de travail

- -

- La première étape du processus consiste à importer tous les fichiers nécessaires depuis le répertoire d'exemples. Ce guide suppose que vous utilisez le - [link:https://www.npmjs.com/package/three package npm] officiel de three.js. Pour notre démo de base dans ce guide, nous avons besoin des fichiers suivants. -

- -
-import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
-import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
-import { GlitchPass } from 'three/addons/postprocessing/GlitchPass.js';
-import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
-
- -

- Après l'importation réussie de tous les fichiers, nous pouvons créer notre compositeur en lui passant une instance de `WebGLRenderer`. -

- -
-const composer = new EffectComposer( renderer );
-
- -

- Lorsque vous utilisez un compositeur, il est nécessaire de modifier la boucle d'animation de l'application. Au lieu d'appeler la méthode de rendu de - `WebGLRenderer`, nous utilisons maintenant la contrepartie respective de `EffectComposer`. -

- -
-function animate() {
-
-  requestAnimationFrame( animate );
-
-  composer.render();
-
-}
-
- -

- Notre compositeur est maintenant prêt, il est donc possible de configurer la chaîne de passes de post-traitement. Ces passes sont responsables de la création - du rendu visuel final de l'application. Elles sont traitées dans l'ordre de leur ajout/insertion. Dans notre exemple, l'instance de `RenderPass` - est exécutée en premier, puis l'instance de `GlitchPass` et enfin `OutputPass`. La dernière passe activée dans la chaîne est automatiquement rendue à l'écran. - La configuration des passes ressemble à ceci : -

- -
-const renderPass = new RenderPass( scene, camera );
-composer.addPass( renderPass );
-
-const glitchPass = new GlitchPass();
-composer.addPass( glitchPass );
-
-const outputPass = new OutputPass();
-composer.addPass( outputPass );
-
- -

- `RenderPass` est normalement placée au début de la chaîne afin de fournir la scène rendue comme entrée pour l'étape de post-traitement suivante. Dans notre cas, - `GlitchPass` utilisera ces données d'image pour appliquer un effet de glitch sauvage. `OutputPass` est généralement la dernière passe de la chaîne qui effectue la conversion de l'espace colorimétrique sRGB et le mappage tonal. - Découvrez cet [link:https://threejs.org/examples/webgl_postprocessing_glitch exemple live] pour le voir en action. -

- -

Passes intégrées

- -

- Vous pouvez utiliser une large gamme de passes de post-traitement prédéfinies fournies par le moteur. Elles se trouvent dans le - répertoire [link:https://github.com/mrdoob/three.js/tree/dev/examples/jsm/postprocessing postprocessing]. -

- -

Passes personnalisées

- -

- Parfois, vous souhaitez écrire un shader de post-traitement personnalisé et l'inclure dans la chaîne de passes de post-traitement. Pour ce scénario, - vous pouvez utiliser `ShaderPass`. Après avoir importé le fichier et votre shader personnalisé, vous pouvez utiliser le code suivant pour configurer la passe. -

- -
-import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
-import { LuminosityShader } from 'three/addons/shaders/LuminosityShader.js';
-
-// plus tard dans votre routine d'initialisation
-
-const luminosityPass = new ShaderPass( LuminosityShader );
-composer.addPass( luminosityPass );
-
- -

- Le dépôt fournit un fichier appelé [link:https://github.com/mrdoob/three.js/blob/master/examples/jsm/shaders/CopyShader.js CopyShader] qui constitue un - bon point de départ pour votre propre shader personnalisé. `CopyShader` copie simplement le contenu de l'image du tampon de lecture (`read buffer`) de l'`EffectComposer` - vers son tampon d'écriture (`write buffer`) sans appliquer aucun effet. -

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/indexed-textures.html b/manual/fr/indexed-textures.html deleted file mode 100644 index 3a9a3bc0739a31..00000000000000 --- a/manual/fr/indexed-textures.html +++ /dev/null @@ -1,631 +0,0 @@ - - - Textures Indexées pour la Sélection et la Couleur - - - - - - - - - - - - - -
-
-

Textures Indexées pour la Sélection et la Couleur

-
-
-
-

Cet article est une continuation de un article sur l'alignement des éléments HTML en 3D. -Si vous ne l'avez pas encore lu, vous devriez commencer par là avant de continuer ici.

-

Parfois, l'utilisation de three.js nécessite de trouver des solutions créatives. -Je ne suis pas sûr que ce soit une excellente solution, mais j'ai pensé la partager et -vous pouvez voir si elle suggère des solutions pour vos besoins.

-

Dans l'article précédent, nous -avons affiché les noms de pays autour d'un globe 3D. Comment pourrions-nous permettre à -l'utilisateur de sélectionner un pays et d'afficher sa sélection ?

-

La première idée qui vient à l'esprit est de générer la géométrie pour chaque pays. -Nous pourrions utiliser une solution de picking comme nous l'avons vu précédemment. -Nous construirions une géométrie 3D pour chaque pays. Si l'utilisateur clique sur le maillage de -ce pays, nous saurions quel pays a été cliqué.

-

Donc, juste pour vérifier cette solution, j'ai essayé de générer des maillages 3D de tous les pays -en utilisant les mêmes données que celles que j'ai utilisées pour générer les contours -dans l'article précédent. -Le résultat était un fichier GLTF (.glb) binaire de 15,5 Mo. Faire télécharger 15,5 Mo -à l'utilisateur me semble excessif.

-

Il existe de nombreuses façons de compresser les données. La première serait probablement -d'appliquer un algorithme pour réduire la résolution des contours. Je n'ai pas passé -de temps à explorer cette solution. Pour les frontières des États-Unis, c'est probablement un -gain énorme. Pour les frontières du Canada, probablement beaucoup moins.

-

Une autre solution serait d'utiliser simplement la compression de données réelle. Par exemple, la compression Gzip -du fichier l'a réduit à 11 Mo. C'est 30% de moins, mais probablement pas suffisant.

-

Nous pourrions stocker toutes les données sous forme de valeurs de plage sur 16 bits au lieu de valeurs flottantes sur 32 bits. -Ou nous pourrions utiliser quelque chose comme la compression Draco -et peut-être que cela suffirait. Je n'ai pas vérifié et je vous encourage à vérifier -par vous-même et à me dire comment ça se passe, car j'aimerais le savoir. 😅

-

Dans mon cas, j'ai pensé à la solution de picking GPU -que nous avons abordée à la fin de l'article sur le picking. Dans -cette solution, nous avons dessiné chaque maillage avec une couleur unique qui représentait -l'ID de ce maillage. Nous avons ensuite dessiné tous les maillages et regardé la couleur -sur laquelle on a cliqué.

-

En nous inspirant de cela, nous pourrions pré-générer une carte des pays où -la couleur de chaque pays est son numéro d'index dans notre tableau de pays. Nous pourrions -alors utiliser une technique de picking GPU similaire. Nous dessinerions le globe hors écran en utilisant -cette texture d'index. Regarder la couleur du pixel sur lequel l'utilisateur clique -nous donnerait l'ID du pays.

-

Donc, j'ai écrit du code -pour générer une telle texture. La voici.

-
- -

Note : Les données utilisées pour générer cette texture proviennent de ce site web -et sont donc sous licence CC-BY-SA.

-

Elle ne fait que 217 Ko, bien mieux que les 14 Mo pour les maillages de pays. En fait, nous pourrions probablement -même réduire la résolution, mais 217 Ko semble suffisant pour l'instant.

-

Alors essayons de l'utiliser pour sélectionner des pays.

-

En prenant du code de l'exemple de picking GPU, nous avons besoin -d'une scène pour le picking.

-
const pickingScene = new THREE.Scene();
-pickingScene.background = new THREE.Color(0);
-
-

et nous devons ajouter le globe avec notre texture d'index à la -scène de picking.

-
{
-  const loader = new THREE.TextureLoader();
-  const geometry = new THREE.SphereGeometry(1, 64, 32);
-
-+  const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
-+  indexTexture.minFilter = THREE.NearestFilter;
-+  indexTexture.magFilter = THREE.NearestFilter;
-+
-+  const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture});
-+  pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
-
-  const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
-  const material = new THREE.MeshBasicMaterial({map: texture});
-  scene.add(new THREE.Mesh(geometry, material));
-}
-
-

Ensuite, copions la classe GPUPickingHelper que nous avons -utilisée précédemment avec quelques modifications mineures.

-
class GPUPickHelper {
-  constructor() {
-    // créer une cible de rendu de 1x1 pixel
-    this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
-    this.pixelBuffer = new Uint8Array(4);
--    this.pickedObject = null;
--    this.pickedObjectSavedColor = 0;
-  }
-  pick(cssPosition, scene, camera) {
-    const {pickingTexture, pixelBuffer} = this;
-
-    // définir le décalage de la vue pour représenter juste un seul pixel sous la souris
-    const pixelRatio = renderer.getPixelRatio();
-    camera.setViewOffset(
-        renderer.getContext().drawingBufferWidth,   // largeur totale
-        renderer.getContext().drawingBufferHeight,  // haut total
-        cssPosition.x * pixelRatio | 0,             // coordonnée x du rectangle
-        cssPosition.y * pixelRatio | 0,             // coordonnée y du rectangle
-        1,                                          // largeur du rectangle
-        1,                                          // hauteur du rectangle
-    );
-    // effectuer le rendu de la scène
-    renderer.setRenderTarget(pickingTexture);
-    renderer.render(scene, camera);
-    renderer.setRenderTarget(null);
-    // effacer le décalage de la vue pour que le rendu revienne à la normale
-    camera.clearViewOffset();
-    // lire le pixel
-    renderer.readRenderTargetPixels(
-        pickingTexture,
-        0,   // x
-        0,   // y
-        1,   // width
-        1,   // height
-        pixelBuffer);
-
-+    const id =
-+        (pixelBuffer[0] << 16) |
-+        (pixelBuffer[1] <<  8) |
-+        (pixelBuffer[2] <<  0);
-+
-+    return id;
--    const id =
--        (pixelBuffer[0] << 16) |
--        (pixelBuffer[1] <<  8) |
--        (pixelBuffer[2]      );
--    const intersectedObject = idToObject[id];
--    if (intersectedObject) {
--      // pick the first object. It's the closest one
--      this.pickedObject = intersectedObject;
--      // save its color
--      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
--      // set its emissive color to flashing red/yellow
--      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
--    }
-  }
-}
-
-

Maintenant, nous pouvons l'utiliser pour sélectionner des pays.

-
const pickHelper = new GPUPickHelper();
-
-function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function pickCountry(event) {
-  // sortir si les données ne sont pas encore chargées
-  if (!countryInfos) {
-    return;
-  }
-
-  const position = getCanvasRelativePosition(event);
-  const id = pickHelper.pick(position, pickingScene, camera);
-  if (id > 0) {
-    // nous avons cliqué sur un pays. Basculer sa propriété 'selected'
-    const countryInfo = countryInfos[id - 1];
-    const selected = !countryInfo.selected;
-    // si nous sélectionnons ce pays et que les touches modificatrices ne sont pas
-    // enfoncées, désélectionner tout le reste.
-    if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
-      unselectAllCountries();
-    }
-    numCountriesSelected += selected ? 1 : -1;
-    countryInfo.selected = selected;
-  } else if (numCountriesSelected) {
-    // l'océan ou le ciel a été cliqué
-    unselectAllCountries();
-  }
-  requestRenderIfNotRequested();
-}
-
-function unselectAllCountries() {
-  numCountriesSelected = 0;
-  countryInfos.forEach((countryInfo) => {
-    countryInfo.selected = false;
-  });
-}
-
-canvas.addEventListener('pointerup', pickCountry);
-
-

Le code ci-dessus définit/annule la propriété selected sur -le tableau de pays. Si shift ou ctrl ou cmd -est enfoncé, vous pouvez sélectionner plus d'un pays.

-

Il ne reste plus qu'à afficher les pays sélectionnés. Pour l'instant, -mettons simplement à jour les labels.

-
function updateLabels() {
-  // sortir si les données ne sont pas encore chargées
-  if (!countryInfos) {
-    return;
-  }
-
-  const large = settings.minArea * settings.minArea;
-  // obtenir une matrice qui représente une orientation relative de la caméra
-  normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
-  // obtenir la position de la caméra
-  camera.getWorldPosition(cameraPosition);
-  for (const countryInfo of countryInfos) {
--    const {position, elem, area} = countryInfo;
--    // large enough?
--    if (area < large) {
-+    const {position, elem, area, selected} = countryInfo;
-+    const largeEnough = area >= large;
-+    const show = selected || (numCountriesSelected === 0 && largeEnough);
-+    if (!show) {
-      elem.style.display = 'none';
-      continue;
-    }
-
-    ...
-
-

et avec cela, nous devrions pouvoir sélectionner des pays

-

- -

-

Le code affiche toujours les pays en fonction de leur superficie, mais si vous -en cliquez sur un, seul celui-ci aura un label.

-

Cela semble donc une solution raisonnable pour sélectionner des pays, -mais qu'en est-il de la mise en évidence des pays sélectionnés ?

-

Pour cela, nous pouvons nous inspirer des graphiques palettisés.

-

Les graphiques palettisés -ou couleurs indexées sont -ce qu'utilisaient les anciens systèmes comme l'Atari 800, l'Amiga, la NES, -la Super Nintendo et même les anciens PC IBM. Au lieu de stocker des images bitmap -en couleurs RGBA (8 bits par couleur, 32 octets par pixel ou plus), ils stockaient -des images bitmap en valeurs de 8 bits ou moins. La valeur de chaque pixel était un index -dans une palette. Par exemple, une valeur -de 3 dans l'image signifie "afficher la couleur 3". La couleur que représente la couleur n°3 est -définie ailleurs dans ce qu'on appelle une "palette".

-

En JavaScript, vous pouvez l'imaginer comme ceci

-
const face7x7PixelImageData = [
-  0, 1, 1, 1, 1, 1, 0,
-  1, 0, 0, 0, 0, 0, 1,
-  1, 0, 2, 0, 2, 0, 1,
-  1, 0, 0, 0, 0, 0, 1,
-  1, 0, 3, 3, 3, 0, 1,
-  1, 0, 0, 0, 0, 0, 1,
-  0, 1, 1, 1, 1, 1, 1,
-];
-
-const palette = [
-  [255, 255, 255],  // white
-  [  0,   0,   0],  // black
-  [  0, 255, 255],  // cyan
-  [255,   0,   0],  // red
-];
-
-

Où chaque pixel dans les données de l'image est un index dans la palette. Si vous interprétiez -les données de l'image à travers la palette ci-dessus, vous obtiendriez cette image

-
- -

Dans notre cas, nous avons déjà une texture ci-dessus qui a un ID différent -par pays. Ainsi, nous pourrions utiliser cette même texture à travers une texture de palette -pour donner à chaque pays sa propre couleur. En modifiant la texture de palette, -nous pouvons colorer chaque pays individuellement. Par exemple, en mettant -toute la texture de palette en noir, puis en attribuant une couleur différente à l'entrée -d'un pays dans la palette, nous pouvons mettre en évidence uniquement ce pays.

-

Pour réaliser des graphiques à index palettisés, il faut du code shader personnalisé. -Modifions les shaders par défaut dans three.js. -De cette façon, nous pourrons utiliser l'éclairage et d'autres fonctionnalités si nous le souhaitons.

-

Comme nous l'avons vu dans l'article sur l'animation de nombreux objets, -nous pouvons modifier les shaders par défaut en ajoutant une fonction à la propriété -onBeforeCompile d'un matériau.

-

Le shader de fragment par défaut ressemble à ceci avant la compilation.

-
#include <common>
-#include <color_pars_fragment>
-#include <uv_pars_fragment>
-#include <map_pars_fragment>
-#include <alphamap_pars_fragment>
-#include <aomap_pars_fragment>
-#include <lightmap_pars_fragment>
-#include <envmap_pars_fragment>
-#include <fog_pars_fragment>
-#include <specularmap_pars_fragment>
-#include <logdepthbuf_pars_fragment>
-#include <clipping_planes_pars_fragment>
-void main() {
-    #include <clipping_planes_fragment>
-    vec4 diffuseColor = vec4( diffuse, opacity );
-    #include <logdepthbuf_fragment>
-    #include <map_fragment>
-    #include <color_fragment>
-    #include <alphamap_fragment>
-    #include <alphatest_fragment>
-    #include <specularmap_fragment>
-    ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );
-    #ifdef USE_LIGHTMAP
-        reflectedLight.indirectDiffuse += texture2D( lightMap, vLightMapUv ).xyz * lightMapIntensity;
-    #else
-        reflectedLight.indirectDiffuse += vec3( 1.0 );
-    #endif
-    #include <aomap_fragment>
-    reflectedLight.indirectDiffuse *= diffuseColor.rgb;
-    vec3 outgoingLight = reflectedLight.indirectDiffuse;
-    #include <envmap_fragment>
-    gl_FragColor = vec4( outgoingLight, diffuseColor.a );
-    #include <premultiplied_alpha_fragment>
-    #include <tonemapping_fragment>
-    #include <colorspace_fragment>
-    #include <fog_fragment>
-}
-
-

En fouillant dans tous ces extraits, -nous constatons que three.js utilise une variable appelée diffuseColor pour gérer la -couleur de base du matériau. Il la définit dans l'extrait <color_fragment>, -nous devrions donc pouvoir la modifier après ce point.

-

diffuseColor à ce stade du shader devrait déjà être la couleur de -notre texture de contour, nous pouvons donc chercher la couleur dans une texture de palette -et les mélanger pour le résultat final.

-

Comme nous l'avons fait précédemment, nous allons créer un tableau -de chaînes de recherche et de remplacement et les appliquer au shader dans -Material.onBeforeCompile.

-
{
-  const loader = new THREE.TextureLoader();
-  const geometry = new THREE.SphereGeometry(1, 64, 32);
-
-  const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
-  indexTexture.minFilter = THREE.NearestFilter;
-  indexTexture.magFilter = THREE.NearestFilter;
-
-  const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture});
-  pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
-
-+  const fragmentShaderReplacements = [
-+    {
-+      from: '#include <common>',
-+      to: `
-+        #include <common>
-+        uniform sampler2D indexTexture;
-+        uniform sampler2D paletteTexture;
-+        uniform float paletteTextureWidth;
-+      `,
-+    },
-+    {
-+      from: '#include <color_fragment>',
-+      to: `
-+        #include <color_fragment>
-+        {
-+          vec4 indexColor = texture2D(indexTexture, vUv);
-+          float index = indexColor.r * 255.0 + indexColor.g * 255.0 * 256.0;
-+          vec2 paletteUV = vec2((index + 0.5) / paletteTextureWidth, 0.5);
-+          vec4 paletteColor = texture2D(paletteTexture, paletteUV);
-+          // diffuseColor.rgb += paletteColor.rgb;   // white outlines
-+          diffuseColor.rgb = paletteColor.rgb - diffuseColor.rgb;  // black outlines
-+        }
-+      `,
-+    },
-+  ];
-
-  const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
-  const material = new THREE.MeshBasicMaterial({map: texture});
-+  material.onBeforeCompile = function(shader) {
-+    fragmentShaderReplacements.forEach((rep) => {
-+      shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
-+    });
-+  };
-  scene.add(new THREE.Mesh(geometry, material));
-}
-
-

Comme vous pouvez le voir ci-dessus, nous ajoutons 3 uniformes, indexTexture, paletteTexture, -et paletteTextureWidth. Nous obtenons une couleur à partir de indexTexture -et la convertissons en index. vUv sont les coordonnées de texture fournies par -three.js. Nous utilisons ensuite cet index pour obtenir une couleur à partir de la texture de palette. -Nous mélangeons ensuite le résultat avec la diffuseColor actuelle. La diffuseColor -à ce stade est notre texture de contour noir et blanc, donc si nous ajoutons les 2 couleurs, -nous obtiendrons des contours blancs. Si nous soustrayons la couleur diffuse actuelle, nous obtiendrons -des contours noirs.

-

Avant de pouvoir effectuer le rendu, nous devons configurer la texture de palette -et ces 3 uniformes.

-

Pour la texture de palette, elle doit juste être suffisamment large pour -contenir une couleur par pays + une pour l'océan (id = 0). -Il y a 240 et quelques pays. Nous pourrions attendre que la -liste des pays se charge pour obtenir un nombre exact ou le chercher. -Il n'y a pas beaucoup de mal à choisir un nombre plus grand, -donc choisissons 512.

-

Voici le code pour créer la texture de palette

-
const maxNumCountries = 512;
-const paletteTextureWidth = maxNumCountries;
-const paletteTextureHeight = 1;
-const palette = new Uint8Array(paletteTextureWidth * 4);
-const paletteTexture = new THREE.DataTexture(
-    palette, paletteTextureWidth, paletteTextureHeight);
-paletteTexture.minFilter = THREE.NearestFilter;
-paletteTexture.magFilter = THREE.NearestFilter;
-
-

Une DataTexture nous permet de donner des données brutes à une texture. Dans ce cas, -nous lui donnons 512 couleurs RGBA, 4 octets chacune où chaque octet représente -respectivement le rouge, le vert et le bleu en utilisant des valeurs allant de 0 à 255.

-

Remplissons-la avec des couleurs aléatoires juste pour voir si ça fonctionne

-
for (let i = 1; i < palette.length; ++i) {
-  palette[i] = Math.random() * 256;
-}
-// définir la couleur de l'océan (index #0)
-palette.set([100, 200, 255, 255], 0);
-paletteTexture.needsUpdate = true;
-
-

Chaque fois que nous voulons que three.js mette à jour la texture de palette avec -le contenu du tableau palette, nous devons définir paletteTexture.needsUpdate -sur true.

-

Et ensuite, nous devons toujours définir les uniformes sur le matériau.

-
const geometry = new THREE.SphereGeometry(1, 64, 32);
-const material = new THREE.MeshBasicMaterial({map: texture});
-material.onBeforeCompile = function(shader) {
-  fragmentShaderReplacements.forEach((rep) => {
-    shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
-  });
-+  shader.uniforms.paletteTexture = {value: paletteTexture};
-+  shader.uniforms.indexTexture = {value: indexTexture};
-+  shader.uniforms.paletteTextureWidth = {value: paletteTextureWidth};
-};
-scene.add(new THREE.Mesh(geometry, material));
-
-

et avec cela, nous obtenons des pays colorés aléatoirement.

-

- -

-

Maintenant que nous pouvons voir que les textures d'index et de palette fonctionnent, -manipulons la palette pour la mise en évidence.

-

Faisons d'abord une fonction qui nous permettra de passer une couleur de style three.js -et de nous donner les valeurs que nous pouvons mettre dans la texture de palette.

-
const tempColor = new THREE.Color();
-function get255BasedColor(color) {
-  tempColor.set(color);
-  const base = tempColor.toArray().map(v => v * 255);
-  base.push(255); // alpha
-  return base;
-}
-
-

L'appeler comme ceci color = get255BasedColor('red') retournera -un tableau comme [255, 0, 0, 255].

-

Ensuite, utilisons-la pour créer quelques couleurs et remplir la -palette.

-
const selectedColor = get255BasedColor('red');
-const unselectedColor = get255BasedColor('#444');
-const oceanColor = get255BasedColor('rgb(100,200,255)');
-resetPalette();
-
-function setPaletteColor(index, color) {
-  palette.set(color, index * 4);
-}
-
-function resetPalette() {
-  // définir toutes les couleurs sur la couleur non sélectionnée
-  for (let i = 1; i < maxNumCountries; ++i) {
-    setPaletteColor(i, unselectedColor);
-  }
-
-  // définir la couleur de l'océan (index #0)
-  setPaletteColor(0, oceanColor);
-  paletteTexture.needsUpdate = true;
-}
-
-

Maintenant, utilisons ces fonctions pour mettre à jour la palette lorsqu'un pays -est sélectionné

-
function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function pickCountry(event) {
-  // sortir si les données ne sont pas encore chargées
-  if (!countryInfos) {
-    return;
-  }
-
-  const position = getCanvasRelativePosition(event);
-  const id = pickHelper.pick(position, pickingScene, camera);
-  if (id > 0) {
-    const countryInfo = countryInfos[id - 1];
-    const selected = !countryInfo.selected;
-    if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
-      unselectAllCountries();
-    }
-    numCountriesSelected += selected ? 1 : -1;
-    countryInfo.selected = selected;
-+    setPaletteColor(id, selected ? selectedColor : unselectedColor);
-+    paletteTexture.needsUpdate = true;
-  } else if (numCountriesSelected) {
-    unselectAllCountries();
-  }
-  requestRenderIfNotRequested();
-}
-
-function unselectAllCountries() {
-  numCountriesSelected = 0;
-  countryInfos.forEach((countryInfo) => {
-    countryInfo.selected = false;
-  });
-+  resetPalette();
-}
-
-

et avec cela, nous devrions pouvoir mettre en évidence 1 ou plusieurs pays.

-

- -

-

Cela semble fonctionner !

-

Un petit détail est que nous ne pouvons pas faire tourner le globe sans changer -l'état de sélection. Si nous sélectionnons un pays et voulons ensuite -faire pivoter le globe, la sélection changera.

-

Essayons de régler cela. Rapidement, nous pouvons vérifier 2 choses. -Le temps écoulé entre le clic et le lâcher. Une autre est de savoir si -l'utilisateur a réellement déplacé la souris. Si le -temps est court ou s'il n'a pas bougé la souris, c'était -probablement un clic. Sinon, il essayait probablement de -faire glisser le globe.

-
+const maxClickTimeMs = 200;
-+const maxMoveDeltaSq = 5 * 5;
-+const startPosition = {};
-+let startTimeMs;
-+
-+function recordStartTimeAndPosition(event) {
-+  startTimeMs = performance.now();
-+  const pos = getCanvasRelativePosition(event);
-+  startPosition.x = pos.x;
-+  startPosition.y = pos.y;
-+}
-
-function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function pickCountry(event) {
-  // sortir si les données ne sont pas encore chargées
-  if (!countryInfos) {
-    return;
-  }
-
-+  // s'il s'est écoulé un certain temps depuis que l'utilisateur a commencé
-+  // alors supposer qu'il s'agissait d'une action de glissement, pas de sélection
-+  const clickTimeMs = performance.now() - startTimeMs;
-+  if (clickTimeMs > maxClickTimeMs) {
-+    return;
-+  }
-+
-+  // s'ils ont bougé, supposer qu'il s'agissait d'une action de glissement
-+  const position = getCanvasRelativePosition(event);
-+  const moveDeltaSq = (startPosition.x - position.x) ** 2 +
-+                      (startPosition.y - position.y) ** 2;
-+  if (moveDeltaSq > maxMoveDeltaSq) {
-+    return;
-+  }
-
--  const position = {x: event.clientX, y: event.clientY};
-  const id = pickHelper.pick(position, pickingScene, camera);
-  if (id > 0) {
-    const countryInfo = countryInfos[id - 1];
-    const selected = !countryInfo.selected;
-    if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
-      unselectAllCountries();
-    }
-    numCountriesSelected += selected ? 1 : -1;
-    countryInfo.selected = selected;
-    setPaletteColor(id, selected ? selectedColor : unselectedColor);
-    paletteTexture.needsUpdate = true;
-  } else if (numCountriesSelected) {
-    unselectAllCountries();
-  }
-  requestRenderIfNotRequested();
-}
-
-function unselectAllCountries() {
-  numCountriesSelected = 0;
-  countryInfos.forEach((countryInfo) => {
-    countryInfo.selected = false;
-  });
-  resetPalette();
-}
-
-+canvas.addEventListener('pointerdown', recordStartTimeAndPosition);
-canvas.addEventListener('pointerup', pickCountry);
-
-

et avec ces modifications, il semble que cela fonctionne pour moi.

-

- -

-

Je ne suis pas expert en UX, donc j'aimerais savoir s'il existe une meilleure -solution.

-

J'espère que cela vous a donné une idée de l'utilité des graphiques indexés et de la façon dont vous pouvez modifier les shaders créés par three.js pour ajouter des fonctionnalités simples. L'utilisation de GLSL, le langage dans lequel les shaders sont écrits, est trop vaste pour cet article. Il y a quelques liens vers des informations dans l'article sur le post-traitement.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/installation.html b/manual/fr/installation.html deleted file mode 100644 index 2038c88ba8df8a..00000000000000 --- a/manual/fr/installation.html +++ /dev/null @@ -1,295 +0,0 @@ - - - Installation - - - - - - - - - - - - - -
-
-

Installation

-
-
-
- -

Structure du projet

- -

- Tout projet three.js nécessite au moins un fichier HTML pour définir la page web, et un fichier JavaScript pour exécuter votre code three.js. La structure et les choix de noms ci-dessous ne sont pas obligatoires, mais seront utilisés tout au long de ce guide par souci de cohérence. -

- -
    -
  • - index.html -
    -<!DOCTYPE html>
    -<html lang="en">
    -  <head>
    -    <meta charset="utf-8">
    -    <title>Ma première application three.js</title>
    -    <style>
    -      body { margin: 0; }
    -    </style>
    -  </head>
    -  <body>
    -    <script type="module" src="/main.js"></script>
    -  </body>
    -</html>
    -    
    -
  • -
  • - main.js -
    -import * as THREE from 'three';
    -
    -...
    -
    -
  • -
  • - public/ -
      -
    • - Le dossier public/ est parfois aussi appelé dossier "static", car les fichiers qu'il contient sont poussés vers le site web sans modification. Généralement, les textures, l'audio et les modèles 3D s'y trouvent. -
    • -
    -
  • -
- -

- Maintenant que nous avons mis en place la structure de base du projet, nous avons besoin d'un moyen pour exécuter le projet localement et y accéder via un navigateur web. L'installation et le développement local peuvent être accomplis avec npm et un outil de build, ou en important three.js depuis un CDN. Les deux options sont expliquées dans les sections ci-dessous. -

- -

Option 1 : Installation avec NPM et un outil de build

- -

Développement

- -

- L'installation depuis le [link:https://www.npmjs.com/ registre de packages npm] et l'utilisation d'un [link:https://eloquentjavascript.net/10_modules.html#h_zWTXAU93DC outil de build] est l'approche recommandée pour la plupart des utilisateurs — plus votre projet a de dépendances, plus vous êtes susceptible de rencontrer des problèmes que l'hébergement statique ne peut pas facilement résoudre. Avec un outil de build, l'importation de fichiers JavaScript locaux et de packages npm devrait fonctionner directement, sans cartes d'importation. -

- - -
    -
  1. - Installez [link:https://nodejs.org/ Node.js]. Nous en aurons besoin pour gérer les dépendances et exécuter notre outil de build. -
  2. -
  3. -

    - Installez three.js et un outil de build, [link:https://vitejs.dev/ Vite], en utilisant un [link:https://www.joshwcomeau.com/javascript/terminal-for-js-devs/ terminal] dans le dossier de votre projet. Vite sera utilisé pendant le développement, mais ne fait pas partie de la page web finale. Si vous préférez utiliser un autre outil de build, c'est bien — nous supportons les outils de build modernes qui peuvent importer les [link:https://eloquentjavascript.net/10_modules.html#h_zWTXAU93DC Modules ES]. -

    -
    -# three.js
    -npm install --save three
    -
    -# vite
    -npm install --save-dev vite
    -
    - -
  4. -
  5. - Depuis votre terminal, exécutez : -
    npx vite 
    - -
  6. -
  7. - Si tout s'est bien passé, vous verrez une URL comme http://localhost:5173 apparaître dans votre terminal, et vous pourrez ouvrir cette URL pour voir votre application web. -
  8. -
- -

- La page sera vide — vous êtes prêt à créer une scène. -

- -

- Si vous voulez en savoir plus sur ces outils avant de continuer, consultez : -

- -
    -
  • - [link:https://threejs-journey.com/lessons/local-server three.js journey : Serveur local] -
  • -
  • - [link:https://vitejs.dev/guide/cli.html Vite : Interface en ligne de commande] -
  • -
  • - [link:https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Understanding_client-side_tools/Package_management MDN : Principes de base de la gestion des packages] -
  • -
- -

Production

- -

- Plus tard, lorsque vous serez prêt à déployer votre application web, il vous suffira d'indiquer à Vite d'exécuter une build de production — npx vite build. Tout ce qui est utilisé par l'application sera compilé, optimisé et copié dans le dossier dist/. Le contenu de ce dossier est prêt à être hébergé sur votre site web. -

- -

Option 2 : Importation depuis un CDN

- -

Développement

- -

L'installation sans outils de build nécessitera quelques modifications de la structure du projet donnée ci-dessus.

- -
    -
  1. -

    - Nous avons importé du code depuis 'three' (un package npm) dans main.js, et les navigateurs web ne savent pas ce que cela signifie. Dans index.html, nous devrons ajouter une [link:https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap carte d'importation] définissant où obtenir le package. Placez le code ci-dessous à l'intérieur de la balise <head></head>, après les styles. -

    -
    -<script type="importmap">
    -{
    -  "imports": {
    -    "three": "https://cdn.jsdelivr.net/npm/three@<version>/build/three.module.js",
    -    "three/addons/": "https://cdn.jsdelivr.net/npm/three@<version>/examples/jsm/"
    -  }
    -}
    -</script>
    -
    -

    - N'oubliez pas de remplacer <version> par une version réelle de three.js, comme "v0.149.0". La version la plus récente peut être trouvée sur la [link:https://www.npmjs.com/package/three?activeTab=versions liste des versions npm]. -

    -
  2. -
  3. -

    - Nous aurons également besoin d'exécuter un serveur local pour héberger ces fichiers à une URL accessible par le navigateur web. Bien qu'il soit techniquement possible de double-cliquer sur un fichier HTML et de l'ouvrir dans votre navigateur, des fonctionnalités importantes que nous implémenterons plus tard ne fonctionnent pas lorsque la page est ouverte de cette manière, pour des raisons de sécurité. -

    -

    - Installez [link:https://nodejs.org/ Node.js], puis exécutez [link:https://www.npmjs.com/package/serve serve] pour démarrer un serveur local dans le répertoire du projet : -

    -
    npx serve .
    -
  4. -
  5. - Si tout s'est bien passé, vous verrez une URL comme http://localhost:3000 apparaître dans votre terminal, et vous pourrez ouvrir cette URL pour voir votre application web. -
  6. -
- -

- La page sera vide — vous êtes prêt à [link:#manual/introduction/Creating-a-scene créer une scène]. -

- -

- De nombreux autres serveurs statiques locaux sont disponibles — certains utilisent des langages différents au lieu de Node.js, et d'autres sont des applications de bureau. Ils fonctionnent tous fondamentalement de la même manière, et nous avons fourni quelques alternatives ci-dessous. -

- -
- Plus de serveurs locaux - -

Ligne de commande

- -

Les serveurs locaux en ligne de commande s'exécutent depuis une fenêtre de terminal. Le langage de programmation associé peut devoir être installé au préalable.

- -
    -
  • npx http-server (Node.js)
  • -
  • npx five-server (Node.js)
  • -
  • python -m SimpleHTTPServer (Python 2.x)
  • -
  • python -m http.server (Python 3.x)
  • -
  • php -S localhost:8000 (PHP 5.4+)
  • -
- - -

GUI

- -

Les serveurs locaux GUI s'exécutent sous forme de fenêtre d'application sur votre ordinateur, et peuvent avoir une interface utilisateur.

- -
    -
  • [link:https://greggman.github.io/servez Servez]
  • -
- -

Plugins d'éditeur de code

- -

Certains éditeurs de code disposent de plugins qui lancent un simple serveur à la demande.

- -
    -
  • [link:https://marketplace.visualstudio.com/items?itemName=yandeu.five-server Five Server] pour Visual Studio Code
  • -
  • [link:https://marketplace.visualstudio.com/items?itemName=ritwickdey.LiveServer Live Server] pour Visual Studio Code
  • -
  • [link:https://atom.io/packages/atom-live-server Live Server] pour Atom
  • -
- - -
- -

Production

- -

- Lorsque vous êtes prêt à déployer votre application web, poussez les fichiers source chez votre hébergeur web — pas besoin de build ou de compiler quoi que ce soit. L'inconvénient de ce compromis est que vous devrez veiller à maintenir la carte d'importation à jour avec toutes les dépendances (et les dépendances des dépendances !) dont votre application a besoin. Si le CDN hébergeant vos dépendances tombe temporairement, votre site web cessera également de fonctionner. -

- -

- IMPORTANT : Importez toutes les dépendances depuis la même version de three.js et depuis le même CDN. Mélanger des fichiers de différentes sources peut entraîner l'inclusion de code dupliqué, ou même casser l'application de manière inattendue. -

- -

Addons

- -

- Par défaut, three.js inclut les fondamentaux d'un moteur 3D. Les autres composants de three.js — tels que les contrôles, les chargeurs et les effets de post-traitement — font partie du répertoire [link:https://github.com/mrdoob/three.js/tree/dev/examples/jsm addons/]. Les Addons n'ont pas besoin d'être installés séparément, mais doivent être importés séparément. -

- -

- L'exemple ci-dessous montre comment importer three.js avec les addons `OrbitControls` et `GLTFLoader`. Si nécessaire, cela sera également mentionné dans la documentation ou les exemples de chaque addon. -

- -
-import * as THREE from 'three';
-import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
-
-const controls = new OrbitControls( camera, renderer.domElement );
-const loader = new GLTFLoader();
-
- -

- D'excellents projets tiers sont également disponibles pour three.js. Ceux-ci doivent être installés séparément — voir Bibliothèques et Plugins. -

- -

Étapes suivantes

- -

- Vous êtes maintenant prêt à créer une scène. -

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/libraries-and-plugins.html b/manual/fr/libraries-and-plugins.html deleted file mode 100644 index 0935775a11093d..00000000000000 --- a/manual/fr/libraries-and-plugins.html +++ /dev/null @@ -1,146 +0,0 @@ - - - Bibliothèques et Plugins - - - - - - - - - - - - - -
-
-

Bibliothèques et Plugins

-
-
-
- -

- Voici une liste de bibliothèques et plugins compatibles développés en externe pour three.js. Cette - liste et les paquets associés sont maintenus par la communauté et ne sont pas garantis - d'être à jour. Si vous souhaitez mettre à jour cette liste, faites une Pull Request ! -

- -

Physique

- -
    -
  • [link:https://github.com/lo-th/Oimo.js/ Oimo.js]
  • -
  • [link:https://enable3d.io/ enable3d]
  • -
  • [link:https://github.com/kripken/ammo.js/ ammo.js]
  • -
  • [link:https://github.com/pmndrs/cannon-es cannon-es]
  • -
  • [link:https://rapier.rs/ rapier]
  • -
  • [link:https://github.com/jrouwe/JoltPhysics.js Jolt]
  • - -
- -

Post-traitement

- -

- En plus des [link:https://github.com/mrdoob/three.js/tree/dev/examples/jsm/postprocessing effets de post-traitement officiels de three.js], - la prise en charge d'effets et de frameworks supplémentaires est disponible via des bibliothèques externes. -

- -
    -
  • [link:https://github.com/vanruesc/postprocessing postprocessing]
  • -
- -

Performance d'Intersection et de Raycast

- -
    -
  • [link:https://github.com/gkjohnson/three-mesh-bvh three-mesh-bvh]
  • -
- -

Tracé de chemin

- -
    -
  • [link:https://github.com/gkjohnson/three-gpu-pathtracer three-gpu-pathtracer]
  • -
- -

Formats de fichier

- -

- En plus des [link:https://github.com/mrdoob/three.js/tree/dev/examples/jsm/loaders chargeurs officiels de three.js], - la prise en charge de formats supplémentaires est disponible via des bibliothèques externes. -

- -
    -
  • [link:https://github.com/gkjohnson/urdf-loaders/tree/master/javascript urdf-loader]
  • -
  • [link:https://github.com/NASA-AMMOS/3DTilesRendererJS 3d-tiles-renderer-js]
  • -
  • [link:https://github.com/kaisalmen/WWOBJLoader Chargeur OBJ WebWorker]
  • -
  • [link:https://github.com/IFCjs/web-ifc-three IFC.js]
  • -
- -

Géométrie

- -
    -
  • [link:https://github.com/spite/THREE.MeshLine THREE.MeshLine]
  • -
- -

Texte et Mise en page 3D

- -
    -
  • [link:https://github.com/protectwise/troika/tree/master/packages/troika-three-text troika-three-text]
  • -
  • [link:https://github.com/felixmariotto/three-mesh-ui three-mesh-ui]
  • -
- -

Systèmes de particules

- -
    -
  • [link:https://github.com/Alchemist0823/three.quarks three.quarks]
  • -
  • [link:https://github.com/creativelifeform/three-nebula three-nebula]
  • -
- -

Cinématique inverse

- -
    -
  • [link:https://github.com/jsantell/THREE.IK THREE.IK]
  • -
  • [link:https://github.com/lo-th/fullik fullik]
  • -
  • [link:https://github.com/gkjohnson/closed-chain-ik-js closed-chain-ik]
  • -
- -

IA de jeu

- -
    -
  • [link:https://mugen87.github.io/yuka/ yuka]
  • -
  • [link:https://github.com/donmccurdy/three-pathfinding three-pathfinding]
  • -
  • [link:https://github.com/isaac-mason/recast-navigation-js recast-navigation-js]
  • -
- -

Wrappers et Frameworks

- -
    -
  • [link:https://aframe.io/ A-Frame]
  • -
  • [link:https://lume.io/ Lume] - Éléments HTML pour graphismes 3D basés sur Three.
  • -
  • [link:https://github.com/pmndrs/react-three-fiber react-three-fiber] - Composants React pour graphismes 3D basés sur Three.
  • -
  • [link:https://threepipe.org/ threepipe] - Un framework de visualisation 3D polyvalent utilisant three.js pour le rendu.
  • -
  • [link:https://ecsyjs/ecsy-three ECSY]
  • -
  • [link:https://threlte.xyz/ Threlte] - Composants Svelte pour graphismes 3D basés sur Three.
  • -
  • [link:https://needle.tools/ Needle Engine]
  • -
  • [link:https://tresjs.org/ tresjs] - Composants Vue pour graphismes 3D basés sur Three.
  • -
  • [link:https://giro3d.org Giro3D] - Framework polyvalent basé sur Three pour visualiser et interagir avec des données géospatiales 2D, 2.5D et 3D.
  • -
  • [link:https://zap.works/mattercraft/ Mattercraft] - Éditeur visuel basé sur navigateur pour le contenu web AR, WebXR et 3D, construit sur three.js avec aperçu en temps réel et moteur physique.
  • -
- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/lights.html b/manual/fr/lights.html deleted file mode 100644 index 304887f22f22b1..00000000000000 --- a/manual/fr/lights.html +++ /dev/null @@ -1,490 +0,0 @@ - - - Lumières - - - - - - - - - - - - - -
-
-

Lumières

-
-
-
-

Cet article fait partie d'une série d'articles sur three.js. Le -premier article est les bases de three.js. Si -vous ne l'avez pas encore lu et que vous débutez avec three.js, vous pourriez envisager de -commencer par là, ainsi que l'article sur la configuration de votre environnement. Le -l'article précédent portait sur les textures.

-

Voyons comment utiliser les différents types de lumières dans three.js.

-

En partant d'un de nos exemples précédents, mettons à jour la caméra. -Nous définirons le champ de vision à 45 degrés, le plan lointain à 100 unités, -et nous déplacerons la caméra de 10 unités vers le haut et de 20 unités vers l'arrière par rapport à l'origine

-
*const fov = 45;
-const aspect = 2;  // the canvas default
-const near = 0.1;
-*const far = 100;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-+camera.position.set(0, 10, 20);
-
-

Ajoutons ensuite OrbitControls. Les OrbitControls -permettent à l'utilisateur de faire tourner ou d'orbiter la caméra autour d'un point. Les OrbitControls -sont une fonctionnalité optionnelle de three.js, nous devons donc d'abord les inclure -dans notre page

-
import * as THREE from 'three';
-+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-
-

Ensuite, nous pouvons les utiliser. Nous passons aux OrbitControls une caméra à -contrôler et l'élément DOM à utiliser pour obtenir les événements d'entrée

-
const controls = new OrbitControls(camera, canvas);
-controls.target.set(0, 5, 0);
-controls.update();
-
-

Nous définissons également la cible d'orbite à 5 unités au-dessus de l'origine -et appelons ensuite controls.update pour que les contrôles utilisent la nouvelle -cible.

-

Voyons ensuite comment créer des éléments à éclairer. D'abord, nous allons créer un -plan au sol. Nous appliquerons une petite texture en damier de 2x2 pixels qui -ressemble à ceci :

-
- -
- -

Nous chargeons d'abord la texture, la définissons en mode répétition, définissons le filtrage au -plus proche, et définissons le nombre de fois que nous voulons qu'elle se répète. Étant donné que la -texture est un damier de 2x2 pixels, en la répétant et en définissant la -répétition à la moitié de la taille du plan, chaque case du damier -aura exactement 1 unité de taille ;

-
const planeSize = 40;
-
-const loader = new THREE.TextureLoader();
-const texture = loader.load('resources/images/checker.png');
-texture.wrapS = THREE.RepeatWrapping;
-texture.wrapT = THREE.RepeatWrapping;
-texture.magFilter = THREE.NearestFilter;
-texture.colorSpace = THREE.SRGBColorSpace;
-const repeats = planeSize / 2;
-texture.repeat.set(repeats, repeats);
-
-

Nous créons ensuite une géométrie de plan, un matériau pour le plan et un maillage -pour l'insérer dans la scène. Les plans sont par défaut dans le plan XY, -mais le sol est dans le plan XZ, nous le faisons donc pivoter.

-
const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
-const planeMat = new THREE.MeshPhongMaterial({
-  map: texture,
-  side: THREE.DoubleSide,
-});
-const mesh = new THREE.Mesh(planeGeo, planeMat);
-mesh.rotation.x = Math.PI * -.5;
-scene.add(mesh);
-
-

Ajoutons un cube et une sphère pour avoir 3 éléments à éclairer, y compris le plan.

-
{
-  const cubeSize = 4;
-  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
-  const cubeMat = new THREE.MeshPhongMaterial({color: '#8AC'});
-  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
-  mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
-  scene.add(mesh);
-}
-{
-  const sphereRadius = 3;
-  const sphereWidthDivisions = 32;
-  const sphereHeightDivisions = 16;
-  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
-  const sphereMat = new THREE.MeshPhongMaterial({color: '#CA8'});
-  const mesh = new THREE.Mesh(sphereGeo, sphereMat);
-  mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
-  scene.add(mesh);
-}
-
-

Maintenant que nous avons une scène à éclairer, ajoutons des lumières !

-

AmbientLight

-

Commençons par créer une Lumière Ambiante

-
const color = 0xFFFFFF;
-const intensity = 1;
-const light = new THREE.AmbientLight(color, intensity);
-scene.add(light);
-
-

Faisons en sorte de pouvoir également ajuster les paramètres de la lumière. -Nous utiliserons de nouveau lil-gui. -Pour pouvoir ajuster la couleur via lil-gui, nous avons besoin d'un petit assistant -qui présente une propriété à lil-gui qui ressemble à une chaîne de couleur hexadécimale CSS -(par ex. : #FF8844). Notre assistant obtiendra la couleur d'une propriété nommée, -la convertira en chaîne hexadécimale pour l'offrir à lil-gui. -Lorsque lil-gui essaiera de définir la propriété de l'assistant, nous assignerons le résultat à la -couleur de la lumière.

-

Voici l'assistant :

-
class ColorGUIHelper {
-  constructor(object, prop) {
-    this.object = object;
-    this.prop = prop;
-  }
-  get value() {
-    return '#' + this.object[this.prop].getHexString();
-  }
-  set value(hexString) {
-    this.object[this.prop].set(hexString);
-  }
-}
-
-

Et voici notre code de configuration de lil-gui

-
const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('couleur');
-gui.add(light, 'intensity', 0, 5, 0.01);
-
-

Et voici le résultat

-

- -

-

Cliquez et faites glisser dans la scène pour faire orbiter la caméra.

-

Remarquez qu'il n'y a pas de définition. Les formes sont plates. La Lumière Ambiante -multiplie simplement la couleur du matériau par la couleur de la lumière multipliée par l' -intensité.

-
color = materialColor * light.color * light.intensity;
-

C'est tout. Elle n'a pas de direction. -Ce style d'éclairage ambiant n'est pas très utile en tant qu'éclairage car il est -uniformément réparti, donc à part changer la couleur -de tout dans la scène, il ne ressemble pas beaucoup à un éclairage. -Ce qui aide, c'est qu'il rend les zones sombres moins sombres.

-

HemisphereLight

-

Passons au code pour une Lumière Hémisphérique. Une Lumière Hémisphérique -prend une couleur de ciel et une couleur de sol et multiplie simplement la -couleur du matériau entre ces 2 couleurs — la couleur du ciel si la -surface de l'objet pointe vers le haut et la couleur du sol si -la surface de l'objet pointe vers le bas.

-

Voici le nouveau code

-
-const color = 0xFFFFFF;
-+const skyColor = 0xB1E1FF;  // light blue
-+const groundColor = 0xB97A20;  // brownish orange
-const intensity = 1;
--const light = new THREE.AmbientLight(color, intensity);
-+const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
-scene.add(light);
-
-

Mettons également à jour le code lil-gui pour éditer les deux couleurs

-
const gui = new GUI();
--gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-+gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('couleur du ciel');
-+gui.addColor(new ColorGUIHelper(light, 'groundColor'), 'value').name('couleur du sol');
-gui.add(light, 'intensity', 0, 5, 0.01);
-
-

Le résultat :

-

- -

-

Remarquez de nouveau qu'il n'y a presque pas de définition, tout semble un peu -plat. La Lumière Hémisphérique utilisée en combinaison avec une autre lumière -peut aider à donner une belle influence de la couleur du -ciel et du sol. De cette façon, elle est mieux utilisée en combinaison avec une -autre lumière ou en substitut d'une Lumière Ambiante.

-

DirectionalLight

-

Passons au code pour une Lumière Directionnelle. -Une Lumière Directionnelle est souvent utilisée pour représenter le soleil.

-
const color = 0xFFFFFF;
-const intensity = 1;
-const light = new THREE.DirectionalLight(color, intensity);
-light.position.set(0, 10, 0);
-light.target.position.set(-5, 0, 0);
-scene.add(light);
-scene.add(light.target);
-
-

Remarquez que nous avons dû ajouter la light et la light.target -à la scène. Une Lumière Directionnelle three.js -brillera dans la direction de sa cible.

-

Faisons en sorte de pouvoir déplacer la cible en l'ajoutant à -notre interface GUI.

-
const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('couleur');
-gui.add(light, 'intensity', 0, 5, 0.01);
-gui.add(light.target.position, 'x', -10, 10);
-gui.add(light.target.position, 'z', -10, 10);
-gui.add(light.target.position, 'y', 0, 10);
-
-

- -

-

Il est un peu difficile de voir ce qui se passe. Three.js dispose d'un ensemble -d'objets d'aide que nous pouvons ajouter à notre scène pour aider à visualiser -les parties invisibles d'une scène. Dans ce cas, nous utiliserons le -Helper de Lumière Directionnelle qui dessinera un plan, pour représenter -la lumière, et une ligne de la lumière à la cible. Nous lui -passons simplement la lumière et l'ajoutons à la scène.

-
const helper = new THREE.DirectionalLightHelper(light);
-scene.add(helper);
-
-

Pendant que nous y sommes, faisons en sorte de pouvoir définir à la fois la position -de la lumière et la cible. Pour ce faire, nous allons créer une fonction -qui, étant donné un Vector3, ajustera ses propriétés x, y, et z -en utilisant lil-gui.

-
function makeXYZGUI(gui, vector3, name, onChangeFn) {
-  const folder = gui.addFolder(name);
-  folder.add(vector3, 'x', -10, 10).onChange(onChangeFn);
-  folder.add(vector3, 'y', 0, 10).onChange(onChangeFn);
-  folder.add(vector3, 'z', -10, 10).onChange(onChangeFn);
-  folder.open();
-}
-
-

Notez que nous devons appeler la fonction update de l'assistant -chaque fois que nous changeons quelque chose afin que l'assistant sache qu'il doit se mettre à -jour. Ainsi, nous passons une fonction onChangeFn qui sera -appelée chaque fois que lil-gui met à jour une valeur.

-

Ensuite, nous pouvons l'utiliser à la fois pour la position de la lumière -et pour la position de la cible, comme ceci

-
+function updateLight() {
-+  light.target.updateMatrixWorld();
-+  helper.update();
-+}
-+updateLight();
-
-const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('couleur');
-gui.add(light, 'intensity', 0, 5, 0.01);
-
-+makeXYZGUI(gui, light.position, 'position', updateLight);
-+makeXYZGUI(gui, light.target.position, 'cible', updateLight);
-
-

Nous pouvons maintenant déplacer la lumière, et sa cible

-

- -

-

Faites orbiter la caméra et il devient plus facile de voir. Le plan -représente une Lumière Directionnelle car une lumière directionnelle -calcule la lumière venant dans une seule direction. Il n'y a pas de -point d'où la lumière provient, c'est un plan infini de lumière -émettant des rayons parallèles.

-

PointLight

-

Une Lumière Ponctuelle est une lumière qui se situe à un point et projette de la lumière -dans toutes les directions à partir de ce point. Modifions le code.

-
const color = 0xFFFFFF;
--const intensity = 1;
-+const intensity = 150;
--const light = new THREE.DirectionalLight(color, intensity);
-+const light = new THREE.PointLight(color, intensity);
-light.position.set(0, 10, 0);
--light.target.position.set(-5, 0, 0);
-scene.add(light);
--scene.add(light.target);
-
-

Passons également à un Helper de Lumière Ponctuelle

-
-const helper = new THREE.DirectionalLightHelper(light);
-+const helper = new THREE.PointLightHelper(light);
-scene.add(helper);
-
-

et comme il n'y a pas de cible, la fonction onChange peut être plus simple.

-
function updateLight() {
--  light.target.updateMatrixWorld();
-  helper.update();
-}
--updateLight();
-
-

Notez qu'à un certain niveau, un Helper de Lumière Ponctuelle n'a pas de... point. -Il dessine simplement un petit losange en fil de fer. Cela pourrait tout aussi facilement -être n'importe quelle forme que vous souhaitez, il suffit d'ajouter un maillage à la lumière elle-même.

-

Une Lumière Ponctuelle a la propriété supplémentaire de distance. -Si la distance est 0, alors la Lumière Ponctuelle brille à -l'infini. Si la distance est supérieure à 0, alors la lumière brille -à pleine intensité au niveau de la lumière et s'estompe jusqu'à ne plus avoir d'influence à -distance unités de distance de la lumière.

-

Configurons l'interface GUI pour que nous puissions ajuster la distance.

-
const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('couleur');
-gui.add(light, 'intensity', 0, 250, 1);
-+gui.add(light, 'distance', 0, 40).onChange(updateLight);
-
-makeXYZGUI(gui, light.position, 'position', updateLight);
--makeXYZGUI(gui, light.target.position, 'target', updateLight);
-
-

Et maintenant, essayez.

-

- -

-

Remarquez quand distance est > 0 comment la lumière s'estompe.

-

SpotLight

-

Les projecteurs sont effectivement une lumière ponctuelle avec un cône -attaché où la lumière ne brille qu'à l'intérieur du cône. -Il y a en fait 2 cônes. Un cône extérieur et un cône intérieur. -Entre le cône intérieur et le cône extérieur, la -lumière s'estompe de la pleine intensité à zéro.

-

Pour utiliser une Projecteur, nous avons besoin d'une cible, tout comme -pour la lumière directionnelle. Le cône de la lumière s'ouvrira -vers la cible.

-

En modifiant notre Lumière Directionnelle avec l'assistant d'en haut

-
const color = 0xFFFFFF;
--const intensity = 1;
-+const intensity = 150;
--const light = new THREE.DirectionalLight(color, intensity);
-+const light = new THREE.SpotLight(color, intensity);
-scene.add(light);
-scene.add(light.target);
-
--const helper = new THREE.DirectionalLightHelper(light);
-+const helper = new THREE.SpotLightHelper(light);
-scene.add(helper);
-
-

L'angle du cône du projecteur est défini avec la propriété angle -en radians. Nous utiliserons notre DegRadHelper de l'article sur les textures -pour présenter une interface utilisateur en -degrés.

-
gui.add(new DegRadHelper(light, 'angle'), 'value', 0, 90).name('angle').onChange(updateLight);
-
-

Le cône intérieur est défini en réglant la propriété pénombre -comme un pourcentage à partir du cône extérieur. En d'autres termes, quand penumbra est 0, alors le -cône intérieur a la même taille (0 = aucune différence) que le cône extérieur. Quand la -penumbra est 1, alors la lumière s'estompe en partant du centre du cône jusqu'au -cône extérieur. Quand penumbra est 0,5, alors la lumière s'estompe en partant de 50 % entre -le centre du cône extérieur.

-
gui.add(light, 'penumbra', 0, 1, 0.01);
-
-

- -

-

Remarquez qu'avec la penumbra par défaut de 0, le projecteur a un bord très net, -tandis que lorsque vous ajustez la penumbra vers 1, le bord devient flou.

-

Il peut être difficile de voir le cône du projecteur. La raison est qu'il est -en dessous du sol. Raccourcissez la distance à environ 5 et vous verrez l'extrémité ouverte -du cône.

-

RectAreaLight

-

Il existe un autre type de lumière, la Lumière Rectangulaire, qui représente -exactement ce à quoi cela ressemble : une zone rectangulaire de lumière, comme un long -néon fluorescent ou peut-être une lucarne dépolie dans un plafond.

-

La Lumière Rectangulaire ne fonctionne qu'avec les matériaux MeshStandardMaterial et -MeshPhysicalMaterial, nous allons donc changer tous nos matériaux en MeshStandardMaterial

-
  ...
-
-  const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
--  const planeMat = new THREE.MeshPhongMaterial({
-+  const planeMat = new THREE.MeshStandardMaterial({
-    map: texture,
-    side: THREE.DoubleSide,
-  });
-  const mesh = new THREE.Mesh(planeGeo, planeMat);
-  mesh.rotation.x = Math.PI * -.5;
-  scene.add(mesh);
-}
-{
-  const cubeSize = 4;
-  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
-- const cubeMat = new THREE.MeshPhongMaterial({color: '#8AC'});
-+ const cubeMat = new THREE.MeshStandardMaterial({color: '#8AC'});
-  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
-  mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
-  scene.add(mesh);
-}
-{
-  const sphereRadius = 3;
-  const sphereWidthDivisions = 32;
-  const sphereHeightDivisions = 16;
-  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
--  const sphereMat = new THREE.MeshPhongMaterial({color: '#CA8'});
-+ const sphereMat = new THREE.MeshStandardMaterial({color: '#CA8'});
-  const mesh = new THREE.Mesh(sphereGeo, sphereMat);
-  mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
-  scene.add(mesh);
-}
-
-

Pour utiliser la Lumière Rectangulaire, nous devons inclure des données optionnelles supplémentaires de three.js et nous inclurons le -Helper de Lumière Rectangulaire pour nous aider à visualiser la lumière

-
import * as THREE from 'three';
-+import {RectAreaLightUniformsLib} from 'three/addons/lights/RectAreaLightUniformsLib.js';
-+import {RectAreaLightHelper} from 'three/addons/helpers/RectAreaLightHelper.js';
-
-

et nous devons appeler RectAreaLightUniformsLib.init

-
function main() {
-  const canvas = document.querySelector('#c');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+  RectAreaLightUniformsLib.init();
-
-

Si vous oubliez les données, la lumière fonctionnera toujours mais elle aura un aspect étrange, alors -n'oubliez pas d'inclure les données supplémentaires.

-

Nous pouvons maintenant créer la lumière

-
const color = 0xFFFFFF;
-*const intensity = 5;
-+const width = 12;
-+const height = 4;
-*const light = new THREE.RectAreaLight(color, intensity, width, height);
-light.position.set(0, 10, 0);
-+light.rotation.x = THREE.MathUtils.degToRad(-90);
-scene.add(light);
-
-*const helper = new RectAreaLightHelper(light);
-*light.add(helper);
-
-

Une chose à noter est que, contrairement à la Lumière Directionnelle et au Projecteur, la -Lumière Rectangulaire n'utilise pas de cible. Elle utilise simplement sa rotation. Une autre chose -à noter est que l'assistant doit être un enfant de la lumière. Il n'est pas un enfant de la -scène comme les autres assistants.

-

Ajustons également l'interface GUI. Nous allons faire en sorte de pouvoir faire pivoter la lumière et ajuster -sa width et sa height

-
const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('couleur');
-gui.add(light, 'intensity', 0, 10, 0.01);
-gui.add(light, 'width', 0, 20);
-gui.add(light, 'height', 0, 20);
-gui.add(new DegRadHelper(light.rotation, 'x'), 'value', -180, 180).name('rotation x');
-gui.add(new DegRadHelper(light.rotation, 'y'), 'value', -180, 180).name('rotation y');
-gui.add(new DegRadHelper(light.rotation, 'z'), 'value', -180, 180).name('rotation z');
-
-makeXYZGUI(gui, light.position, 'position');
-
-

Et voici cela.

-

- -

-

Il est important de noter que chaque lumière que vous ajoutez à la scène ralentit la vitesse -de rendu de la scène par three.js, vous devriez donc toujours essayer d'en -utiliser le moins possible pour atteindre vos objectifs.

-

Ensuite, passons à la gestion des caméras.

-

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/load-gltf.html b/manual/fr/load-gltf.html deleted file mode 100644 index fc6661e871e17e..00000000000000 --- a/manual/fr/load-gltf.html +++ /dev/null @@ -1,588 +0,0 @@ - - - Chargement d'un fichier .GLTF - - - - - - - - - - - - - -
-
-

Chargement d'un fichier .GLTF

-
-
-
-

Dans une leçon précédente, nous avons chargé un fichier .OBJ. Si vous ne l'avez pas lue, vous pourriez vouloir la consulter d'abord.

-

Comme indiqué là-bas, le format de fichier .OBJ est très ancien et assez simple. Il ne fournit aucun graphe de scène, donc tout ce qui est chargé est un seul grand maillage. Il a été conçu principalement comme un moyen simple de passer des données entre des éditeurs 3D.

-

Le format gLTF est en réalité un format conçu dès le départ pour être utilisé pour afficher des graphiques. Les formats 3D peuvent être divisés en 3 ou 4 types de base.

-
    -
  • Formats d'éditeurs 3D

    -

    Ce sont des formats spécifiques à une seule application. .blend (Blender), .max (3d Studio Max), .mb et .ma (Maya), etc...

    -
  • -
  • Formats d'échange

    -

    Ce sont des formats comme .OBJ, .DAE (Collada), .FBX. Ils sont conçus pour aider à échanger des informations entre les éditeurs 3D. En tant que tels, ils sont généralement beaucoup plus volumineux que nécessaire avec des informations supplémentaires utilisées uniquement à l'intérieur des éditeurs 3D.

    -
  • -
  • Formats d'application

    -

    Ceux-ci sont généralement spécifiques à certaines applications, généralement des jeux.

    -
  • -
  • Formats de transmission

    -

    gLTF pourrait être le premier véritable format de transmission. Je suppose que VRML pourrait être considéré comme tel, mais VRML était en réalité un très mauvais format.

    -

    gLTF est conçu pour bien faire certaines choses que tous ces autres formats ne font pas

    -
      -
    1. Être petit pour la transmission

      -

      Par exemple, cela signifie qu'une grande partie de leurs données volumineuses, comme les sommets, est stockée en binaire. Lorsque vous téléchargez un fichier .gLTF, ces données peuvent être téléchargées sur le GPU sans aucun traitement. Elles sont prêtes telles quelles. C'est en contraste avec, par exemple, VRML, .OBJ ou .DAE où les sommets sont stockés sous forme de texte et doivent être analysés. Les positions de sommets en texte peuvent facilement être 3 à 5 fois plus volumineuses qu'en binaire.

      -
    2. -
    3. Être prêt à être rendu

      -

      C'est encore différent des autres formats, sauf peut-être les formats d'application. Les données d'un fichier glTF sont destinées à être rendues, pas éditées. Les données qui ne sont pas importantes pour le rendu ont généralement été supprimées. Les polygones ont été convertis en triangles. Les matériaux ont des valeurs connues qui sont censées fonctionner partout.

      -
    4. -
    -
  • -
-

gLTF a été spécifiquement conçu pour que vous puissiez télécharger un fichier glTF et l'afficher avec un minimum de problèmes. Croisons les doigts pour que ce soit vraiment le cas, car aucun autre format n'a été capable de faire cela.

-

Je n'étais pas vraiment sûr de ce que je devais montrer. À un certain niveau, le chargement et l'affichage d'un fichier gLTF sont plus simples qu'un fichier .OBJ. Contrairement à un fichier .OBJ, les matériaux font directement partie du format. Cela dit, j'ai pensé que je devais au moins en charger un et je pense qu'examiner les problèmes que j'ai rencontrés pourrait fournir de bonnes informations.

-

En cherchant sur le net, j'ai trouvé cette ville low-poly par antonmoek qui semblait, si nous avons de la chance, faire un bon exemple.

-
- -

En partant d'un exemple de l'article sur les fichiers .OBJ, j'ai supprimé le code de chargement de .OBJ et je l'ai remplacé par le code de chargement de .GLTF.

-

L'ancien code .OBJ était

-
const mtlLoader = new MTLLoader();
-mtlLoader.loadMtl('resources/models/windmill/windmill-fixed.mtl', (mtl) => {
-  mtl.preload();
-  mtl.materials.Material.side = THREE.DoubleSide;
-  objLoader.setMaterials(mtl);
-  objLoader.load('resources/models/windmill/windmill.obj', (event) => {
-    const root = event.detail.loaderRootNode;
-    scene.add(root);
-    ...
-  });
-});
-
-

Le nouveau code .GLTF est

-
{
-  const gltfLoader = new GLTFLoader();
-  const url = 'resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf';
-  gltfLoader.load(url, (gltf) => {
-    const root = gltf.scene;
-    scene.add(root);
-    ...
-  });
-
-

J'ai gardé le code de cadrage automatique comme auparavant.

-

Nous devons également inclure le GLTFLoader et nous pouvons nous débarrasser du OBJLoader.

-
-import {LoaderSupport} from 'three/addons/loaders/LoaderSupport.js';
--import {OBJLoader} from 'three/addons/loaders/OBJLoader.js';
--import {MTLLoader} from 'three/addons/loaders/MTLLoader.js';
-+import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
-
-

Et en exécutant cela, nous obtenons

-

- -

-

Magie ! Ça fonctionne tout seul, textures comprises.

-

Ensuite, je voulais voir si je pouvais animer les voitures qui circulent, j'ai donc eu besoin de vérifier si la scène avait les voitures comme entités séparées et si elles étaient configurées d'une manière que je pouvais utiliser.

-

J'ai écrit du code pour afficher le graphe de scène dans la console JavaScript.

-

Voici le code pour imprimer le graphe de scène.

-
function dumpObject(obj, lines = [], isLast = true, prefix = '') {
-  const localPrefix = isLast ? '└─' : '├─';
-  lines.push(`${prefix}${prefix ? localPrefix : ''}${obj.name || '*no-name*'} [${obj.type}]`);
-  const newPrefix = prefix + (isLast ? '  ' : '│ ');
-  const lastNdx = obj.children.length - 1;
-  obj.children.forEach((child, ndx) => {
-    const isLast = ndx === lastNdx;
-    dumpObject(child, lines, isLast, newPrefix);
-  });
-  return lines;
-}
-
-

Et je l'ai appelée juste après avoir chargé la scène.

-
const gltfLoader = new GLTFLoader();
-gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
-  const root = gltf.scene;
-  scene.add(root);
-  console.log(dumpObject(root).join('\n'));
-
-

En exécutant cela, j'ai obtenu cette liste

-
OSG_Scene [Scene]
-  └─RootNode_(gltf_orientation_matrix) [Object3D]
-    └─RootNode_(model_correction_matrix) [Object3D]
-      └─4d4100bcb1c640e69699a87140df79d7fbx [Object3D]
-        └─RootNode [Object3D]
-          │ ...
-          ├─Cars [Object3D]
-          │ ├─CAR_03_1 [Object3D]
-          │ │ └─CAR_03_1_World_ap_0 [Mesh]
-          │ ├─CAR_03 [Object3D]
-          │ │ └─CAR_03_World_ap_0 [Mesh]
-          │ ├─Car_04 [Object3D]
-          │ │ └─Car_04_World_ap_0 [Mesh]
-          │ ├─CAR_03_2 [Object3D]
-          │ │ └─CAR_03_2_World_ap_0 [Mesh]
-          │ ├─Car_04_1 [Object3D]
-          │ │ └─Car_04_1_World_ap_0 [Mesh]
-          │ ├─Car_04_2 [Object3D]
-          │ │ └─Car_04_2_World_ap_0 [Mesh]
-          │ ├─Car_04_3 [Object3D]
-          │ │ └─Car_04_3_World_ap_0 [Mesh]
-          │ ├─Car_04_4 [Object3D]
-          │ │ └─Car_04_4_World_ap_0 [Mesh]
-          │ ├─Car_08_4 [Object3D]
-          │ │ └─Car_08_4_World_ap8_0 [Mesh]
-          │ ├─Car_08_3 [Object3D]
-          │ │ └─Car_08_3_World_ap9_0 [Mesh]
-          │ ├─Car_04_1_2 [Object3D]
-          │ │ └─Car_04_1_2_World_ap_0 [Mesh]
-          │ ├─Car_08_2 [Object3D]
-          │ │ └─Car_08_2_World_ap11_0 [Mesh]
-          │ ├─CAR_03_1_2 [Object3D]
-          │ │ └─CAR_03_1_2_World_ap_0 [Mesh]
-          │ ├─CAR_03_2_2 [Object3D]
-          │ │ └─CAR_03_2_2_World_ap_0 [Mesh]
-          │ ├─Car_04_2_2 [Object3D]
-          │ │ └─Car_04_2_2_World_ap_0 [Mesh]
-          ...
-
-

À partir de cela, nous pouvons voir que toutes les voitures se trouvent sous un parent appelé "Cars".

-
*          ├─Cars [Object3D]
-          │ ├─CAR_03_1 [Object3D]
-          │ │ └─CAR_03_1_World_ap_0 [Mesh]
-          │ ├─CAR_03 [Object3D]
-          │ │ └─CAR_03_World_ap_0 [Mesh]
-          │ ├─Car_04 [Object3D]
-          │ │ └─Car_04_World_ap_0 [Mesh]
-
-

Donc, comme test simple, j'ai pensé que j'essaierais juste de faire tourner tous les enfants du nœud "Cars" autour de leur axe Y.

-

J'ai cherché le nœud "Cars" après avoir chargé la scène et j'ai sauvegardé le résultat.

-
+let cars;
-{
-  const gltfLoader = new GLTFLoader();
-  gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
-    const root = gltf.scene;
-    scene.add(root);
-+    cars = root.getObjectByName('Cars');
-
-

Ensuite, dans la fonction render, nous pouvons simplement définir la rotation de chaque enfant de cars.

-
+function render(time) {
-+  time *= 0.001;  // convertir en secondes
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-+  if (cars) {
-+    for (const car of cars.children) {
-+      car.rotation.y = time;
-+    }
-+  }
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-

Et nous obtenons

-

- -

-

Hmmm, il semble que malheureusement cette scène n'a pas été conçue pour animer les voitures, car leurs origines ne sont pas configurées à cette fin. Les camions tournent dans la mauvaise direction.

-

Cela soulève un point important : si vous allez faire quelque chose en 3D, vous devez planifier à l'avance et concevoir vos éléments de manière à ce que leurs origines soient aux bons endroits, qu'ils aient la bonne échelle, etc.

-

Comme je ne suis pas un artiste et que je ne connais pas très bien Blender, je vais bricoler cet exemple. Nous allons prendre chaque voiture et la faire appartenir à un autre Object3D. Nous allons ensuite déplacer ces objets Object3D pour déplacer les voitures, mais séparément, nous pouvons définir l'Object3D d'origine de la voiture pour la réorienter afin qu'elle soit à peu près là où nous en avons vraiment besoin.

-

En regardant à nouveau la liste du graphe de scène, il semble qu'il n'y ait en réalité que 3 types de voitures : "Car_08", "CAR_03" et "Car_04". Espérons que chaque type de voiture fonctionnera avec les mêmes ajustements.

-

J'ai écrit ce code pour passer en revue chaque voiture, la faire appartenir à un nouvel Object3D, faire appartenir ce nouvel Object3D à la scène, appliquer des paramètres par *type* de voiture pour corriger son orientation, et ajouter le nouvel Object3D à un tableau cars.

-
-let cars;
-+const cars = [];
-{
-  const gltfLoader = new GLTFLoader();
-  gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
-    const root = gltf.scene;
-    scene.add(root);
-
--    cars = root.getObjectByName('Cars');
-+    const loadedCars = root.getObjectByName('Cars');
-+    const fixes = [
-+      { prefix: 'Car_08', rot: [Math.PI * .5, 0, Math.PI * .5], },
-+      { prefix: 'CAR_03', rot: [0, Math.PI, 0], },
-+      { prefix: 'Car_04', rot: [0, Math.PI, 0], },
-+    ];
-+
-+    root.updateMatrixWorld();
-+    for (const car of loadedCars.children.slice()) {
-+      const fix = fixes.find(fix => car.name.startsWith(fix.prefix));
-+      const obj = new THREE.Object3D();
-+      car.getWorldPosition(obj.position);
-+      car.position.set(0, 0, 0);
-+      car.rotation.set(...fix.rot);
-+      obj.add(car);
-+      scene.add(obj);
-+      cars.push(obj);
-+    }
-     ...
-
-

Cela corrige l'orientation des voitures.

-

- -

-

Maintenant, faisons-les rouler.

-

Faire même un simple système de conduite est trop pour ce post, mais il semble qu'au lieu de cela, nous pourrions simplement créer un chemin alambiqué qui parcourt toutes les routes, puis placer les voitures sur ce chemin. Voici une image de Blender à mi-chemin de la construction du chemin.

-
- -

J'avais besoin d'un moyen d'obtenir les données de ce chemin depuis Blender. Heureusement, j'ai pu sélectionner juste mon chemin et exporter en .OBJ en cochant "write nurbs".

-
- -

En ouvrant le fichier .OBJ, j'ai pu obtenir une liste de points que j'ai formatée ainsi

-
const controlPoints = [
-  [1.118281, 5.115846, -3.681386],
-  [3.948875, 5.115846, -3.641834],
-  [3.960072, 5.115846, -0.240352],
-  [3.985447, 5.115846, 4.585005],
-  [-3.793631, 5.115846, 4.585006],
-  [-3.826839, 5.115846, -14.736200],
-  [-14.542292, 5.115846, -14.765865],
-  [-14.520929, 5.115846, -3.627002],
-  [-5.452815, 5.115846, -3.634418],
-  [-5.467251, 5.115846, 4.549161],
-  [-13.266233, 5.115846, 4.567083],
-  [-13.250067, 5.115846, -13.499271],
-  [4.081842, 5.115846, -13.435463],
-  [4.125436, 5.115846, -5.334928],
-  [-14.521364, 5.115846, -5.239871],
-  [-14.510466, 5.115846, 5.486727],
-  [5.745666, 5.115846, 5.510492],
-  [5.787942, 5.115846, -14.728308],
-  [-5.423720, 5.115846, -14.761919],
-  [-5.373599, 5.115846, -3.704133],
-  [1.004861, 5.115846, -3.641834],
-];
-
-

THREE.js possède des classes de courbes. La CatmullRomCurve3 semblait pouvoir fonctionner. L'intérêt de ce type de courbe est qu'elle essaie de créer une courbe lisse passant par les points.

-

En fait, l'insertion directe de ces points générera une courbe comme celle-ci

-
- -

mais nous voulons des coins plus marqués. Il semblait que si nous calculions des points supplémentaires, nous pourrions obtenir ce que nous voulons. Pour chaque paire de points, nous allons calculer un point à 10 % du chemin entre les 2 points et un autre à 90 % du chemin entre les 2 points, et passer le résultat à CatmullRomCurve3.

-

Cela nous donnera une courbe comme celle-ci

-
- -

Voici le code pour créer la courbe

-
let curve;
-let curveObject;
-{
-  const controlPoints = [
-    [1.118281, 5.115846, -3.681386],
-    [3.948875, 5.115846, -3.641834],
-    [3.960072, 5.115846, -0.240352],
-    [3.985447, 5.115846, 4.585005],
-    [-3.793631, 5.115846, 4.585006],
-    [-3.826839, 5.115846, -14.736200],
-    [-14.542292, 5.115846, -14.765865],
-    [-14.520929, 5.115846, -3.627002],
-    [-5.452815, 5.115846, -3.634418],
-    [-5.467251, 5.115846, 4.549161],
-    [-13.266233, 5.115846, 4.567083],
-    [-13.250067, 5.115846, -13.499271],
-    [4.081842, 5.115846, -13.435463],
-    [4.125436, 5.115846, -5.334928],
-    [-14.521364, 5.115846, -5.239871],
-    [-14.510466, 5.115846, 5.486727],
-    [5.745666, 5.115846, 5.510492],
-    [5.787942, 5.115846, -14.728308],
-    [-5.423720, 5.115846, -14.761919],
-    [-5.373599, 5.115846, -3.704133],
-    [1.004861, 5.115846, -3.641834],
-  ];
-  const p0 = new THREE.Vector3();
-  const p1 = new THREE.Vector3();
-  curve = new THREE.CatmullRomCurve3(
-    controlPoints.map((p, ndx) => {
-      p0.set(...p);
-      p1.set(...controlPoints[(ndx + 1) % controlPoints.length]);
-      return [
-        (new THREE.Vector3()).copy(p0),
-        (new THREE.Vector3()).lerpVectors(p0, p1, 0.1),
-        (new THREE.Vector3()).lerpVectors(p0, p1, 0.9),
-      ];
-    }).flat(),
-    true,
-  );
-  {
-    const points = curve.getPoints(250);
-    const geometry = new THREE.BufferGeometry().setFromPoints(points);
-    const material = new THREE.LineBasicMaterial({color: 0xff0000});
-    curveObject = new THREE.Line(geometry, material);
-    scene.add(curveObject);
-  }
-}
-
-

La première partie de ce code crée une courbe. La deuxième partie de ce code génère 250 points à partir de la courbe, puis crée un objet pour afficher les lignes formées en connectant ces 250 points.

-

En exécutant l'exemple, je n'ai pas vu la courbe. Pour la rendre visible, je l'ai fait ignorer le test de profondeur et la rendre en dernier.

-
    curveObject = new THREE.Line(geometry, material);
-+    material.depthTest = false;
-+    curveObject.renderOrder = 1;
-
-

Et c'est là que j'ai découvert qu'elle était beaucoup trop petite.

-
- -

En vérifiant la hiérarchie dans Blender, j'ai découvert que l'artiste avait mis à l'échelle le nœud parent de toutes les voitures.

-
- -

La mise à l'échelle est mauvaise pour les applications 3D en temps réel. Elle cause toutes sortes de problèmes et finit par être une source de frustration infinie lors de la création d'applications 3D en temps réel. Les artistes ne le savent souvent pas car il est si facile de mettre à l'échelle une scène entière dans un programme d'édition 3D, mais si vous décidez de créer une application 3D en temps réel, je vous suggère de demander à vos artistes de ne jamais rien mettre à l'échelle. S'ils modifient l'échelle, ils devraient trouver un moyen d'appliquer cette échelle aux sommets afin que, lorsqu'elle arrive dans votre application, vous puissiez ignorer l'échelle.

-

Et, pas seulement l'échelle, dans ce cas, les voitures sont tournées et décalées par leur parent, le nœud Cars. Cela rendra difficile au moment de l'exécution de déplacer les voitures dans l'espace mondial. Pour être clair, dans ce cas, nous voulons que les voitures circulent dans l'espace mondial, c'est pourquoi ces problèmes se posent. Si quelque chose est destiné à être manipulé dans un espace local, comme la lune tournant autour de la terre, c'est moins problématique.

-

Pour en revenir à la fonction que nous avons écrite ci-dessus pour afficher le graphe de scène, affichons la position, la rotation et l'échelle de chaque nœud.

-
+function dumpVec3(v3, precision = 3) {
-+  return `${v3.x.toFixed(precision)}, ${v3.y.toFixed(precision)}, ${v3.z.toFixed(precision)}`;
-+}
-
-function dumpObject(obj, lines, isLast = true, prefix = '') {
-  const localPrefix = isLast ? '└─' : '├─';
-  lines.push(`${prefix}${prefix ? localPrefix : ''}${obj.name || '*no-name*'} [${obj.type}]`);
-+  const dataPrefix = obj.children.length
-+     ? (isLast ? '  │ ' : '│ │ ')
-+     : (isLast ? '    ' : '│   ');
-+  lines.push(`${prefix}${dataPrefix}  pos: ${dumpVec3(obj.position)}`);
-+  lines.push(`${prefix}${dataPrefix}  rot: ${dumpVec3(obj.rotation)}`);
-+  lines.push(`${prefix}${dataPrefix}  scl: ${dumpVec3(obj.scale)}`);
-  const newPrefix = prefix + (isLast ? '  ' : '│ ');
-  const lastNdx = obj.children.length - 1;
-  obj.children.forEach((child, ndx) => {
-    const isLast = ndx === lastNdx;
-    dumpObject(child, lines, isLast, newPrefix);
-  });
-  return lines;
-}
-
-

Et le résultat de l'exécution

-
OSG_Scene [Scene]
-  │   pos: 0.000, 0.000, 0.000
-  │   rot: 0.000, 0.000, 0.000
-  │   scl: 1.000, 1.000, 1.000
-  └─RootNode_(gltf_orientation_matrix) [Object3D]
-    │   pos: 0.000, 0.000, 0.000
-    │   rot: -1.571, 0.000, 0.000
-    │   scl: 1.000, 1.000, 1.000
-    └─RootNode_(model_correction_matrix) [Object3D]
-      │   pos: 0.000, 0.000, 0.000
-      │   rot: 0.000, 0.000, 0.000
-      │   scl: 1.000, 1.000, 1.000
-      └─4d4100bcb1c640e69699a87140df79d7fbx [Object3D]
-        │   pos: 0.000, 0.000, 0.000
-        │   rot: 1.571, 0.000, 0.000
-        │   scl: 1.000, 1.000, 1.000
-        └─RootNode [Object3D]
-          │   pos: 0.000, 0.000, 0.000
-          │   rot: 0.000, 0.000, 0.000
-          │   scl: 1.000, 1.000, 1.000
-          ├─Cars [Object3D]
-*          │ │   pos: -369.069, -90.704, -920.159
-*          │ │   rot: 0.000, 0.000, 0.000
-*          │ │   scl: 1.000, 1.000, 1.000
-          │ ├─CAR_03_1 [Object3D]
-          │ │ │   pos: 22.131, 14.663, -475.071
-          │ │ │   rot: -3.142, 0.732, 3.142
-          │ │ │   scl: 1.500, 1.500, 1.500
-          │ │ └─CAR_03_1_World_ap_0 [Mesh]
-          │ │       pos: 0.000, 0.000, 0.000
-          │ │       rot: 0.000, 0.000, 0.000
-          │ │       scl: 1.000, 1.000, 1.000
-
-

Cela nous montre que l'Cars dans la scène originale a vu sa rotation et son échelle supprimées et appliquées à ses enfants. Cela suggère que soit l'exportateur utilisé pour créer le fichier .GLTF a fait un travail spécial ici, soit plus probablement l'artiste a exporté une version différente du fichier que le fichier .blend correspondant, ce qui explique pourquoi les choses ne correspondent pas.

-

La morale de l'histoire est que j'aurais probablement dû télécharger le fichier .blend et exporter moi-même. Avant d'exporter, j'aurais dû inspecter tous les nœuds principaux et supprimer toute transformation.

-

Tous ces nœuds en haut

-
OSG_Scene [Scene]
-  │   pos: 0.000, 0.000, 0.000
-  │   rot: 0.000, 0.000, 0.000
-  │   scl: 1.000, 1.000, 1.000
-  └─RootNode_(gltf_orientation_matrix) [Object3D]
-    │   pos: 0.000, 0.000, 0.000
-    │   rot: -1.571, 0.000, 0.000
-    │   scl: 1.000, 1.000, 1.000
-    └─RootNode_(model_correction_matrix) [Object3D]
-      │   pos: 0.000, 0.000, 0.000
-      │   rot: 0.000, 0.000, 0.000
-      │   scl: 1.000, 1.000, 1.000
-      └─4d4100bcb1c640e69699a87140df79d7fbx [Object3D]
-        │   pos: 0.000, 0.000, 0.000
-        │   rot: 1.571, 0.000, 0.000
-        │   scl: 1.000, 1.000, 1.000
-
-

sont également un gaspillage.

-

Idéalement, la scène devrait se composer d'un seul nœud "racine" sans position, rotation ou échelle. Au moment de l'exécution, je pourrais alors retirer tous les enfants de cette racine et les faire appartenir à la scène elle-même. Il pourrait y avoir des enfants de la racine comme "Cars" qui m'aideraient à trouver toutes les voitures, mais idéalement, il n'aurait pas non plus de translation, rotation ou échelle afin que je puisse rattacher les voitures à la scène avec un minimum de travail.

-

En tout cas, la solution la plus rapide, même si ce n'est peut-être pas la meilleure, est de simplement ajuster l'objet que nous utilisons pour visualiser la courbe.

-

Voici ce que j'ai obtenu au final.

-

D'abord, j'ai ajusté la position de la courbe et trouvé des valeurs qui semblaient fonctionner. Je l'ai ensuite cachée.

-
{
-  const points = curve.getPoints(250);
-  const geometry = new THREE.BufferGeometry().setFromPoints(points);
-  const material = new THREE.LineBasicMaterial({color: 0xff0000});
-  curveObject = new THREE.Line(geometry, material);
-+  curveObject.scale.set(100, 100, 100);
-+  curveObject.position.y = -621;
-+  curveObject.visible = false;
-  material.depthTest = false;
-  curveObject.renderOrder = 1;
-  scene.add(curveObject);
-}
-
-

Ensuite, j'ai écrit du code pour déplacer les voitures le long de la courbe. Pour chaque voiture, nous choisissons une position de 0 à 1 le long de la courbe et calculons un point dans l'espace mondial en utilisant l'curveObject pour transformer le point. Nous choisissons ensuite un autre point légèrement plus loin sur la courbe. Nous définissons l'orientation de la voiture en utilisant lookAt et plaçons la voiture au point médian entre les 2 points.

-
// créer 2 Vector3 que nous pouvons utiliser pour les calculs de chemin
-const carPosition = new THREE.Vector3();
-const carTarget = new THREE.Vector3();
-
-function render(time) {
-  ...
-
--  for (const car of cars) {
--    car.rotation.y = time;
--  }
-
-+  {
-+    const pathTime = time * .01;
-+    const targetOffset = 0.01;
-+    cars.forEach((car, ndx) => {
-+      // un nombre entre 0 et 1 pour espacer uniformément les voitures
-+      const u = pathTime + ndx / cars.length;
-+
-+      // obtenir le premier point
-+      curve.getPointAt(u % 1, carPosition);
-+      carPosition.applyMatrix4(curveObject.matrixWorld);
-+
-+      // obtenir un deuxième point légèrement plus loin sur la courbe
-+      curve.getPointAt((u + targetOffset) % 1, carTarget);
-+      carTarget.applyMatrix4(curveObject.matrixWorld);
-+
-+      // placer la voiture au premier point (temporairement)
-+      car.position.copy(carPosition);
-+      // orienter la voiture vers le deuxième point
-+      car.lookAt(carTarget);
-+
-+      // placer la voiture entre les 2 points
-+      car.position.lerpVectors(carPosition, carTarget, 0.5);
-+    });
-+  }
-
-

et quand je l'ai exécuté, j'ai découvert que pour chaque type de voiture, leur hauteur au-dessus de leurs origines n'est pas définie de manière cohérente, et j'ai donc dû décaler chacune un peu.

-
const loadedCars = root.getObjectByName('Cars');
-const fixes = [
--  { prefix: 'Car_08', rot: [Math.PI * .5, 0, Math.PI * .5], },
--  { prefix: 'CAR_03', rot: [0, Math.PI, 0], },
--  { prefix: 'Car_04', rot: [0, Math.PI, 0], },
-+  { prefix: 'Car_08', y: 0,  rot: [Math.PI * .5, 0, Math.PI * .5], },
-+  { prefix: 'CAR_03', y: 33, rot: [0, Math.PI, 0], },
-+  { prefix: 'Car_04', y: 40, rot: [0, Math.PI, 0], },
-];
-
-root.updateMatrixWorld();
-for (const car of loadedCars.children.slice()) {
-  const fix = fixes.find(fix => car.name.startsWith(fix.prefix));
-  const obj = new THREE.Object3D();
-  car.getWorldPosition(obj.position);
--  car.position.set(0, 0, 0);
-+  car.position.set(0, fix.y, 0);
-  car.rotation.set(...fix.rot);
-  obj.add(car);
-  scene.add(obj);
-  cars.push(obj);
-}
-
-

Et le résultat.

-

- -

-

Pas mal pour quelques minutes de travail.

-

La dernière chose que je voulais faire est d'activer les ombres.

-

Pour ce faire, j'ai pris tout le code GUI de l'exemple d'ombres DirectionalLight dans l'article sur les ombres et l'ai collé dans notre dernier code.

-

Ensuite, après le chargement, nous devons activer les ombres sur tous les objets.

-
{
-  const gltfLoader = new GLTFLoader();
-  gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
-    const root = gltf.scene;
-    scene.add(root);
-
-+    root.traverse((obj) => {
-+      if (obj.castShadow !== undefined) {
-+        obj.castShadow = true;
-+        obj.receiveShadow = true;
-+      }
-+    });
-
-

J'ai ensuite passé près de 4 heures à essayer de comprendre pourquoi les helpers d'ombre ne fonctionnaient pas. C'était parce que j'avais oublié d'activer les ombres avec

-
renderer.shadowMap.enabled = true;
-
-

😭

-

J'ai ensuite ajusté les valeurs jusqu'à ce que la caméra d'ombre de notre DirectionLight ait un frustum qui couvrait toute la scène. Voici les paramètres avec lesquels j'ai fini.

-
{
-  const color = 0xFFFFFF;
-  const intensity = 1;
-  const light = new THREE.DirectionalLight(color, intensity);
-+  light.castShadow = true;
-*  light.position.set(-250, 800, -850);
-*  light.target.position.set(-550, 40, -450);
-
-+  light.shadow.bias = -0.004;
-+  light.shadow.mapSize.width = 2048;
-+  light.shadow.mapSize.height = 2048;
-
-  scene.add(light);
-  scene.add(light.target);
-+  const cam = light.shadow.camera;
-+  cam.near = 1;
-+  cam.far = 2000;
-+  cam.left = -1500;
-+  cam.right = 1500;
-+  cam.top = 1500;
-+  cam.bottom = -1500;
-...
-
-

et j'ai défini la couleur de fond en bleu clair.

-
const scene = new THREE.Scene();
--scene.background = new THREE.Color('black');
-+scene.background = new THREE.Color('#DEFEFF');
-
-

Et ... les ombres

-

- -

-

J'espère que parcourir ce projet a été utile et a montré de bons exemples de résolution de certains des problèmes liés au chargement d'un fichier avec un graphe de scène.

-

Une chose intéressante est qu'en comparant le fichier .blend au fichier .gltf, le fichier .blend a plusieurs lumières mais elles ne sont pas des lumières après avoir été chargées dans la scène. Un fichier .GLTF est juste un fichier JSON, vous pouvez donc facilement regarder à l'intérieur. Il se compose de plusieurs tableaux de choses et chaque élément dans un tableau est référencé par index ailleurs. Bien qu'il y ait des extensions en cours de développement, elles soulignent un problème commun à presque tous les formats 3D. Ils ne peuvent jamais couvrir tous les cas.

-

Il y a toujours un besoin de plus de données. Par exemple, nous avons exporté manuellement un chemin pour les voitures à suivre. Idéalement, cette information aurait pu être dans le fichier .GLTF, mais pour ce faire, nous aurions besoin d'écrire notre propre exportateur et de marquer d'une manière ou d'une autre les nœuds pour la façon dont nous voulons qu'ils soient exportés, ou utiliser un schéma de nommage ou quelque chose de similaire pour obtenir les données de l'outil que nous utilisons pour créer les données dans notre application.

-

Tout cela est laissé comme un exercice pour le lecteur.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/load-obj.html b/manual/fr/load-obj.html deleted file mode 100644 index f068e25dd2aaa7..00000000000000 --- a/manual/fr/load-obj.html +++ /dev/null @@ -1,620 +0,0 @@ - - - Charger un fichier .OBJ - - - - - - - - - - - - - -
-
-

Charger un fichier .OBJ

-
-
-
-

L'une des choses les plus courantes que les gens veulent faire avec three.js -est de charger et d'afficher des modèles 3D. Un format courant est le format 3D -.OBJ, alors essayons d'en charger un.

-

En cherchant sur internet, j'ai trouvé ce modèle 3D de moulin à vent sous licence CC-BY-NC 3.0 par ahedov.

-
- -

J'ai téléchargé le fichier .blend à partir de ce site, je l'ai chargé dans Blender -et l'ai exporté en tant que fichier .OBJ.

-
- -
-

Note : Si vous n'avez jamais utilisé Blender, vous pourriez être surpris -par le fait que Blender fait les choses différemment de presque tous les autres -programmes que vous avez utilisés. Sachez simplement que vous pourriez avoir besoin de -prendre le temps de lire quelques bases sur la navigation dans l'interface utilisateur de Blender.

-

Permettez-moi d'ajouter également que les programmes 3D en général sont d'énormes bêtes avec -des milliers de fonctionnalités. Ce sont parmi les logiciels les plus compliqués qui existent. -Lorsque j'ai appris 3D Studio Max en 1996, j'ai lu 70% du manuel -de 600 pages en y consacrant quelques heures par jour pendant environ 3 semaines. Cela a -porté ses fruits, car lorsque j'ai appris Maya quelques années plus tard, certaines des leçons -apprises auparavant étaient applicables à Maya. Alors, sachez simplement que si vous -voulez vraiment pouvoir utiliser un logiciel 3D pour créer des assets 3D -ou pour modifier des assets existants, inscrivez-le à votre emploi du temps et réservez -du temps pour vraiment suivre quelques leçons.

-
-

Dans tous les cas, j'ai utilisé ces options d'exportation

-
- -

Essayons de l'afficher !

-

Je suis parti de l'exemple d'éclairage directionnel de -l'article sur les lumières et je l'ai combiné avec -l'exemple d'éclairage hémisphérique, j'ai donc fini avec une -HemisphereLight et une DirectionalLight. J'ai également supprimé tout ce qui concerne l'interface utilisateur -pour régler les lumières. J'ai également supprimé le cube et la sphère -qui étaient ajoutés à la scène.

-

À partir de là, la première chose à faire est d'inclure le chargeur OBJLoader -dans notre script.

-
import {OBJLoader} from 'three/addons/loaders/OBJLoader.js';
-
-

Ensuite, pour charger le fichier .OBJ, nous créons une instance de OBJLoader, -lui passons l'URL de notre fichier .OBJ, et ajoutons un rappel qui -ajoute le modèle chargé à notre scène.

-
{
-  const objLoader = new OBJLoader();
-  objLoader.load('resources/models/windmill/windmill.obj', (root) => {
-    scene.add(root);
-  });
-}
-
-

Si nous exécutons cela, que se passe-t-il ?

-

- -

-

Eh bien, c'est presque ça, mais nous obtenons des erreurs concernant les matériaux, -car nous n'avons pas donné de matériaux à la scène et les fichiers .OBJ n'ont pas de -paramètres de matériaux.

-

Le chargeur .OBJ peut recevoir un -objet de paires nom / matériau. Lorsqu'il charge le fichier .OBJ, -pour chaque nom de matériau qu'il trouve, il cherchera le matériau correspondant -dans la carte des matériaux définie sur le chargeur. S'il trouve un -matériau correspondant au nom, il utilisera ce matériau. Sinon, -il utilisera le matériau par défaut du chargeur.

-

Parfois, les fichiers .OBJ sont accompagnés d'un fichier .MTL qui définit -les matériaux. Dans notre cas, l'exportateur a également créé un fichier .MTL. -Le format .MTL est du texte brut (ASCII), il est donc facile à examiner. -En le regardant ici

-
# Blender MTL File: 'windmill_001.blend'
-# Material Count: 2
-
-newmtl Material
-Ns 0.000000
-Ka 1.000000 1.000000 1.000000
-Kd 0.800000 0.800000 0.800000
-Ks 0.000000 0.000000 0.000000
-Ke 0.000000 0.000000 0.000000
-Ni 1.000000
-d 1.000000
-illum 1
-map_Kd windmill_001_lopatky_COL.jpg
-map_Bump windmill_001_lopatky_NOR.jpg
-
-newmtl windmill
-Ns 0.000000
-Ka 1.000000 1.000000 1.000000
-Kd 0.800000 0.800000 0.800000
-Ks 0.000000 0.000000 0.000000
-Ke 0.000000 0.000000 0.000000
-Ni 1.000000
-d 1.000000
-illum 1
-map_Kd windmill_001_base_COL.jpg
-map_Bump windmill_001_base_NOR.jpg
-map_Ns windmill_001_base_SPEC.jpg
-
-

Nous pouvons voir qu'il y a 2 matériaux référençant 5 textures jpg, -mais où sont les fichiers de texture ?

-
- -

Tout ce que nous avons obtenu était un fichier .OBJ et un fichier .MTL.

-

Au moins pour ce modèle, il s'avère que les textures sont intégrées -dans le fichier .blend que nous avons téléchargé. Nous pouvons demander à Blender -d'exporter ces fichiers en choisissant Fichier->Données externes->Désintégrer tout en fichiers (File->External Data->Unpack All Into Files)

-
- -

puis en choisissant Écrire les fichiers dans le répertoire actuel (Write Files to Current Directory)

-
- -

Cela finit par écrire les fichiers dans le même dossier que le fichier .blend, -dans un sous-dossier appelé textures.

-
- -

Maintenant que les textures sont disponibles, nous pouvons charger le fichier .MTL.

-

D'abord, nous devons inclure le MTLLoader ;

-
import * as THREE from 'three';
-import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-import {OBJLoader} from 'three/addons/loaders/OBJLoader.js';
-+import {MTLLoader} from 'three/addons/loaders/MTLLoader.js';
-
-

Ensuite, nous chargeons d'abord le fichier .MTL. Une fois le chargement terminé, -nous ajoutons les matériaux tout juste chargés au OBJLoader lui-même via la méthode setMaterials, -puis nous chargeons le fichier .OBJ.

-
{
-+  const mtlLoader = new MTLLoader();
-+  mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => {
-+    mtl.preload();
-+    objLoader.setMaterials(mtl);
-    objLoader.load('resources/models/windmill/windmill.obj', (root) => {
-      scene.add(root);
-    });
-+  });
-}
-
-

Et si nous essayons cela...

-

- -

-

Notez que si vous faites tourner le modèle, vous verrez le tissu du moulin disparaître

-
- -

Nous devons rendre le matériau des pales double face, -ce que nous avons vu dans l'article sur les matériaux. -Il n'y a pas de moyen facile de corriger cela dans le fichier .MTL. -Spontanément, je peux penser à 3 façons de corriger cela.

-
    -
  1. Parcourir tous les matériaux après les avoir chargés et les rendre tous double face.

    -
     const mtlLoader = new MTLLoader();
    - mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => {
    -   mtl.preload();
    -   for (const material of Object.values(mtl.materials)) {
    -     material.side = THREE.DoubleSide;
    -   }
    -   ...
    -

    Cette solution fonctionne, mais idéalement, nous ne voulons que les matériaux qui ont besoin -d'être double face soient double face, car dessiner en double face -est plus lent qu'en simple face.

    -
  2. -
  3. Définir manuellement un matériau spécifique

    -

    En regardant dans le fichier .MTL, il y a 2 matériaux. L'un s'appelle "windmill" -et l'autre s'appelle "Material". Par essais et erreurs, j'ai découvert -que les pales utilisent le matériau appelé "Material", nous pourrions donc le définir -spécifiquement

    -
     const mtlLoader = new MTLLoader();
    - mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => {
    -   mtl.preload();
    -   mtl.materials.Material.side = THREE.DoubleSide;
    -   ...
    -
  4. -
  5. Réalisant que le fichier .MTL est limité, nous pourrions simplement ne pas l'utiliser -et créer nous-mêmes les matériaux à la place.

    -

    Dans ce cas, nous devrions rechercher l'objet Mesh après -avoir chargé le fichier obj.

    -
     objLoader.load('resources/models/windmill/windmill.obj', (root) => {
    -   const materials = {
    -     Material: new THREE.MeshPhongMaterial({...}),
    -     windmill: new THREE.MeshPhongMaterial({...}),
    -   };
    -   root.traverse(node => {
    -     const material = materials[node.material?.name];
    -     if (material) {
    -       node.material = material;
    -     }
    -   })
    -   scene.add(root);
    - });
    -
  6. -
-

À vous de choisir laquelle vous préférez. 1 est la plus simple. 3 est la plus flexible. -2 est entre les deux. Pour l'instant, je vais choisir la 2.

-

Et avec cette modification, vous devriez toujours voir le tissu sur les pales -en regardant par derrière, mais il y a un autre problème. Si nous zoomons de près, -nous voyons que les choses deviennent pixellisées.

-
- -

Que se passe-t-il ?

-

En regardant les textures, il y a 2 textures étiquetées NOR pour carte NORmale. -Et en les regardant, elles ressemblent à des cartes normales. Les cartes normales sont généralement -violettes, tandis que les cartes de relief sont noires et blanches. Les cartes normales représentent -la direction de la surface, tandis que les cartes de relief représentent la hauteur de -la surface.

-
- -

En regardant le code source du MTLLoader, -il s'attend au mot-clé norm pour les cartes normales, alors éditons le fichier .MTL

-
# Blender MTL File: 'windmill_001.blend'
-# Material Count: 2
-
-newmtl Material
-Ns 0.000000
-Ka 1.000000 1.000000 1.000000
-Kd 0.800000 0.800000 0.800000
-Ks 0.000000 0.000000 0.000000
-Ke 0.000000 0.000000 0.000000
-Ni 1.000000
-d 1.000000
-illum 1
-map_Kd windmill_001_lopatky_COL.jpg
--map_Bump windmill_001_lopatky_NOR.jpg
-+norm windmill_001_lopatky_NOR.jpg
-
-newmtl windmill
-Ns 0.000000
-Ka 1.000000 1.000000 1.000000
-Kd 0.800000 0.800000 0.800000
-Ks 0.000000 0.000000 0.000000
-Ke 0.000000 0.000000 0.000000
-Ni 1.000000
-d 1.000000
-illum 1
-map_Kd windmill_001_base_COL.jpg
--map_Bump windmill_001_base_NOR.jpg
-+norm windmill_001_base_NOR.jpg
-map_Ns windmill_001_base_SPEC.jpg
-
-

et maintenant, lorsque nous le chargerons, il utilisera les cartes normales comme cartes normales -et nous pourrons voir l'arrière des pales.

-

- -

-

Chargeons un fichier différent.

-

En cherchant sur internet, j'ai trouvé ce modèle 3D de moulin à vent sous licence CC-BY-NC réalisé par Roger Gerzner / GERIZ.3D Art.

-
- -

Il avait déjà une version .OBJ disponible. Chargeons-le (notez que j'ai retiré le chargeur .MTL pour l'instant)

-
-  objLoader.load('resources/models/windmill/windmill.obj', ...
-+  objLoader.load('resources/models/windmill-2/windmill.obj', ...
-
-

- -

-

Hmmm, rien n'apparaît. Quel est le problème ? Quelle taille a le modèle ? -Nous pouvons demander à THREE.js quelle taille a le modèle et essayer de régler notre -caméra automatiquement.

-

Tout d'abord, nous pouvons demander à THREE.js de calculer une boîte qui contient la scène -que nous venons de charger et de demander sa taille et son centre

-
objLoader.load('resources/models/windmill_2/windmill.obj', (root) => {
-  scene.add(root);
-
-+  const box = new THREE.Box3().setFromObject(root);
-+  const boxSize = box.getSize(new THREE.Vector3()).length();
-+  const boxCenter = box.getCenter(new THREE.Vector3());
-+  console.log(boxSize);
-+  console.log(boxCenter);
-
-

En regardant dans la console JavaScript, je vois

-
size 2123.6499788469982
-center p {x: -0.00006103515625, y: 770.0909731090069, z: -3.313507080078125}
-
-

Notre caméra n'affiche actuellement qu'environ 100 unités, avec near à 0.1 et far à 100. -Notre plan de sol ne mesure que 40 unités de large, donc en gros, ce modèle de moulin à vent est si grand, 2000 unités, -qu'il entoure notre caméra et que toutes ses parties sont en dehors de notre frustum.

-
- -

Nous pourrions corriger cela manuellement, mais nous pourrions aussi faire en sorte que la caméra cadre automatiquement notre scène. -Essayons cela. Nous pouvons ensuite utiliser la boîte que nous venons de calculer pour ajuster les paramètres de la caméra afin de -visualiser toute la scène. Notez qu'il n'y a pas de bonne réponse -sur l'endroit où placer la caméra. Nous pourrions être face à la scène depuis n'importe quelle direction et à n'importe quelle -altitude, il faudra donc choisir quelque chose.

-

Comme nous l'avons vu dans l'article sur les caméras, la caméra définit un frustum. -Ce frustum est défini par le champ de vision (fov) et les paramètres near et far. Nous -voulons savoir, étant donné le champ de vision actuel de la caméra, à quelle distance la caméra -doit se trouver pour que la boîte contenant la scène s'inscrive dans le frustum, en supposant que le frustum -s'étende à l'infini. En d'autres termes, supposons que near est 0.00000001 et far est l'infini.

-

Puisque nous connaissons la taille de la boîte et que nous connaissons le champ de vision, nous avons ce triangle

-
- -

Vous pouvez voir à gauche la caméra et le frustum bleu qui se projette devant elle. -Nous venons de calculer la boîte qui contient le moulin à vent. Nous devons -calculer à quelle distance la caméra doit se trouver de la boîte pour que celle-ci -apparaisse à l'intérieur du frustum.

-

En utilisant la trigonométrie de base des triangles rectangles et SOHCAHTOA, -étant donné que nous connaissons le champ de vision pour le frustum et que nous connaissons la taille de la boîte, nous pouvons calculer la distance.

-
- -

Sur la base de ce diagramme, la formule pour calculer la distance est

-
distance = halfSizeToFitOnScreen / tangent(halfFovY)
-
-

Traduisons cela en code. D'abord, créons une fonction qui calculera distance, puis déplacera la -caméra de distance unités à partir du centre de la boîte. Nous dirigerons -ensuite la caméra vers le center de la boîte.

-
function frameArea(sizeToFitOnScreen, boxSize, boxCenter, camera) {
-  const halfSizeToFitOnScreen = sizeToFitOnScreen * 0.5;
-  const halfFovY = THREE.MathUtils.degToRad(camera.fov * .5);
-  const distance = halfSizeToFitOnScreen / Math.tan(halfFovY);
-
-  // calcule un vecteur unitaire qui pointe dans la direction où se trouve actuellement la caméra par rapport au centre de la boîte
-  const direction = (new THREE.Vector3()).subVectors(camera.position, boxCenter).normalize();
-
-  // déplace la caméra vers une position située à distance unités du centre,
-  // dans la même direction où se trouvait déjà la caméra par rapport au centre
-  camera.position.copy(direction.multiplyScalar(distance).add(boxCenter));
-
-  // choisit des valeurs near et far pour le frustum qui
-  // contiendront la boîte.
-  camera.near = boxSize / 100;
-  camera.far = boxSize * 100;
-
-  camera.updateProjectionMatrix();
-
-  // oriente la caméra pour qu'elle regarde le centre de la boîte
-  camera.lookAt(boxCenter.x, boxCenter.y, boxCenter.z);
-}
-
-

Nous passons 2 tailles. Le boxSize et le sizeToFitOnScreen. Si nous passions simplement boxSize -et l'utilisions comme sizeToFitOnScreen, alors le calcul ferait en sorte que la boîte s'insère parfaitement dans -le frustum. Nous voulons un peu d'espace supplémentaire au-dessus et en dessous, nous passerons donc une taille -légèrement plus grande.

-
{
-  const objLoader = new OBJLoader();
-  objLoader.load('resources/models/windmill_2/windmill.obj', (root) => {
-    scene.add(root);
-+    // calcule la boîte qui contient tout ce qui se trouve
-+    // à partir de la racine et en dessous
-+    const box = new THREE.Box3().setFromObject(root);
-+
-+    const boxSize = box.getSize(new THREE.Vector3()).length();
-+    const boxCenter = box.getCenter(new THREE.Vector3());
-+
-+    // positionne la caméra pour encadrer la boîte
-+    frameArea(boxSize * 1.2, boxSize, boxCenter, camera);
-+
-+    // met à jour les contrôles OrbitControls pour gérer la nouvelle taille
-+    controls.maxDistance = boxSize * 10;
-+    controls.target.copy(boxCenter);
-+    controls.update();
-  });
-}
-
-

Vous pouvez voir ci-dessus que nous passons boxSize * 1.2 pour nous donner 20% d'espace supplémentaire -au-dessus et en dessous de la boîte lorsque nous essayons de l'insérer dans le frustum. Nous avons également mis à jour les -OrbitControls afin que la caméra orbite autour du centre -de la scène.

-

Maintenant si nous essayons cela, nous obtenons...

-

- -

-

Cela fonctionne presque. Utilisez la souris pour faire pivoter la caméra et vous -devriez voir le moulin à vent. Le problème est que le moulin à vent est grand et que le centre de la boîte se trouve à environ (0, 770, 0). -Ainsi, lorsque nous déplaçons la caméra de sa position de départ (0, 10, 20) à distance unités du centre -dans la direction où se trouve la caméra par rapport au centre, cela déplace la caméra presque droit vers le bas, -sous le moulin à vent.

-
- -

Changeons cela pour nous déplacer latéralement à partir du centre de la boîte, dans la direction -où se trouve la caméra par rapport au centre. Tout ce que nous devons faire pour cela -est de mettre à zéro la composante y du vecteur allant de la boîte à la caméra. -Ensuite, lorsque nous normalisons ce vecteur, il deviendra un vecteur parallèle au plan XZ. -En d'autres termes, parallèle au sol.

-
-// calcule un vecteur unitaire qui pointe dans la direction où se trouve actuellement la caméra par rapport au centre de la boîte
--// from the center of the box
--const direction = (new THREE.Vector3()).subVectors(camera.position, boxCenter).normalize();
-+// calcule un vecteur unitaire qui pointe dans la direction où se trouve actuellement la caméra
-+// dans le plan xz par rapport au centre de la boîte
-+const direction = (new THREE.Vector3())
-+    .subVectors(camera.position, boxCenter)
-+    .multiply(new THREE.Vector3(1, 0, 1))
-+    .normalize();
-
-

Si vous regardez le bas du moulin à vent, vous verrez un petit carré. -C'est notre plan de sol.

-
- -

Il ne mesure que 40x40 unités et est donc beaucoup trop petit par rapport au moulin à vent. -Étant donné que le moulin à vent mesure plus de 2000 unités, changeons la taille du plan de sol -pour quelque chose de plus approprié. Nous devons également ajuster la répétition, sinon notre damier -sera si fin que nous ne pourrons même pas le voir à moins de zoomer très très près.

-
-const planeSize = 40;
-+const planeSize = 4000;
-
-const loader = new THREE.TextureLoader();
-const texture = loader.load('resources/images/checker.png');
-texture.wrapS = THREE.RepeatWrapping;
-texture.wrapT = THREE.RepeatWrapping;
-texture.magFilter = THREE.NearestFilter;
--const repeats = planeSize / 2;
-+const repeats = planeSize / 200;
-texture.repeat.set(repeats, repeats);
-
-

et maintenant nous pouvons voir ce moulin à vent

-

- -

-

Ajoutons à nouveau les matériaux. Comme précédemment, il y a un fichier .MTL qui référence -des textures, mais en regardant les fichiers, je constate rapidement un problème.

-
 $ ls -l windmill
- -rw-r--r--@ 1 gregg  staff       299 May 20  2009 windmill.mtl
- -rw-r--r--@ 1 gregg  staff    142989 May 20  2009 windmill.obj
- -rw-r--r--@ 1 gregg  staff  12582956 Apr 19  2009 windmill_diffuse.tga
- -rw-r--r--@ 1 gregg  staff  12582956 Apr 20  2009 windmill_normal.tga
- -rw-r--r--@ 1 gregg  staff  12582956 Apr 19  2009 windmill_spec.tga
-
-

Il y a des fichiers TARGA (.tga) et ils sont gigantesques !

-

THREE.js a en fait un chargeur TGA, mais il est discutable de l'utiliser pour la plupart des cas d'utilisation. -Si vous créez une visionneuse où vous voulez permettre aux utilisateurs de voir des fichiers 3D aléatoires qu'ils -trouvent sur internet, alors peut-être, juste peut-être, que vous voudrez charger des fichiers TGA. (*)

-

Un problème avec les fichiers TGA est qu'ils ne peuvent pas être bien compressés du tout. -TGA ne prend en charge qu'une compression très simple, et en regardant ci-dessus, nous pouvons voir que les fichiers ne sont pas du tout -compressés, car les chances qu'ils aient tous exactement la même taille sont extrêmement faibles. -De plus, ils font 12 mégaoctets chacun !!!

-

Si nous utilisions ces fichiers, l'utilisateur devrait télécharger 36 Mo -pour voir le moulin à vent.

-

Un autre problème avec TGA est que le navigateur lui-même ne les prend pas en charge, donc le chargement -sera probablement plus lent que le chargement de formats pris en charge comme .JPG et .PNG

-

Je suis presque sûr que pour nos besoins, les convertir en .JPG sera la meilleure option. -En regardant à l'intérieur, je vois qu'ils ont 3 canaux chacun, RGB, il n'y a pas de canal alpha. JPG -ne prend en charge que 3 canaux, c'est donc une bonne correspondance. JPG prend également en charge la compression -avec perte, ce qui nous permet de rendre les fichiers beaucoup plus petits à télécharger

-

En chargeant les fichiers, ils étaient chacun de 2048x2048. Cela m'a semblé un gâchis, -mais bien sûr, cela dépend de votre cas d'utilisation. Je les ai mis chacun en 1024x1024 -et les ai enregistrés avec un réglage de qualité de 50% dans Photoshop. Obtenir une liste de fichiers

-
 $ ls -l ../threejs.org/manual/examples/resources/models/windmill
- -rw-r--r--@ 1 gregg  staff     299 May 20  2009 windmill.mtl
- -rw-r--r--@ 1 gregg  staff  142989 May 20  2009 windmill.obj
- -rw-r--r--@ 1 gregg  staff  259927 Nov  7 18:37 windmill_diffuse.jpg
- -rw-r--r--@ 1 gregg  staff   98013 Nov  7 18:38 windmill_normal.jpg
- -rw-r--r--@ 1 gregg  staff  191864 Nov  7 18:39 windmill_spec.jpg
-
-

Nous sommes passés de 36 Mo à 0,55 Mo ! Bien sûr, l'artiste pourrait ne pas être satisfait -de cette compression, alors assurez-vous de le consulter pour discuter des compromis.

-

Maintenant, pour utiliser le fichier .MTL, nous devons l'éditer pour qu'il référence les fichiers .JPG -au lieu des fichiers .TGA. Heureusement, c'est un simple fichier texte, il est donc facile à éditer

-
newmtl blinn1SG
-Ka 0.10 0.10 0.10
-
-Kd 0.00 0.00 0.00
-Ks 0.00 0.00 0.00
-Ke 0.00 0.00 0.00
-Ns 0.060000
-Ni 1.500000
-d 1.000000
-Tr 0.000000
-Tf 1.000000 1.000000 1.000000
-illum 2
--map_Kd windmill_diffuse.tga
-+map_Kd windmill_diffuse.jpg
-
--map_Ks windmill_spec.tga
-+map_Ks windmill_spec.jpg
-
--map_bump windmill_normal.tga
--bump windmill_normal.tga
-+map_bump windmill_normal.jpg
-+bump windmill_normal.jpg
-
-

Maintenant que le fichier .MTL pointe vers des textures de taille raisonnable, nous devons le charger. -Nous allons donc faire comme nous l'avons fait ci-dessus : charger d'abord les matériaux, -puis les définir sur l'OBJLoader

-
{
-+  const mtlLoader = new MTLLoader();
-+  mtlLoader.load('resources/models/windmill_2/windmill-fixed.mtl', (mtl) => {
-+    mtl.preload();
-+    const objLoader = new OBJLoader();
-+    objLoader.setMaterials(mtl);
-    objLoader.load('resources/models/windmill/windmill.obj', (root) => {
-      root.updateMatrixWorld();
-      scene.add(root);
-      // calcule la boîte qui contient tout ce qui se trouve
-      // à partir de la racine et en dessous
-      const box = new THREE.Box3().setFromObject(root);
-
-      const boxSize = box.getSize(new THREE.Vector3()).length();
-      const boxCenter = box.getCenter(new THREE.Vector3());
-
-      // positionne la caméra pour encadrer la boîte
-      frameArea(boxSize * 1.2, boxSize, boxCenter, camera);
-
-      // met à jour les contrôles Trackball pour gérer la nouvelle taille
-      controls.maxDistance = boxSize * 10;
-      controls.target.copy(boxCenter);
-      controls.update();
-    });
-+  });
-}
-
-

Avant d'essayer, j'ai rencontré quelques problèmes que, plutôt que de montrer un échec, -je vais simplement passer en revue.

-

Problème n°1 : Le MTLLoader de three crée des matériaux qui multiplient la couleur diffuse du matériau par la carte de texture diffuse.

-

C'est une fonctionnalité utile, mais en regardant le fichier .MTL ci-dessus, la ligne

-
Kd 0.00 0.00 0.00
-
-

définit la couleur diffuse à 0. Carte de texture * 0 = noir ! Il est possible que l'outil de modélisation -utilisé pour créer le moulin à vent n'ait pas multiplié la carte de texture diffuse par la couleur diffuse. -C'est pourquoi cela a fonctionné pour les artistes qui ont créé ce moulin à vent.

-

Pour corriger cela, nous pouvons changer la ligne en

-
Kd 1.00 1.00 1.00
-
-

car Carte de Texture * 1 = Carte de Texture.

-

Problème n°2 : La couleur spéculaire est également noire

-

La ligne qui commence par Ks spécifie la couleur spéculaire. Il est probable que le logiciel de modélisation -utilisé pour créer le moulin à vent ait fait quelque chose de similaire à ce qu'il a fait avec les cartes diffuses, -c'est-à-dire qu'il a utilisé la couleur de la carte spéculaire pour les reflets spéculaires. -Three.js utilise uniquement le canal rouge d'une carte spéculaire comme entrée pour déterminer la quantité -de couleur spéculaire à refléter, mais three a toujours besoin d'une couleur spéculaire définie.

-

Comme ci-dessus, nous pouvons corriger cela en éditant le fichier .MTL comme ceci.

-
-Ks 0.00 0.00 0.00
-+Ks 1.00 1.00 1.00
-
-

Problème n°3 : Le fichier windmill_normal.jpg est une carte normale et non une carte de relief.

-

Tout comme ci-dessus, il suffit d'éditer le fichier .MTL

-
-map_bump windmill_normal.jpg
--bump windmill_normal.jpg
-+norm windmill_normal.jpg
-
-

Compte tenu de tout cela, si nous l'essayons maintenant, il devrait se charger avec les matériaux.

-

- -

-

Le chargement de modèles rencontre souvent ce genre de problèmes. Les problèmes courants incluent :

-
    -
  • Nécessité de connaître la taille

    -

    Ci-dessus, nous avons fait en sorte que la caméra essaie de cadrer la scène, mais ce n'est pas toujours la chose appropriée à faire. Généralement, la chose la plus appropriée à faire est de créer vos propres modèles ou de télécharger les modèles, de les charger dans un logiciel 3D et d'examiner leur échelle et de l'ajuster si nécessaire.

    -
  • -
  • Orientation incorrecte

    -

    THREE.js utilise généralement Y vers le haut. Certains logiciels de modélisation utilisent par défaut Z vers le haut, d'autres Y vers le haut. Certains sont configurables. -Si vous rencontrez ce cas où vous chargez un modèle et qu'il est sur le côté. Vous pouvez soit modifier votre code pour faire pivoter le modèle après le chargement (non recommandé), soit charger le modèle dans votre logiciel de modélisation préféré ou utiliser des outils en ligne de commande pour faire pivoter l'objet dans l'orientation dont vous avez besoin, tout comme vous éditeriez une image pour votre site web plutôt que de la télécharger et d'appliquer du code pour l'ajuster. Blender a même des options lors de l'exportation pour changer l'orientation.

    -
  • -
  • Pas de fichier .MTL ou matériaux incorrects ou paramètres incompatibles

    -

    Ci-dessus, nous avons utilisé un fichier .MTL qui nous a aidés à charger des matériaux, mais il y a eu des problèmes. Nous avons édité manuellement le fichier .MTL pour les corriger. -Il est également courant de regarder à l'intérieur du fichier .OBJ pour voir quels matériaux il contient, ou de charger le fichier .OBJ dans THREE.js et de parcourir la scène pour afficher tous les matériaux. Ensuite, modifiez le code pour créer des matériaux personnalisés et les attribuer là où cela est approprié, soit en créant un objet paire nom/matériau à passer au chargeur au lieu de charger le fichier .MTL, SOIT, après le chargement de la scène, en parcourant la scène et en corrigeant les choses.

    -
  • -
  • Textures trop grandes

    -

    La plupart des modèles 3D sont créés pour l'architecture, les films et publicités, ou les jeux. Pour l'architecture et les films, personne ne se soucie vraiment de la taille des textures puisque. Pour les jeux, les gens s'en soucient car les jeux ont une mémoire limitée, mais la plupart des jeux s'exécutent localement. Cependant, sur les pages web, vous voulez charger le plus rapidement possible, et vous devez donc regarder les textures et essayer de les rendre aussi petites que possible tout en conservant un bon rendu. En fait, pour le premier moulin à vent, nous aurions probablement dû faire quelque chose concernant les textures. Elles totalisent actuellement 10 Mo !!!

    -

    Rappelez-vous également, -comme nous l'avons mentionné dans l'article sur les textures, que -les textures prennent de la mémoire. Ainsi, un JPG de 50 Ko qui s'étend à 4096x4096 se téléchargera -rapidement mais prendra toujours une énorme quantité de mémoire.

    -
  • -
-

La dernière chose que je voulais montrer est de faire tourner les moulins à vent. Malheureusement, -les fichiers .OBJ n'ont pas de hiérarchie. Cela signifie que toutes les parties de chaque -moulin à vent sont fondamentalement considérées comme un seul maillage. Vous ne pouvez pas faire tourner les -pales du moulin car elles ne sont pas séparées du reste du bâtiment.

-

C'est l'une des principales raisons pour lesquelles le .OBJ n'est pas vraiment un bon format. -Si je devais deviner, la raison pour laquelle il est plus courant que d'autres formats -est qu'il est simple et, comme il ne prend pas en charge de nombreuses fonctionnalités, il fonctionne le plus souvent. -Surtout si vous créez quelque chose de statique comme une image architecturale -et qu'il n'y a pas besoin d'animer quoi que ce soit, ce n'est pas une mauvaise façon d'intégrer des -éléments statiques dans une scène.

-

Ensuite, nous allons essayer le chargement d'une scène gLTF. -Le format gLTF prend en charge beaucoup plus de fonctionnalités.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/loading-3d-models.html b/manual/fr/loading-3d-models.html deleted file mode 100644 index a62e64f272ac94..00000000000000 --- a/manual/fr/loading-3d-models.html +++ /dev/null @@ -1,160 +0,0 @@ - - - Chargement de modèles 3D - - - - - - - - - - - - - -
-
-

Chargement de modèles 3D

-
-
-
- -

- Les modèles 3D sont disponibles dans des centaines de formats de fichiers, chacun ayant des objectifs différents, des fonctionnalités variées et une complexité variable. Bien que - - three.js propose de nombreux chargeurs, choisir le bon format et le bon flux de travail vous fera gagner du temps et vous évitera des frustrations plus tard. Certains formats sont difficiles à manipuler, inefficaces pour les expériences en temps réel, ou simplement pas entièrement pris en charge à l'heure actuelle. -

- -

- Ce guide propose un flux de travail recommandé pour la plupart des utilisateurs, ainsi que des suggestions sur ce qu'il faut essayer si les choses ne se passent pas comme prévu. -

- -

Avant de commencer

- -

- Si vous débutez avec l'exécution d'un serveur local, commencez par - Installation - d'abord. De nombreuses erreurs courantes lors de la visualisation de modèles 3D peuvent être évitées en hébergeant correctement les fichiers. -

- -

Flux de travail recommandé

- -

- Dans la mesure du possible, nous recommandons l'utilisation de glTF (GL Transmission Format). Les versions - .GLB et .GLTF du format sont bien prises en charge. Étant donné que glTF est axé sur la diffusion d'assets en temps réel, il est compact à transmettre et rapide à charger. Les fonctionnalités incluent les maillages, les matériaux, les textures, les peaux, les squelettes, les cibles de déformation (morph targets), les animations, les lumières et les caméras. -

- -

- Des fichiers glTF du domaine public sont disponibles sur des sites comme - - Sketchfab, ou divers outils incluent l'exportation glTF : -

- - - -

- Si vos outils préférés ne prennent pas en charge glTF, envisagez de demander l'exportation glTF aux auteurs, ou de poster sur - le fil de discussion de la feuille de route glTF. -

- -

- Lorsque glTF n'est pas une option, des formats populaires tels que FBX, OBJ ou COLLADA sont également disponibles et régulièrement mis à jour. -

- -

Chargement

- -

- Seuls quelques chargeurs (par exemple `ObjectLoader`) sont inclus par défaut avec three.js — les autres doivent être ajoutés individuellement à votre application. -

- -
-import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
-
- -

- Une fois qu'un chargeur a été importé, vous êtes prêt à ajouter un modèle à votre scène. La syntaxe varie selon les différents chargeurs — lorsque vous utilisez un autre format, consultez les exemples et la documentation de ce chargeur. Pour glTF, l'utilisation avec des scripts globaux serait : -

- -
-const loader = new GLTFLoader();
-
-loader.load( 'path/to/model.glb', function ( gltf ) {
-
-  scene.add( gltf.scene );
-
-}, undefined, function ( error ) {
-
-  console.error( error );
-
-} );
-
- -

Dépannage

- -

- Vous avez passé des heures à modéliser un chef-d'œuvre artisanal, vous le chargez dans la page web, et — oh non ! 😭 Il est déformé, mal coloré, ou manque entièrement. Commencez par ces étapes de dépannage : -

- -
    -
  1. - Vérifiez la console JavaScript pour les erreurs, et assurez-vous d'avoir utilisé une fonction de rappel `onError` lors de l'appel à `.load()` pour journaliser le résultat. -
  2. -
  3. - Visualisez le modèle dans une autre application. Pour glTF, des visionneuses par glisser-déposer sont disponibles pour - three.js et - babylon.js. Si le modèle - apparaît correctement dans une ou plusieurs applications, - signalez un bug à three.js. - Si le modèle ne peut être affiché dans aucune application, nous vous encourageons fortement à signaler un bug à l'application utilisée pour créer le modèle. -
  4. -
  5. - Essayez de mettre à l'échelle le modèle vers le haut ou vers le bas par un facteur de 1000. De nombreux modèles sont mis à l'échelle différemment, et les grands modèles peuvent ne pas apparaître si la caméra est à l'intérieur du modèle. -
  6. -
  7. - Essayez d'ajouter et de positionner une source de lumière. Le modèle peut être caché dans l'obscurité. -
  8. -
  9. - Recherchez les demandes de texture échouées dans l'onglet réseau, comme - `"C:\\Path\To\Model\texture.jpg"`. Utilisez plutôt des chemins relatifs à votre - modèle, tels que `images/texture.jpg` — cela peut nécessiter d'éditer le fichier modèle dans un éditeur de texte. -
  10. -
- -

Demander de l'aide

- -

- Si vous avez suivi le processus de dépannage ci-dessus et que votre modèle ne fonctionne toujours pas, la bonne approche pour demander de l'aide vous permettra d'obtenir une solution plus rapidement. Posez une question sur le - forum three.js et, dans la mesure du possible, - incluez votre modèle (ou un modèle plus simple présentant le même problème) dans tous les formats dont vous disposez. Incluez suffisamment d'informations pour que quelqu'un d'autre puisse reproduire le problème rapidement — idéalement, une démo en direct. -

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/material-table.html b/manual/fr/material-table.html deleted file mode 100644 index 9287f8ca14a743..00000000000000 --- a/manual/fr/material-table.html +++ /dev/null @@ -1,47 +0,0 @@ - - - Tableau des fonctionnalités des matériaux - - - - - - - - - - - - - -
-
-

Tableau des fonctionnalités des matériaux

-
-
-
-

Les matériaux les plus courants dans three.js sont les matériaux Mesh. Voici un tableau montrant quels matériaux prennent en charge quelles fonctionnalités.

-
-
- - -
- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/materials.html b/manual/fr/materials.html deleted file mode 100644 index 770216a9b6258a..00000000000000 --- a/manual/fr/materials.html +++ /dev/null @@ -1,314 +0,0 @@ - - - Matériaux - - - - - - - - - - - - - -
-
-

Matériaux

-
-
-
-

Cet article fait partie d'une série d'articles sur three.js. Le -premier article est les bases de three.js. Si -vous ne l'avez pas encore lu et que vous débutez avec three.js, vous pourriez -vouloir commencer par là.

-

Three.js propose plusieurs types de matériaux. -Ils définissent comment les objets apparaîtront dans la scène. -Les matériaux que vous utilisez dépendent vraiment de ce que vous essayez -d'accomplir.

-

Il existe 2 manières de définir la plupart des propriétés des matériaux. L'une -au moment de la création, ce que nous avons déjà vu.

-
const material = new THREE.MeshPhongMaterial({
-  color: 0xFF0000,    // rouge (peut aussi utiliser une chaîne de couleur CSS ici)
-  flatShading: true,
-});
-
-

L'autre est après la création

-
const material = new THREE.MeshPhongMaterial();
-material.color.setHSL(0, 1, .5);  // rouge
-material.flatShading = true;
-
-

notez que les propriétés de type THREE.Color peuvent être définies de plusieurs manières.

-
material.color.set(0x00FFFF);    // comme le style #RRGGBB de CSS
-material.color.set(cssString);   // n'importe quelle couleur CSS, par exemple 'purple', '#F32',
-                                 // 'rgb(255, 127, 64)',
-                                 // 'hsl(180, 50%, 25%)'
-material.color.set(someColor)    // une autre THREE.Color
-material.color.setHSL(h, s, l)   // où h, s et l sont de 0 à 1
-material.color.setRGB(r, g, b)   // où r, g et b sont de 0 à 1
-
-

Et au moment de la création, vous pouvez passer soit un nombre hexadécimal, soit une chaîne CSS

-
const m1 = new THREE.MeshBasicMaterial({color: 0xFF0000});         // rouge
-const m2 = new THREE.MeshBasicMaterial({color: 'red'});            // rouge
-const m3 = new THREE.MeshBasicMaterial({color: '#F00'});           // rouge
-const m4 = new THREE.MeshBasicMaterial({color: 'rgb(255,0,0)'});   // rouge
-const m5 = new THREE.MeshBasicMaterial({color: 'hsl(0,100%,50%)'}); // rouge
-
-

Passons donc en revue l'ensemble des matériaux de three.js.

-

Le MeshBasicMaterial n'est pas affecté par les lumières.

-

Le MeshLambertMaterial calcule l'éclairage uniquement aux sommets, contrairement au MeshPhongMaterial qui calcule l'éclairage à chaque pixel. Le MeshPhongMaterial -prend également en charge les reflets spéculaires.

-
-
-
-
Basique
-
-
-
-
Lambert
-
-
-
-
Phong
-
-
-
-
-
-
-
-
-
-
-
-
-
-
modèles low-poly avec les mêmes matériaux
- -

Le paramètre shininess du MeshPhongMaterial détermine la brillance du reflet spéculaire. Par défaut, il est de 30.

-
-
-
-
shininess: 0
-
-
-
-
shininess: 30
-
-
-
-
shininess: 150
-
-
- -

Notez que définir la propriété emissive sur une couleur pour un -MeshLambertMaterial ou un MeshPhongMaterial et définir la color en noir -(et shininess à 0 pour phong) finit par ressembler exactement au MeshBasicMaterial.

-
-
-
-
-
Basique
-
color: 'purple'
-
-
-
-
-
-
Lambert
-
color: 'black'
-
emissive: 'purple'
-
-
-
-
-
-
Phong
-
color: 'black'
-
emissive: 'purple'
-
shininess: 0
-
-
-
- -

Pourquoi avoir les 3 alors que MeshPhongMaterial peut faire les mêmes choses que MeshBasicMaterial -et MeshLambertMaterial ? La raison est que le matériau le plus sophistiqué -nécessite plus de puissance GPU pour être dessiné. Sur un GPU plus lent, -comme celui d'un téléphone portable, vous pourriez vouloir réduire la -puissance GPU nécessaire pour dessiner votre scène en utilisant l'un des -matériaux moins complexes. Il s'ensuit également que si vous n'avez pas -besoin des fonctionnalités supplémentaires, utilisez le matériau le plus simple. -Si vous n'avez pas besoin de l'éclairage et des reflets spéculaires, -utilisez le MeshBasicMaterial.

-

Le MeshToonMaterial est similaire au MeshPhongMaterial -avec une grande différence. Au lieu d'ombrer en douceur, il utilise une -carte de dégradé (une texture de taille X sur 1) pour décider comment ombrer. -Par défaut, il utilise une carte de dégradé dont la luminosité est de 70% -pour les premiers 70% et de 100% ensuite, mais vous pouvez fournir votre -propre carte de dégradé. Cela donne finalement un aspect bicolore qui -ressemble à un dessin animé.

-
-
-
- -

Ensuite, il y a 2 matériaux basés sur le rendu physique. Le Rendu Basé sur -le Physique est souvent abrégé PBR.

-

Les matériaux ci-dessus utilisent des calculs simples pour créer des matériaux -qui semblent en 3D, mais ils ne correspondent pas à ce qui se passe réellement -dans le monde réel. Les 2 matériaux PBR utilisent des calculs beaucoup plus -complexes pour se rapprocher de ce qui se passe réellement dans le monde réel.

-

Le premier est MeshStandardMaterial. La plus grande différence entre -MeshPhongMaterial et MeshStandardMaterial est qu'il utilise différents paramètres. -MeshPhongMaterial avait un paramètre shininess. MeshStandardMaterial a 2 -paramètres : roughness et metalness.

-

À un niveau basique, roughness est l'opposé -de shininess. Quelque chose qui a une rugosité élevée, comme une balle de baseball, n'a pas -de reflets durs, tandis que quelque chose qui n'est pas rugueux, comme une boule de billard, -est très brillant. La rugosité va de 0 à 1.

-

L'autre paramètre, metalness, indique -à quel point le matériau est métallique. Les métaux se comportent différemment des non-métaux. 0 -pour non-métal et 1 pour métal.

-

Voici un échantillon rapide de MeshStandardMaterial avec une roughness de 0 à 1 -horizontalement et une metalness de 0 à 1 verticalement.

-
- -

Le MeshPhysicalMaterial est le même que le MeshStandardMaterial mais il -ajoute un paramètre clearcoat qui va de 0 à 1 pour déterminer la -quantité de couche de vernis brillant à appliquer, et un paramètre -clearCoatRoughness qui spécifie la rugosité de la couche brillante.

-

Voici la même grille de roughness par metalness que ci-dessus, mais avec -les paramètres clearcoat et clearCoatRoughness.

-
- -

Les différents matériaux standard progressent du plus rapide au plus lent : -MeshBasicMaterialMeshLambertMaterialMeshPhongMaterial ➡ -MeshStandardMaterialMeshPhysicalMaterial. Les matériaux plus lents -peuvent créer des scènes plus réalistes, mais vous pourriez avoir besoin de -concevoir votre code pour utiliser les matériaux plus rapides sur les machines -à faible puissance ou les appareils mobiles.

-

Il existe 3 matériaux qui ont des utilisations spéciales. ShadowMaterial -est utilisé pour obtenir les données créées par les ombres. Nous n'avons pas -encore abordé les ombres. Lorsque ce sera le cas, nous utiliserons ce matériau -pour jeter un coup d'œil à ce qui se passe en coulisse.

-

Le MeshDepthMaterial affiche la profondeur de chaque pixel où -les pixels au near négatif de la caméra sont 0 et au far négatif sont 1. -Certains effets spéciaux peuvent utiliser ces données, que nous aborderons -ultérieurement.

-
-
-
-
-
- -

Le MeshNormalMaterial vous montrera les normales de la géométrie. -Les normales sont la direction vers laquelle pointe un triangle ou un pixel particulier. -MeshNormalMaterial dessine les normales de l'espace de vue (les normales relatives à la caméra). -x est rouge, -y est vert, et -z est bleu donc les choses orientées -vers la droite seront roses, -vers la gauche seront aqua, -vers le haut seront vert clair, -vers le bas seront violettes, -et vers l'écran seront lavande.

-
-
-
-
-
- -

ShaderMaterial sert à créer des matériaux personnalisés à l'aide du système de shaders de three.js. -RawShaderMaterial sert à créer des shaders entièrement personnalisés sans aide de three.js. -Ces deux sujets sont vastes et seront abordés plus tard.

-

La plupart des matériaux partagent un ensemble de paramètres tous définis par Material. -Consultez la documentation -pour les voir tous, mais passons en revue deux des propriétés les plus couramment utilisées.

-

flatShading : -si l'objet semble facetté ou lisse. défaut = false.

-
-
-
-
flatShading: false
-
-
-
-
flatShading: true
-
-
- -

side : quels côtés des triangles afficher. La valeur par défaut est THREE.FrontSide. -Les autres options sont THREE.BackSide et THREE.DoubleSide (les deux côtés). -La plupart des objets 3D dessinés dans three.js sont probablement des solides opaques, donc les faces arrière -(les faces tournées vers l'intérieur du solide) n'ont pas besoin d'être dessinées. La raison la plus courante -de définir side est pour les plans ou d'autres objets non solides où il est -courant de voir les faces arrière des triangles.

-

Voici 6 plans dessinés avec THREE.FrontSide et THREE.DoubleSide.

-
-
-
-
side: THREE.FrontSide
-
-
-
-
side: THREE.DoubleSide
-
-
- -

Il y a vraiment beaucoup de choses à considérer avec les matériaux et nous en avons encore beaucoup -à voir. En particulier, nous avons largement ignoré les textures qui ouvrent tout un éventail -d'options. Cependant, avant d'aborder les textures, nous devons faire une pause et -configurer votre environnement de développement.

-
-

material.needsUpdate

-

-Ce sujet affecte rarement la plupart des applications three.js, mais juste pour information... -Three.js applique les paramètres des matériaux lorsqu'un matériau est utilisé, où "utilisé" -signifie "quelque chose qui est rendu utilise le matériau". Certains paramètres de matériau ne sont -appliqués qu'une seule fois, car leur modification nécessite beaucoup de travail de la part de three.js. -Dans ces cas, vous devez définir material.needsUpdate = true pour indiquer à -three.js d'appliquer vos modifications de matériau. Les paramètres les plus courants -qui nécessitent de définir needsUpdate si vous modifiez les paramètres après avoir -utilisé le matériau sont : -

-
    -
  • flatShading
  • -
  • ajout ou suppression d'une texture -

    - Changer une texture est acceptable, mais si vous souhaitez passer de l'absence de texture - à l'utilisation d'une texture, ou de l'utilisation d'une texture à l'absence de texture, - alors vous devez définir needsUpdate = true. -

    -

    Dans le cas où l'on passe d'une texture à l'absence de texture, il est souvent - simplement préférable d'utiliser une texture blanche de 1x1 pixel.

    -
  • -
-

Comme mentionné ci-dessus, la plupart des applications ne rencontrent jamais ces problèmes. La plupart des applications -ne basculent pas entre un ombrage plat et non plat. La plupart des applications -utilisent également des textures ou une couleur unie pour un matériau donné ; elles basculent rarement -de l'utilisation de l'un à l'utilisation de l'autre. -

-
- -

- - - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/matrix-transformations.html b/manual/fr/matrix-transformations.html deleted file mode 100644 index dd964fc69a000f..00000000000000 --- a/manual/fr/matrix-transformations.html +++ /dev/null @@ -1,96 +0,0 @@ - - - Transformations matricielles - - - - - - - - - - - - - -
-
-

Transformations matricielles

-
-
-
- -

- Three.js utilise des `matrices` pour encoder les transformations 3D : translations (position), rotations et mises à l'échelle. Chaque instance d'`Object3D` possède une `matrix` qui stocke la position, la rotation et l'échelle de cet objet. Cette page décrit comment mettre à jour la transformation d'un objet. -

- -

Propriétés de commodité et `matrixAutoUpdate`

- -

- Il existe deux manières de mettre à jour la transformation d'un objet : -

-
    -
  1. - Modifiez les propriétés `position`, `quaternion` et `scale` de l'objet, et laissez three.js recalculer la matrice de l'objet à partir de ces propriétés : -
    -object.position.copy( start_position );
    -object.quaternion.copy( quaternion );
    -
    - Par défaut, la propriété `matrixAutoUpdate` est définie sur true, et la matrice sera automatiquement recalculée. - Si l'objet est statique, ou si vous souhaitez contrôler manuellement le moment où le recalcul se produit, de meilleures performances peuvent être obtenues en définissant la propriété sur false : -
    -object.matrixAutoUpdate = false;
    -
    - Et après avoir modifié une propriété, mettez à jour la matrice manuellement : -
    -object.updateMatrix();
    -
    -
  2. -
  3. - Modifiez la matrice de l'objet directement. La classe `Matrix4` dispose de différentes méthodes pour modifier la matrice : -
    -object.matrix.makeRotationFromQuaternion( quaternion );
    -object.matrix.setPosition( start_position );
    -object.matrixAutoUpdate = false;
    -
    - Notez que `matrixAutoUpdate` doit être défini sur `false` dans ce cas, et vous devez vous assurer de ne pas appeler `updateMatrix`. Appeler `updateMatrix` écrasera les modifications manuelles apportées à la matrice, en recalculant la matrice à partir de `position`, `scale`, etc. -
  4. -
- -

Matrices de l'objet et du monde

-

- La matrice d'un objet stocke la transformation de l'objet par rapport à son parent ; pour obtenir la transformation de l'objet en coordonnées du monde, vous devez accéder à la matrice du monde de l'objet. -

-

- Lorsque la transformation du parent ou de l'enfant change, vous pouvez demander la mise à jour de la matrice du monde de l'objet enfant en appelant `object.updateMatrixWorld()`. -

-

- Un objet peut être transformé via `applyMatrix4()`. Note : En coulisses, cette méthode repose sur `Matrix4.decompose()`, et toutes les matrices ne sont pas décomposables de cette manière. Par exemple, si un objet a un parent avec une mise à l'échelle non uniforme, la matrice du monde de l'objet peut ne pas être décomposable, et cette méthode pourrait ne pas être appropriée. -

- -

Rotation et Quaternion

-

- Three.js propose deux manières de représenter les rotations 3D : les angles d'Euler et les Quaternions, ainsi que des méthodes pour convertir entre les deux. Les angles d'Euler sont sujets à un problème appelé « verrouillage de cardan » (gimbal lock), où certaines configurations peuvent perdre un degré de liberté (empêchant l'objet de tourner autour d'un axe). Pour cette raison, les rotations d'objets sont toujours stockées dans le quaternion de l'objet. -

-

- Les versions précédentes de la librairie incluaient une propriété `useQuaternion` qui, lorsqu'elle était définie sur false, entraînait le calcul de la matrice de l'objet à partir d'un angle d'Euler. Cette pratique est obsolète --- à la place, vous devriez utiliser la méthode `object.setRotationFromEuler()`, qui mettra à jour le quaternion. -

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/multiple-scenes.html b/manual/fr/multiple-scenes.html deleted file mode 100644 index 52f0284fde5a46..00000000000000 --- a/manual/fr/multiple-scenes.html +++ /dev/null @@ -1,652 +0,0 @@ - - - Plusieurs Canvases, Plusieurs Scènes - - - - - - - - - - - - - -
-
-

Plusieurs Canvases, Plusieurs Scènes

-
-
-
-

Une question courante est de savoir comment utiliser THREE.js avec plusieurs canvases. -Disons que vous voulez faire un site de commerce électronique ou que vous voulez créer -une page avec beaucoup de diagrammes 3D. À première vue, cela semble facile. -Faites simplement un canvas partout où vous voulez un diagramme. Pour chaque canvas, -créez un Renderer.

-

Vous découvrirez rapidement, cependant, que vous rencontrez des problèmes.

-
    -
  1. Le navigateur limite le nombre de contextes WebGL que vous pouvez avoir.

    -

    Typiquement, cette limite est d'environ 8. Dès que vous créez -le 9ème contexte, le plus ancien sera perdu.

    -
  2. -
  3. Les ressources WebGL ne peuvent pas être partagées entre les contextes

    -

    Cela signifie que si vous voulez charger un modèle de 10 Mo dans 2 canvases -et que ce modèle utilise 20 Mo de textures, votre modèle de 10 Mo devra -être chargé deux fois et vos textures seront également chargées -deux fois. Rien ne peut être partagé entre les contextes. Cela -signifie également que les choses doivent être initialisées deux fois, les shaders compilés deux fois, -etc. Cela empire à mesure qu'il y a plus de canvases.

    -
  4. -
-

Alors, quelle est la solution ?

-

La solution est un canvas qui remplit la zone d'affichage en arrière-plan et un autre élément pour représenter chaque canvas "virtuel". Nous créons un seul Renderer, puis une Scene pour chaque canvas virtuel. Nous vérifierons ensuite les positions des éléments de canvas virtuels et s'ils sont à l'écran, nous demanderons à THREE.js de dessiner leur scène à l'endroit correct.

-

Avec cette solution, il n'y a qu'un seul canvas, nous résolvons donc les problèmes 1 -et 2 ci-dessus. Nous ne rencontrerons pas la limite de contextes WebGL car nous -n'utiliserons qu'un seul contexte. Nous ne rencontrerons pas non plus les problèmes -de partage pour les mêmes raisons.

-

Commençons par un exemple simple avec seulement 2 scènes. D'abord, nous allons -créer le HTML

-
<canvas id="c"></canvas>
-<p>
-  <span id="box" class="diagram left"></span>
-  J'aime les boîtes. Les cadeaux viennent dans des boîtes.
-  Quand je trouve une nouvelle boîte, je suis toujours impatient de découvrir ce qu'il y a dedans.
-</p>
-<p>
-  <span id="pyramid" class="diagram right"></span>
-  Quand j'étais enfant, je rêvais de partir en expédition à l'intérieur d'une pyramide
-  et de trouver un tombeau inconnu rempli de momies et de trésors.
-</p>
-
-

Ensuite, nous pouvons configurer le CSS peut-être comme ceci

-
#c {
-  position: fixed;
-  left: 0;
-  top: 0;
-  width: 100%;
-  height: 100%;
-  display: block;
-  z-index: -1;
-}
-.diagram {
-  display: inline-block;
-  width: 5em;
-  height: 3em;
-  border: 1px solid black;
-}
-.left {
-  float: left;
-  margin-right: .25em;
-}
-.right {
-  float: right;
-  margin-left: .25em;
-}
-
-

Nous configurons le canvas pour qu'il remplisse l'écran et nous définissons son z-index à --1 pour qu'il apparaisse derrière les autres éléments. Nous devons également spécifier une sorte de largeur et de hauteur -pour nos éléments de canvas virtuels puisqu'il n'y a rien à l'intérieur -pour leur donner une taille.

-

Maintenant, nous allons créer 2 scènes, chacune avec une lumière et une caméra. -À une scène, nous ajouterons un cube et à l'autre une sphère.

-
function makeScene(elem) {
-  const scene = new THREE.Scene();
-
-  const fov = 45;
-  const aspect = 2;  // the canvas default
-  const near = 0.1;
-  const far = 5;
-  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-  camera.position.z = 2;
-  camera.position.set(0, 1, 2);
-  camera.lookAt(0, 0, 0);
-
-  {
-    const color = 0xFFFFFF;
-    const intensity = 1;
-    const light = new THREE.DirectionalLight(color, intensity);
-    light.position.set(-1, 2, 4);
-    scene.add(light);
-  }
-
-  return {scene, camera, elem};
-}
-
-function setupScene1() {
-  const sceneInfo = makeScene(document.querySelector('#box'));
-  const geometry = new THREE.BoxGeometry(1, 1, 1);
-  const material = new THREE.MeshPhongMaterial({color: 'red'});
-  const mesh = new THREE.Mesh(geometry, material);
-  sceneInfo.scene.add(mesh);
-  sceneInfo.mesh = mesh;
-  return sceneInfo;
-}
-
-function setupScene2() {
-  const sceneInfo = makeScene(document.querySelector('#pyramid'));
-  const radius = .8;
-  const widthSegments = 4;
-  const heightSegments = 2;
-  const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
-  const material = new THREE.MeshPhongMaterial({
-    color: 'blue',
-    flatShading: true,
-  });
-  const mesh = new THREE.Mesh(geometry, material);
-  sceneInfo.scene.add(mesh);
-  sceneInfo.mesh = mesh;
-  return sceneInfo;
-}
-
-const sceneInfo1 = setupScene1();
-const sceneInfo2 = setupScene2();
-
-

Et ensuite, nous allons créer une fonction pour rendre chaque scène -uniquement si l'élément est à l'écran. Nous pouvons indiquer à THREE.js -de ne rendre qu'une partie du canvas en activant le test scissor -avec Renderer.setScissorTest, puis en définissant à la fois le scissor et le viewport avec Renderer.setViewport et Renderer.setScissor.

-
function renderSceneInfo(sceneInfo) {
-  const {scene, camera, elem} = sceneInfo;
-
-  // obtenir la position relative à la zone d'affichage de cet élément
-  const {left, right, top, bottom, width, height} =
-      elem.getBoundingClientRect();
-
-  const isOffscreen =
-      bottom < 0 ||
-      top > renderer.domElement.clientHeight ||
-      right < 0 ||
-      left > renderer.domElement.clientWidth;
-
-  if (isOffscreen) {
-    return;
-  }
-
-  camera.aspect = width / height;
-  camera.updateProjectionMatrix();
-
-  const positiveYUpBottom = canvasRect.height - bottom;
-  renderer.setScissor(left, positiveYUpBottom, width, height);
-  renderer.setViewport(left, positiveYUpBottom, width, height);
-
-  renderer.render(scene, camera);
-}
-
-

Et ensuite, notre fonction de rendu commencera par effacer l'écran, -puis rendra chaque scène.

-
function render(time) {
-  time *= 0.001;
-
-  resizeRendererToDisplaySize(renderer);
-
-  renderer.setScissorTest(false);
-  renderer.clear(true, true);
-  renderer.setScissorTest(true);
-
-  sceneInfo1.mesh.rotation.y = time * .1;
-  sceneInfo2.mesh.rotation.y = time * .1;
-
-  renderSceneInfo(sceneInfo1);
-  renderSceneInfo(sceneInfo2);
-
-  requestAnimationFrame(render);
-}
-
-

Et voici le résultat

-

- -

-

Vous pouvez voir où se trouve le premier <span>, il y a un cube rouge, et où se trouve le deuxième span, il y a une sphère bleue.

-

Synchronisation

-

Le code ci-dessus fonctionne, mais il y a un petit problème. -Si vos scènes sont compliquées ou si, pour une raison quelconque, -le rendu prend trop de temps, la position des scènes -dessinées dans le canvas sera en décalage par rapport au reste de la page.

-

Si nous donnons une bordure à chaque zone

-
.diagram {
-  display: inline-block;
-  width: 5em;
-  height: 3em;
-+  border: 1px solid black;
-}
-
-

Et nous définissons le fond de chaque scène

-
const scene = new THREE.Scene();
-+scene.background = new THREE.Color('red');
-
-

Et si nous faisons défiler rapidement de haut en bas, nous verrons le problème. Voici une animation du défilement ralenti par 10.

-
- -

Nous pouvons passer à une méthode différente qui présente un compromis différent. Nous allons changer le CSS du canvas de position: fixed à position: absolute.

-
#c {
--  position: fixed;
-+  position: absolute;
-
-

Ensuite, nous définirons la transformation du canvas pour le déplacer afin -que le haut du canvas soit au niveau du haut de la partie -de la page actuellement défilée.

-
function render(time) {
-  ...
-
-  const transform = `translateY(${window.scrollY}px)`;
-  renderer.domElement.style.transform = transform;
-
-

position: fixed empêchait le canvas de défiler du tout -tandis que le reste de la page défilait par-dessus. position: absolute -permettra au canvas de défiler avec le reste de la page, ce qui signifie -que ce que nous dessinons restera avec la page pendant le défilement, -même si nous sommes trop lents à rendre. Lorsque nous aurons enfin l'occasion de rendre, -nous déplacerons le canvas pour qu'il corresponde à l'endroit où la page -a été défilée, puis nous referons le rendu. Cela signifie que seuls les bords -de la fenêtre montreront des morceaux non rendus pendant un instant, mais le contenu -au milieu de la page devrait correspondre et ne pas glisser. Voici une vue -des résultats de la nouvelle méthode ralentie par 10.

-
- -

Généraliser le code

-

Maintenant que nous avons fait fonctionner plusieurs scènes, rendons cela un peu plus générique.

-

Nous pourrions faire en sorte que la fonction de rendu principale, celle qui gère le canvas, contienne simplement une liste d'éléments et leur fonction de rendu associée. Pour chaque élément, elle vérifierait si l'élément est à l'écran et, si oui, appellerait la fonction de rendu correspondante. De cette manière, nous aurions un système générique où les scènes individuelles ne sont pas vraiment conscientes d'être rendues dans un espace plus petit.

-

Voici la fonction de rendu principale

-
const sceneElements = [];
-function addScene(elem, fn) {
-  sceneElements.push({elem, fn});
-}
-
-function render(time) {
-  time *= 0.001;
-
-  resizeRendererToDisplaySize(renderer);
-
-  renderer.setScissorTest(false);
-  renderer.setClearColor(clearColor, 0);
-  renderer.clear(true, true);
-  renderer.setScissorTest(true);
-
-  const transform = `translateY(${window.scrollY}px)`;
-  renderer.domElement.style.transform = transform;
-
-  for (const {elem, fn} of sceneElements) {
-    // obtenir la position relative à la zone d'affichage de cet élément
-    const rect = elem.getBoundingClientRect();
-    const {left, right, top, bottom, width, height} = rect;
-
-    const isOffscreen =
-        bottom < 0 ||
-        top > renderer.domElement.clientHeight ||
-        right < 0 ||
-        left > renderer.domElement.clientWidth;
-
-    if (!isOffscreen) {
-      const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
-      renderer.setScissor(left, positiveYUpBottom, width, height);
-      renderer.setViewport(left, positiveYUpBottom, width, height);
-
-      fn(time, rect);
-    }
-  }
-
-  requestAnimationFrame(render);
-}
-
-

Vous pouvez voir qu'elle boucle sur sceneElements, qui est censé être un tableau d'objets, chacun ayant une propriété elem et fn.

-

Elle vérifie si l'élément est à l'écran. Si c'est le cas, elle appelle fn et lui passe l'heure actuelle et son rectangle.

-

Maintenant, le code de configuration pour chaque scène s'ajuste simplement pour s'ajouter à la liste des scènes

-
{
-  const elem = document.querySelector('#box');
-  const {scene, camera} = makeScene();
-  const geometry = new THREE.BoxGeometry(1, 1, 1);
-  const material = new THREE.MeshPhongMaterial({color: 'red'});
-  const mesh = new THREE.Mesh(geometry, material);
-  scene.add(mesh);
-  addScene(elem, (time, rect) => {
-    camera.aspect = rect.width / rect.height;
-    camera.updateProjectionMatrix();
-    mesh.rotation.y = time * .1;
-    renderer.render(scene, camera);
-  });
-}
-
-{
-  const elem = document.querySelector('#pyramid');
-  const {scene, camera} = makeScene();
-  const radius = .8;
-  const widthSegments = 4;
-  const heightSegments = 2;
-  const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
-  const material = new THREE.MeshPhongMaterial({
-    color: 'blue',
-    flatShading: true,
-  });
-  const mesh = new THREE.Mesh(geometry, material);
-  scene.add(mesh);
-  addScene(elem, (time, rect) => {
-    camera.aspect = rect.width / rect.height;
-    camera.updateProjectionMatrix();
-    mesh.rotation.y = time * .1;
-    renderer.render(scene, camera);
-  });
-}
-
-

Avec cela, nous n'avons plus besoin de sceneInfo1 et sceneInfo2, et le code qui faisait pivoter les maillages est maintenant spécifique à chaque scène.

-

- -

-

Utilisation de l'attribut dataset HTML

-

Une dernière chose encore plus générique que nous pouvons faire est d'utiliser l'attribut dataset HTML. C'est une façon d'ajouter vos propres données à un élément HTML. Au lieu d'utiliser id="...", nous utiliserons data-diagram="..." comme ceci

-
<canvas id="c"></canvas>
-<p>
--  <span id="box" class="diagram left"></span>
-+  <span data-diagram="box" class="left"></span>
-  J'aime les boîtes. Les cadeaux viennent dans des boîtes.
-  Quand je trouve une nouvelle boîte, je suis toujours impatient de découvrir ce qu'il y a dedans.
-</p>
-<p>
--  <span id="pyramid" class="diagram left"></span>
-+  <span data-diagram="pyramid" class="right"></span>
-  Quand j'étais enfant, je rêvais de partir en expédition à l'intérieur d'une pyramide
-  et de trouver un tombeau inconnu rempli de momies et de trésors.
-</p>
-
-

Nous pouvons ensuite modifier le sélecteur CSS pour sélectionner cela

-
-.diagram
-+*[data-diagram] {
-  display: inline-block;
-  width: 5em;
-  height: 3em;
-}
-
-

Nous allons modifier le code de configuration de la scène pour qu'il soit simplement une correspondance de noms avec des fonctions d'initialisation de scène qui renvoient une fonction de rendu de scène.

-
const sceneInitFunctionsByName = {
-  'box': () => {
-    const {scene, camera} = makeScene();
-    const geometry = new THREE.BoxGeometry(1, 1, 1);
-    const material = new THREE.MeshPhongMaterial({color: 'red'});
-    const mesh = new THREE.Mesh(geometry, material);
-    scene.add(mesh);
-    return (time, rect) => {
-      mesh.rotation.y = time * .1;
-      camera.aspect = rect.width / rect.height;
-      camera.updateProjectionMatrix();
-      renderer.render(scene, camera);
-    };
-  },
-  'pyramid': () => {
-    const {scene, camera} = makeScene();
-    const radius = .8;
-    const widthSegments = 4;
-    const heightSegments = 2;
-    const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
-    const material = new THREE.MeshPhongMaterial({
-      color: 'blue',
-      flatShading: true,
-    });
-    const mesh = new THREE.Mesh(geometry, material);
-    scene.add(mesh);
-    return (time, rect) => {
-      mesh.rotation.y = time * .1;
-      camera.aspect = rect.width / rect.height;
-      camera.updateProjectionMatrix();
-      renderer.render(scene, camera);
-    };
-  },
-};
-
-

Et pour initialiser, nous pouvons simplement utiliser querySelectorAll pour trouver tous les diagrammes et appeler la fonction d'initialisation correspondante pour ce diagramme.

-
document.querySelectorAll('[data-diagram]').forEach((elem) => {
-  const sceneName = elem.dataset.diagram;
-  const sceneInitFunction = sceneInitFunctionsByName[sceneName];
-  const sceneRenderFunction = sceneInitFunction(elem);
-  addScene(elem, sceneRenderFunction);
-});
-
-

Pas de changement visuel, mais le code est encore plus générique.

-

-

Ajout de Contrôles à chaque élément

-

Ajouter de l'interactivité, par exemple un TrackballControls, est tout aussi simple. Nous ajoutons d'abord le script pour le contrôle.

-
import {TrackballControls} from 'three/addons/controls/TrackballControls.js';
-
-

Et ensuite, nous pouvons ajouter un TrackballControls à chaque scène en passant l'élément associé à cette scène.

-
-function makeScene() {
-+function makeScene(elem) {
-  const scene = new THREE.Scene();
-
-  const fov = 45;
-  const aspect = 2;  // the canvas default
-  const near = 0.1;
-  const far = 5;
-  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-  camera.position.set(0, 1, 2);
-  camera.lookAt(0, 0, 0);
-+  scene.add(camera);
-
-+  const controls = new TrackballControls(camera, elem);
-+  controls.noZoom = true;
-+  controls.noPan = true;
-
-  {
-    const color = 0xFFFFFF;
-    const intensity = 1;
-    const light = new THREE.DirectionalLight(color, intensity);
-    light.position.set(-1, 2, 4);
--    scene.add(light);
-+    camera.add(light);
-  }
-
--  return {scene, camera};
-+ return {scene, camera, controls};
-}
-
-

Vous remarquerez que nous avons ajouté la caméra à la scène et la lumière à la caméra. -Cela rend la lumière relative à la caméra. Comme les TrackballControls -déplacent la caméra, c'est probablement ce que nous voulons. -Cela permet de maintenir la lumière éclairant le côté de l'objet que nous regardons.

-

Nous devons mettre à jour ces contrôles dans nos fonctions de rendu

-
const sceneInitFunctionsByName = {
-- 'box': () => {
--    const {scene, camera} = makeScene();
-+ 'box': (elem) => {
-+    const {scene, camera, controls} = makeScene(elem);
-    const geometry = new THREE.BoxGeometry(1, 1, 1);
-    const material = new THREE.MeshPhongMaterial({color: 'red'});
-    const mesh = new THREE.Mesh(geometry, material);
-    scene.add(mesh);
-    return (time, rect) => {
-      mesh.rotation.y = time * .1;
-      camera.aspect = rect.width / rect.height;
-      camera.updateProjectionMatrix();
-+      controls.handleResize();
-+      controls.update();
-      renderer.render(scene, camera);
-    };
-  },
--  'pyramid': () => {
--    const {scene, camera} = makeScene();
-+  'pyramid': (elem) => {
-+    const {scene, camera, controls} = makeScene(elem);
-    const radius = .8;
-    const widthSegments = 4;
-    const heightSegments = 2;
-    const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
-    const material = new THREE.MeshPhongMaterial({
-      color: 'blue',
-      flatShading: true,
-    });
-    const mesh = new THREE.Mesh(geometry, material);
-    scene.add(mesh);
-    return (time, rect) => {
-      mesh.rotation.y = time * .1;
-      camera.aspect = rect.width / rect.height;
-      camera.updateProjectionMatrix();
-+      controls.handleResize();
-+      controls.update();
-      renderer.render(scene, camera);
-    };
-  },
-};
-
-

Et maintenant, si vous faites glisser les objets, ils pivoteront.

-

- -

-

Ces techniques sont utilisées sur ce site même. En particulier, l'article sur les primitives et l'article sur les matériaux utilisent cette technique pour ajouter les différents exemples tout au long de l'article.

-

Une autre solution consisterait à rendre sur un canvas hors écran et à copier le résultat sur un canvas 2D à chaque élément. -L'avantage de cette solution est qu'il n'y a aucune limite à la manière dont vous pouvez composer chaque zone séparée. Avec la solution précédente, -nous avions un seul canvas en arrière-plan. Avec cette solution, nous avons des éléments HTML normaux.

-

L'inconvénient est que c'est plus lent car une copie doit avoir lieu pour chaque zone. La lenteur dépend du navigateur -et du GPU.

-

Les modifications nécessaires sont assez minimes

-

D'abord, nous allons modifier le HTML car nous n'avons plus besoin d'un canvas sur la page

-
<body>
--  <canvas id="c"></canvas>
-  ...
-</body>
-
-

puis nous allons modifier le CSS

-
-#c {
--  position: absolute;
--  left: 0;
--  top: 0;
--  width: 100%;
--  height: 100%;
--  display: block;
--  z-index: -1;
--}
-canvas {
-  width: 100%;
-  height: 100%;
-  display: block;
-}
-*[data-diagram] {
-  display: inline-block;
-  width: 5em;
-  height: 3em;
-}
-

Nous avons fait en sorte que tous les canvases remplissent leur conteneur.

-

Maintenant, changeons le JavaScript. D'abord, nous ne recherchons plus -le canvas. Au lieu de cela, nous en créons un. Nous activons également -simplement le test scissor au début.

-
function main() {
--  const canvas = document.querySelector('#c');
-+  const canvas = document.createElement('canvas');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas, alpha: true});
-+  renderer.setScissorTest(true);
-
-  ...
-
-

Ensuite, pour chaque scène, nous créons un contexte de rendu 2D et -ajoutons son canvas à l'élément pour cette scène

-
const sceneElements = [];
-function addScene(elem, fn) {
-+  // ajouter un canvas à l'élément
-+  const ctx = document.createElement('canvas').getContext('2d');
-+  elem.appendChild(ctx.canvas);
--  sceneElements.push({elem, fn});
-+  sceneElements.push({elem, ctx, fn});
-}
-
-

Ensuite, lors du rendu, si le canvas du renderer n'est pas -assez grand pour rendre cette zone, nous augmentons sa taille. -De même, si le canvas de cette zone n'a pas la bonne taille, nous -changeons sa taille. Enfin, nous définissons le scissor et le viewport, -rendons la scène pour cette zone, puis copions le résultat sur le canvas de la zone.

-
function render(time) {
-  time *= 0.001;
-
--  resizeRendererToDisplaySize(renderer);
--
--  renderer.setScissorTest(false);
--  renderer.setClearColor(clearColor, 0);
--  renderer.clear(true, true);
--  renderer.setScissorTest(true);
--
--  const transform = `translateY(${window.scrollY}px)`;
--  renderer.domElement.style.transform = transform;
-
--  for (const {elem, fn} of sceneElements) {
-+  for (const {elem, fn, ctx} of sceneElements) {
-    // obtenir la position relative à la zone d'affichage de cet élément
-    const rect = elem.getBoundingClientRect();
-    const {left, right, top, bottom, width, height} = rect;
-+    const rendererCanvas = renderer.domElement;
-
-    const isOffscreen =
-        bottom < 0 ||
--        top > renderer.domElement.clientHeight ||
-+        top > window.innerHeight ||
-        right < 0 ||
--        left > renderer.domElement.clientWidth;
-+        left > window.innerWidth;
-
-    if (!isOffscreen) {
--      const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
--      renderer.setScissor(left, positiveYUpBottom, width, height);
--      renderer.setViewport(left, positiveYUpBottom, width, height);
-
-+      // s'assurer que le canvas du renderer est assez grand
-+      if (rendererCanvas.width < width || rendererCanvas.height < height) {
-+        renderer.setSize(width, height, false);
-+      }
-+
-+      // s'assurer que le canvas pour cette zone est de la même taille que la zone
-+      if (ctx.canvas.width !== width || ctx.canvas.height !== height) {
-+        ctx.canvas.width = width;
-+        ctx.canvas.height = height;
-+      }
-+
-+      renderer.setScissor(0, 0, width, height);
-+      renderer.setViewport(0, 0, width, height);
-
-      fn(time, rect);
-
-+      // copier la scène rendue sur le canvas de cet élément
-+      ctx.globalCompositeOperation = 'copy';
-+      ctx.drawImage(
-+          rendererCanvas,
-+          0, rendererCanvas.height - height, width, height,  // src rect
-+          0, 0, width, height);                              // dst rect
-    }
-  }
-
-  requestAnimationFrame(render);
-}
-
-

Le résultat est identique

-

- -

-

Un autre avantage de cette solution est que vous pourriez potentiellement utiliser -OffscreenCanvas -pour rendre depuis un web worker et toujours utiliser cette technique. Malheureusement, en juillet 2020, -OffscreenCanvas n'est pris en charge que par Chrome.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/offscreencanvas.html b/manual/fr/offscreencanvas.html deleted file mode 100644 index 51b0eae9d902d3..00000000000000 --- a/manual/fr/offscreencanvas.html +++ /dev/null @@ -1,1089 +0,0 @@ - - - OffscreenCanvas - - - - - - - - - - - - - -
-
-

OffscreenCanvas

-
-
-
-

OffscreenCanvas -est une fonctionnalité de navigateur relativement nouvelle, actuellement disponible uniquement dans Chrome mais apparemment -à venir sur d'autres navigateurs. OffscreenCanvas permet à un web worker de rendre -sur un canevas. C'est une façon de décharger le travail lourd, comme le rendu d'une scène 3D complexe, -sur un web worker afin de ne pas ralentir la réactivité du navigateur. Cela -signifie également que les données sont chargées et analysées dans le worker, ce qui réduit potentiellement les saccades pendant -le chargement de la page.

-

Commencer à l'utiliser est assez simple. Portons l'exemple des 3 cubes en rotation depuis l'article sur la réactivité.

-

En général, les workers ont leur code séparé -dans un autre fichier script, tandis que la plupart des exemples sur ce site ont leurs -scripts intégrés dans le fichier HTML de la page sur laquelle ils se trouvent.

-

Dans notre cas, nous allons créer un fichier appelé offscreencanvas-cubes.js et -y copier tout le JavaScript depuis l'exemple réactif. Nous apporterons ensuite -les modifications nécessaires pour qu'il s'exécute dans un worker.

-

Nous avons encore besoin de JavaScript dans notre fichier HTML. La première chose -à faire est de trouver le canevas, puis de transférer son contrôle -pour qu'il soit offscreen en appelant canvas.transferControlToOffscreen.

-
function main() {
-  const canvas = document.querySelector('#c');
-  const offscreen = canvas.transferControlToOffscreen();
-
-  ...
-
-

Nous pouvons ensuite démarrer notre worker avec new Worker(pathToScript, {type: 'module'}). -et lui passer l'objet offscreen.

-
function main() {
-  const canvas = document.querySelector('#c');
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-cubes.js', {type: 'module'});
-  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
-}
-main();
-
-

Il est important de noter que les workers ne peuvent pas accéder au DOM. Ils -ne peuvent pas regarder les éléments HTML ni recevoir les événements de souris ou -de clavier. La seule chose qu'ils peuvent généralement faire est de répondre -aux messages qui leur sont envoyés et de renvoyer des messages à la page.

-

Pour envoyer un message à un worker, nous appelons worker.postMessage et -lui passons 1 ou 2 arguments. Le premier argument est un objet JavaScript -qui sera cloné -et envoyé au worker. Le second argument est un tableau optionnel -d'objets qui font partie du premier objet et que nous voulons transférer -au worker. Ces objets ne seront pas clonés. Au lieu de cela, ils seront transférés -et cesseront d'exister dans la page principale. Cesser d'exister est probablement -la mauvaise description, ils sont plutôt neutralisés. Seuls certains types d'objets -peuvent être transférés au lieu d'être clonés. Ils incluent OffscreenCanvas, -donc une fois transféré, l'objet offscreen dans la page principale devient inutile.

-

Les workers reçoivent les messages via leur gestionnaire onmessage. L'objet -que nous avons passé à postMessage arrive sur event.data passé au gestionnaire onmessage -sur le worker. Le code ci-dessus déclare un type: 'main' dans l'objet qu'il passe -au worker. Cet objet n'a aucune signification pour le navigateur. Il est entièrement destiné -à notre propre usage. Nous allons créer un gestionnaire qui, basé sur le type, appelle -une fonction différente dans le worker. Ensuite, nous pourrons ajouter des fonctions au besoin et -les appeler facilement depuis la page principale.

-
const handlers = {
-  main,
-};
-
-self.onmessage = function(e) {
-  const fn = handlers[e.data.type];
-  if (typeof fn !== 'function') {
-    throw new Error('no handler for type: ' + e.data.type);
-  }
-  fn(e.data);
-};
-
-

Vous pouvez voir ci-dessus que nous recherchons simplement le gestionnaire basé sur le type et que nous lui passons les data -qui ont été envoyées depuis la page principale.

-

Il ne nous reste plus qu'à commencer à modifier la fonction main que nous avons collée dans -offscreencanvas-cubes.js depuis l'article sur la réactivité.

-

Au lieu de rechercher le canevas depuis le DOM, nous le recevrons des données d'événement.

-
-function main() {
--  const canvas = document.querySelector('#c');
-+function main(data) {
-+  const {canvas} = data;
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-
-  ...
-
-

En gardant à l'esprit que les workers ne peuvent pas voir le DOM du tout, le premier problème -que nous rencontrons est que resizeRendererToDisplaySize ne peut pas lire canvas.clientWidth -et canvas.clientHeight car ce sont des valeurs DOM. Voici le code original

-
function resizeRendererToDisplaySize(renderer) {
-  const canvas = renderer.domElement;
-  const width = canvas.clientWidth;
-  const height = canvas.clientHeight;
-  const needResize = canvas.width !== width || canvas.height !== height;
-  if (needResize) {
-    renderer.setSize(width, height, false);
-  }
-  return needResize;
-}
-
-

Au lieu de cela, nous devrons envoyer les tailles au worker dès qu'elles changent. -Ajoutons donc un état global et conservons la largeur et la hauteur à cet endroit.

-
const state = {
-  width: 300,  // par défaut du canevas
-  height: 150,  // par défaut du canevas
-};
-
-

Ensuite, ajoutons un gestionnaire 'size' pour mettre à jour ces valeurs.

-
+function size(data) {
-+  state.width = data.width;
-+  state.height = data.height;
-+}
-
-const handlers = {
-  main,
-+  size,
-};
-
-

Maintenant, nous pouvons modifier resizeRendererToDisplaySize pour utiliser state.width et state.height

-
function resizeRendererToDisplaySize(renderer) {
-  const canvas = renderer.domElement;
--  const width = canvas.clientWidth;
--  const height = canvas.clientHeight;
-+  const width = state.width;
-+  const height = state.height;
-  const needResize = canvas.width !== width || canvas.height !== height;
-  if (needResize) {
-    renderer.setSize(width, height, false);
-  }
-  return needResize;
-}
-
-

et là où nous calculons l'aspect, nous avons besoin de changements similaires

-
function render(time) {
-  time *= 0.001;
-
-  if (resizeRendererToDisplaySize(renderer)) {
--    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-+    camera.aspect = state.width / state.height;
-    camera.updateProjectionMatrix();
-  }
-
-  ...
-
-

De retour dans la page principale, nous enverrons un événement size chaque fois que la page change de taille.

-
const worker = new Worker('offscreencanvas-picking.js', {type: 'module'});
-worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
-
-+function sendSize() {
-+  worker.postMessage({
-+    type: 'size',
-+    width: canvas.clientWidth,
-+    height: canvas.clientHeight,
-+  });
-+}
-+
-+window.addEventListener('resize', sendSize);
-+sendSize();
-
-

Nous l'appelons également une fois pour envoyer la taille initiale.

-

Et avec ces quelques modifications seulement, en supposant que votre navigateur prenne entièrement en charge OffscreenCanvas, -cela devrait fonctionner. Avant de l'exécuter, vérifions si le navigateur prend réellement en charge -OffscreenCanvas et, si ce n'est pas le cas, affichons une erreur. Ajoutons d'abord du HTML pour afficher l'erreur.

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="noOffscreenCanvas" style="display:none;">
-+    <div>no OffscreenCanvas support</div>
-+  </div>
-</body>
-
-

et un peu de CSS pour cela

-
#noOffscreenCanvas {
-    display: flex;
-    width: 100%;
-    height: 100%;
-    align-items: center;
-    justify-content: center;
-    background: red;
-    color: white;
-}
-
-

et ensuite nous pouvons vérifier l'existence de transferControlToOffscreen pour voir -si le navigateur prend en charge OffscreenCanvas

-
function main() {
-  const canvas = document.querySelector('#c');
-+  if (!canvas.transferControlToOffscreen) {
-+    canvas.style.display = 'none';
-+    document.querySelector('#noOffscreenCanvas').style.display = '';
-+    return;
-+  }
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-picking.js', {type: 'module});
-  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
-
-  ...
-
-

et avec cela, si votre navigateur prend en charge OffscreenCanvas, cet exemple devrait fonctionner

-

- -

-

C'est formidable, mais comme tous les navigateurs ne prennent pas en charge OffscreenCanvas pour le moment, -modifions le code pour qu'il fonctionne à la fois avec OffscreenCanvas et, si ce n'est pas le cas, pour qu'il revienne à l'utilisation -du canevas dans la page principale comme d'habitude.

-
-

En aparté, si vous avez besoin de OffscreenCanvas pour rendre votre page réactive, alors -l'intérêt d'avoir un fallback n'est pas évident. Peut-être que selon si -vous exécutez sur la page principale ou dans un worker, vous pourriez ajuster la quantité -de travail effectué afin que lorsque vous exécutez dans un worker, vous puissiez faire plus que lorsque -vous exécutez dans la page principale. Ce que vous faites dépend entièrement de vous.

-
-

La première chose que nous devrions probablement faire est de séparer le code three.js -du code spécifique au worker. De cette façon, nous pouvons -utiliser le même code sur la page principale et sur le worker. En d'autres termes, -nous aurons maintenant 3 fichiers

-
    -
  1. notre fichier html.

    -

    threejs-offscreencanvas-w-fallback.html

    -
  2. -
  3. un fichier JavaScript qui contient notre code three.js.

    -

    shared-cubes.js

    -
  4. -
  5. notre code de support pour le worker

    -

    offscreencanvas-worker-cubes.js

    -
  6. -
-

shared-cubes.js et offscreencanvas-worker-cubes.js sont essentiellement -la séparation de notre fichier offscreencanvas-cubes.js précédent. Nous -copions d'abord tout le contenu de offscreencanvas-cubes.js dans shared-cube.js. Ensuite, -nous renommons main en init car nous avons déjà une fonction main dans notre -fichier HTML, et nous devons exporter init et state

-
import * as THREE from 'three';
-
--const state = {
-+export const state = {
-  width: 300,   // par défaut du canevas
-  height: 150,  // par défaut du canevas
-};
-
--function main(data) {
-+export function init(data) {
-  const {canvas} = data;
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-
-

et découpons juste les parties non liées à three.js

-
-function size(data) {
--  state.width = data.width;
--  state.height = data.height;
--}
--
--const handlers = {
--  main,
--  size,
--};
--
--self.onmessage = function(e) {
--  const fn = handlers[e.data.type];
--  if (typeof fn !== 'function') {
--    throw new Error('no handler for type: ' + e.data.type);
--  }
--  fn(e.data);
--};
-
-

Ensuite, nous copions les parties que nous venons de supprimer dans offscreencanvas-worker-cubes.js -et importons shared-cubes.js ainsi qu'appelons init au lieu de main.

-
import {init, state} from './shared-cubes.js';
-
-function size(data) {
-  state.width = data.width;
-  state.height = data.height;
-}
-
-const handlers = {
--  main,
-+  init,
-  size,
-};
-
-self.onmessage = function(e) {
-  const fn = handlers[e.data.type];
-  if (typeof fn !== 'function') {
-    throw new Error('no handler for type: ' + e.data.type);
-  }
-  fn(e.data);
-};
-
-

De même, nous devons inclure shared-cubes.js dans la page principale

-
<script type="module">
-+import {init, state} from './shared-cubes.js';
-
-

Nous pouvons supprimer le HTML et le CSS que nous avons ajoutés précédemment

-
<body>
-  <canvas id="c"></canvas>
--  <div id="noOffscreenCanvas" style="display:none;">
--    <div>no OffscreenCanvas support</div>
--  </div>
-</body>
-
-

et un peu de CSS pour cela

-
-#noOffscreenCanvas {
--    display: flex;
--    width: 100%;
--    height: 100%;
--    align-items: center;
--    justify-content: center;
--    background: red;
--    color: white;
--}
-
-

Ensuite, modifions le code dans la page principale pour appeler une fonction de démarrage ou une autre -selon que le navigateur prend en charge OffscreenCanvas.

-
function main() {
-  const canvas = document.querySelector('#c');
--  if (!canvas.transferControlToOffscreen) {
--    canvas.style.display = 'none';
--    document.querySelector('#noOffscreenCanvas').style.display = '';
--    return;
--  }
--  const offscreen = canvas.transferControlToOffscreen();
--  const worker = new Worker('offscreencanvas-picking.js', {type: 'module'});
--  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
-+  if (canvas.transferControlToOffscreen) {
-+    startWorker(canvas);
-+  } else {
-+    startMainPage(canvas);
-+  }
-  ...
-
-

Nous allons déplacer tout le code que nous avions pour configurer le worker à l'intérieur de startWorker

-
function startWorker(canvas) {
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-worker-cubes.js', {type: 'module'});
-  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
-
-  function sendSize() {
-    worker.postMessage({
-      type: 'size',
-      width: canvas.clientWidth,
-      height: canvas.clientHeight,
-    });
-  }
-
-  window.addEventListener('resize', sendSize);
-  sendSize();
-
-  console.log('using OffscreenCanvas');
-}
-
-

et envoyer init au lieu de main

-
-  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
-+  worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);
-
-

pour démarrer dans la page principale, nous pouvons faire ceci

-
function startMainPage(canvas) {
-  init({canvas});
-
-  function sendSize() {
-    state.width = canvas.clientWidth;
-    state.height = canvas.clientHeight;
-  }
-  window.addEventListener('resize', sendSize);
-  sendSize();
-
-  console.log('using regular canvas');
-}
-
-

et avec cela, notre exemple s'exécutera soit dans un OffscreenCanvas, soit il -reviendra à s'exécuter dans la page principale.

-

- -

-

C'était donc relativement facile. Essayons le picking. Nous allons prendre du code de -l'exemple RayCaster depuis l'article sur le picking -et le faire fonctionner offscreen.

-

Copions le fichier shared-cube.js vers shared-picking.js et ajoutons les parties de picking. Nous copions le PickHelper

-
class PickHelper {
-  constructor() {
-    this.raycaster = new THREE.Raycaster();
-    this.pickedObject = null;
-    this.pickedObjectSavedColor = 0;
-  }
-  pick(normalizedPosition, scene, camera, time) {
-    // restore the color if there is a picked object
-    if (this.pickedObject) {
-      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
-      this.pickedObject = undefined;
-    }
-
-    // cast a ray through the frustum
-    this.raycaster.setFromCamera(normalizedPosition, camera);
-    // get the list of objects the ray intersected
-    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
-    if (intersectedObjects.length) {
-      // pick the first object. It's the closest one
-      this.pickedObject = intersectedObjects[0].object;
-      // save its color
-      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
-      // set its emissive color to flashing red/yellow
-      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
-    }
-  }
-}
-
-const pickPosition = {x: 0, y: 0};
-const pickHelper = new PickHelper();
-
-

Nous avons mis à jour pickPosition à partir de la souris comme ceci

-
function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function setPickPosition(event) {
-  const pos = getCanvasRelativePosition(event);
-  pickPosition.x = (pos.x / canvas.width ) *  2 - 1;
-  pickPosition.y = (pos.y / canvas.height) * -2 + 1;  // notez que nous inversons Y
-}
-window.addEventListener('mousemove', setPickPosition);
-
-

Un worker ne peut pas lire la position de la souris directement, donc tout comme le code de taille, -envoyons un message avec la position de la souris. Comme pour le code de taille, nous enverrons -la position de la souris et mettrons à jour pickPosition

-
function size(data) {
-  state.width = data.width;
-  state.height = data.height;
-}
-
-+function mouse(data) {
-+  pickPosition.x = data.x;
-+  pickPosition.y = data.y;
-+}
-
-const handlers = {
-  init,
-+  mouse,
-  size,
-};
-
-self.onmessage = function(e) {
-  const fn = handlers[e.data.type];
-  if (typeof fn !== 'function') {
-    throw new Error('no handler for type: ' + e.data.type);
-  }
-  fn(e.data);
-};
-
-

De retour dans notre page principale, nous devons ajouter du code pour passer la souris -au worker ou à la page principale.

-
+let sendMouse;
-
-function startWorker(canvas) {
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-worker-picking.js', {type: 'module'});
-  worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);
-
-+  sendMouse = (x, y) => {
-+    worker.postMessage({
-+      type: 'mouse',
-+      x,
-+      y,
-+    });
-+  };
-
-  function sendSize() {
-    worker.postMessage({
-      type: 'size',
-      width: canvas.clientWidth,
-      height: canvas.clientHeight,
-    });
-  }
-
-  window.addEventListener('resize', sendSize);
-  sendSize();
-
-  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
-}
-
-function startMainPage(canvas) {
-  init({canvas});
-
-+  sendMouse = (x, y) => {
-+    pickPosition.x = x;
-+    pickPosition.y = y;
-+  };
-
-  function sendSize() {
-    state.width = canvas.clientWidth;
-    state.height = canvas.clientHeight;
-  }
-  window.addEventListener('resize', sendSize);
-  sendSize();
-
-  console.log('using regular canvas');  /* eslint-disable-line no-console */
-}
-
-

Ensuite, nous pouvons copier tout le code de gestion de la souris dans la page principale et -apporter juste des modifications mineures pour utiliser sendMouse

-
function setPickPosition(event) {
-  const pos = getCanvasRelativePosition(event);
--  pickPosition.x = (pos.x / canvas.clientWidth ) *  2 - 1;
--  pickPosition.y = (pos.y / canvas.clientHeight) * -2 + 1;  // note we flip Y
-+  sendMouse(
-+      (pos.x / canvas.clientWidth ) *  2 - 1,
-+      (pos.y / canvas.clientHeight) * -2 + 1);  // notez que nous inversons Y
-}
-
-function clearPickPosition() {
-  // Contrairement à la souris qui a toujours une position
-  // si l'utilisateur arrête de toucher l'écran, nous voulons
-  // arrêter le picking. Pour l'instant, nous choisissons juste une valeur
-  // peu susceptible de sélectionner quelque chose
--  pickPosition.x = -100000;
--  pickPosition.y = -100000;
-+  sendMouse(-100000, -100000);
-}
-window.addEventListener('mousemove', setPickPosition);
-window.addEventListener('mouseout', clearPickPosition);
-window.addEventListener('mouseleave', clearPickPosition);
-
-window.addEventListener('touchstart', (event) => {
-  // prevent the window from scrolling
-  event.preventDefault();
-  setPickPosition(event.touches[0]);
-}, {passive: false});
-
-window.addEventListener('touchmove', (event) => {
-  setPickPosition(event.touches[0]);
-});
-
-window.addEventListener('touchend', clearPickPosition);
-
-

et avec cela, le picking devrait fonctionner avec OffscreenCanvas.

-

- -

-

Allons un peu plus loin et ajoutons les OrbitControls. -Cela sera un peu plus complexe. Les OrbitControls utilisent -le DOM de manière assez extensive pour vérifier la souris, les événements tactiles, -et le clavier.

-

Contrairement à notre code jusqu'à présent, nous ne pouvons pas vraiment utiliser un objet state global -sans réécrire tout le code des OrbitControls pour qu'il fonctionne avec. -Les OrbitControls prennent un HTMLElement auquel ils attachent la plupart -des événements DOM qu'ils utilisent. Peut-être pourrions-nous passer notre propre -objet qui a la même surface d'API qu'un élément DOM. -Nous n'avons besoin de prendre en charge que les fonctionnalités dont les OrbitControls ont besoin.

-

En fouillant dans le code source des OrbitControls, -il semble que nous devions gérer les événements suivants.

-
    -
  • contextmenu
  • -
  • pointerdown
  • -
  • pointermove
  • -
  • pointerup
  • -
  • touchstart
  • -
  • touchmove
  • -
  • touchend
  • -
  • wheel
  • -
  • keydown
  • -
-

Pour les événements de pointeur, nous avons besoin des propriétés ctrlKey, metaKey, shiftKey, -button, pointerType, clientX, clientY, pageX et pageY.

-

Pour les événements keydown, nous avons besoin des propriétés ctrlKey, metaKey, shiftKey -et keyCode.

-

Pour l'événement wheel, nous n'avons besoin que de la propriété deltaY.

-

Et pour les événements tactiles, nous n'avons besoin que de pageX et pageY de -la propriété touches.

-

Alors, créons une paire d'objets proxy. Une partie s'exécutera dans la page principale, -capturera tous ces événements et transmettra les valeurs de propriété pertinentes -au worker. L'autre partie s'exécutera dans le worker, recevra ces -événements et les transmettra en utilisant des événements qui ont la même structure -que les événements DOM originaux, de sorte que les OrbitControls ne pourront pas -faire la différence.

-

Voici le code pour la partie worker.

-
import {EventDispatcher} from 'three';
-
-class ElementProxyReceiver extends EventDispatcher {
-  constructor() {
-    super();
-  }
-  handleEvent(data) {
-    this.dispatchEvent(data);
-  }
-}
-
-

Tout ce qu'il fait est, s'il reçoit un message, de le dispatcher. -Il hérite de EventDispatcher qui fournit des méthodes comme -addEventListener et removeEventListener, tout comme un élément DOM, -donc si nous le passons aux OrbitControls, cela devrait fonctionner.

-

ElementProxyReceiver gère 1 élément. Dans notre cas, nous n'en avons besoin que d'un, -mais il est préférable d'anticiper, alors créons un gestionnaire pour gérer -plus d'un.

-
class ProxyManager {
-  constructor() {
-    this.targets = {};
-    this.handleEvent = this.handleEvent.bind(this);
-  }
-  makeProxy(data) {
-    const {id} = data;
-    const proxy = new ElementProxyReceiver();
-    this.targets[id] = proxy;
-  }
-  getProxy(id) {
-    return this.targets[id];
-  }
-  handleEvent(data) {
-    this.targets[data.id].handleEvent(data.data);
-  }
-}
-
-

Nous pouvons créer une instance de ProxyManager et appeler sa méthode makeProxy -avec un identifiant, ce qui créera un ElementProxyReceiver qui -répondra aux messages avec cet identifiant.

-

Connectons-le au gestionnaire de messages de notre worker.

-
const proxyManager = new ProxyManager();
-
-function start(data) {
-  const proxy = proxyManager.getProxy(data.canvasId);
-  init({
-    canvas: data.canvas,
-    inputElement: proxy,
-  });
-}
-
-function makeProxy(data) {
-  proxyManager.makeProxy(data);
-}
-
-...
-
-const handlers = {
--  init,
--  mouse,
-+  start,
-+  makeProxy,
-+  event: proxyManager.handleEvent,
-   size,
-};
-
-self.onmessage = function(e) {
-  const fn = handlers[e.data.type];
-  if (typeof fn !== 'function') {
-    throw new Error('no handler for type: ' + e.data.type);
-  }
-  fn(e.data);
-};
-
-

Dans notre code three.js partagé, nous devons importer les OrbitControls et les configurer.

-
import * as THREE from 'three';
-+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-
-export function init(data) {
--  const {canvas} = data;
-+  const {canvas, inputElement} = data;
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-
-+  const controls = new OrbitControls(camera, inputElement);
-+  controls.target.set(0, 0, 0);
-+  controls.update();
-
-

Notez que nous passons notre proxy aux OrbitControls via inputElement -au lieu de passer le canevas comme nous le faisons dans d'autres exemples sans OffscreenCanvas.

-

Ensuite, nous pouvons déplacer tout le code des événements de picking du fichier HTML -vers le code three.js partagé également, tout en changeant -canvas en inputElement.

-
function getCanvasRelativePosition(event) {
--  const rect = canvas.getBoundingClientRect();
-+  const rect = inputElement.getBoundingClientRect();
-  return {
-    x: event.clientX - rect.left,
-    y: event.clientY - rect.top,
-  };
-}
-
-function setPickPosition(event) {
-  const pos = getCanvasRelativePosition(event);
--  sendMouse(
--      (pos.x / canvas.clientWidth ) *  2 - 1,
--      (pos.y / canvas.clientHeight) * -2 + 1);  // note we flip Y
-+  pickPosition.x = (pos.x / inputElement.clientWidth ) *  2 - 1;
-+  pickPosition.y = (pos.y / inputElement.clientHeight) * -2 + 1;  // notez que nous inversons Y
-}
-
-function clearPickPosition() {
-  // Contrairement à la souris qui a toujours une position
-  // si l'utilisateur arrête de toucher l'écran, nous voulons
-  // arrêter le picking. Pour l'instant, nous choisissons juste une valeur
-  // peu susceptible de sélectionner quelque chose
--  sendMouse(-100000, -100000);
-+  pickPosition.x = -100000;
-+  pickPosition.y = -100000;
-}
-
-*inputElement.addEventListener('mousemove', setPickPosition);
-*inputElement.addEventListener('mouseout', clearPickPosition);
-*inputElement.addEventListener('mouseleave', clearPickPosition);
-
-*inputElement.addEventListener('touchstart', (event) => {
-  // prevent the window from scrolling
-  event.preventDefault();
-  setPickPosition(event.touches[0]);
-}, {passive: false});
-
-*inputElement.addEventListener('touchmove', (event) => {
-  setPickPosition(event.touches[0]);
-});
-
-*inputElement.addEventListener('touchend', clearPickPosition);
-
-

De retour dans la page principale, nous avons besoin de code pour envoyer des messages pour -tous les événements que nous avons énumérés ci-dessus.

-
let nextProxyId = 0;
-class ElementProxy {
-  constructor(element, worker, eventHandlers) {
-    this.id = nextProxyId++;
-    this.worker = worker;
-    const sendEvent = (data) => {
-      this.worker.postMessage({
-        type: 'event',
-        id: this.id,
-        data,
-      });
-    };
-
-    // register an id
-    worker.postMessage({
-      type: 'makeProxy',
-      id: this.id,
-    });
-    for (const [eventName, handler] of Object.entries(eventHandlers)) {
-      element.addEventListener(eventName, function(event) {
-        handler(event, sendEvent);
-      });
-    }
-  }
-}
-
-

ElementProxy prend l'élément dont nous voulons proxifier les événements. Il -enregistre ensuite un identifiant auprès du worker en en choisissant un et en l'envoyant -via le message makeProxy que nous avons configuré précédemment. Le worker créera -un ElementProxyReceiver et l'enregistrera avec cet identifiant.

-

Nous avons ensuite un objet de gestionnaires d'événements à enregistrer. De cette façon, -nous pouvons passer des gestionnaires uniquement pour les événements que nous voulons transmettre au -worker.

-

Lorsque nous démarrons le worker, nous créons d'abord un proxy et passons nos gestionnaires d'événements.

-
function startWorker(canvas) {
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-worker-orbitcontrols.js', {type: 'module'});
-
-+  const eventHandlers = {
-+    contextmenu: preventDefaultHandler,
-+    mousedown: mouseEventHandler,
-+    mousemove: mouseEventHandler,
-+    mouseup: mouseEventHandler,
-+    pointerdown: mouseEventHandler,
-+    pointermove: mouseEventHandler,
-+    pointerup: mouseEventHandler,
-+    touchstart: touchEventHandler,
-+    touchmove: touchEventHandler,
-+    touchend: touchEventHandler,
-+    wheel: wheelEventHandler,
-+    keydown: filteredKeydownEventHandler,
-+  };
-+  const proxy = new ElementProxy(canvas, worker, eventHandlers);
-  worker.postMessage({
-    type: 'start',
-    canvas: offscreen,
-+    canvasId: proxy.id,
-  }, [offscreen]);
-  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
-}
-
-

Et voici les gestionnaires d'événements. Tout ce qu'ils font est de copier une liste de propriétés -à partir de l'événement qu'ils reçoivent. On leur passe une fonction sendEvent à laquelle ils passent les données -qu'ils créent. Cette fonction ajoutera l'identifiant correct et l'enverra au worker.

-
const mouseEventHandler = makeSendPropertiesHandler([
-  'ctrlKey',
-  'metaKey',
-  'shiftKey',
-  'button',
-  'pointerType',
-  'clientX',
-  'clientY',
-  'pointerId',
-  'pageX',
-  'pageY',
-]);
-const wheelEventHandlerImpl = makeSendPropertiesHandler([
-  'deltaX',
-  'deltaY',
-]);
-const keydownEventHandler = makeSendPropertiesHandler([
-  'ctrlKey',
-  'metaKey',
-  'shiftKey',
-  'keyCode',
-]);
-
-function wheelEventHandler(event, sendFn) {
-  event.preventDefault();
-  wheelEventHandlerImpl(event, sendFn);
-}
-
-function preventDefaultHandler(event) {
-  event.preventDefault();
-}
-
-function copyProperties(src, properties, dst) {
-  for (const name of properties) {
-      dst[name] = src[name];
-  }
-}
-
-function makeSendPropertiesHandler(properties) {
-  return function sendProperties(event, sendFn) {
-    const data = {type: event.type};
-    copyProperties(event, properties, data);
-    sendFn(data);
-  };
-}
-
-function touchEventHandler(event, sendFn) {
-  // preventDefault() corrige les événements mousemove, mouseup et mousedown
-  // qui se déclenchent lors d'un simple toucher/relâcher
-  // Cela n'arrive qu'avec OffscreenCanvas
-  event.preventDefault();
-  const touches = [];
-  const data = {type: event.type, touches};
-  for (let i = 0; i < event.touches.length; ++i) {
-    const touch = event.touches[i];
-    touches.push({
-      pageX: touch.pageX,
-      pageY: touch.pageY,
-      clientX: touch.clientX,
-      clientY: touch.clientY,
-    });
-  }
-  sendFn(data);
-}
-
-// Les quatre touches fléchées
-const orbitKeys = {
-  '37': true,  // left
-  '38': true,  // up
-  '39': true,  // right
-  '40': true,  // down
-};
-function filteredKeydownEventHandler(event, sendFn) {
-  const {keyCode} = event;
-  if (orbitKeys[keyCode]) {
-    event.preventDefault();
-    keydownEventHandler(event, sendFn);
-  }
-}
-
-

Cela semble proche de fonctionner, mais si nous l'essayons réellement, nous verrons -que les OrbitControls ont besoin de quelques éléments supplémentaires.

-

L'une d'elles est qu'ils appellent element.focus. Nous n'avons pas besoin que cela se produise -dans le worker, alors ajoutons simplement un stub.

-
class ElementProxyReceiver extends THREE.EventDispatcher {
-  constructor() {
-    super();
-  }
-  handleEvent(data) {
-    this.dispatchEvent(data);
-  }
-+  focus() {
-+    // sans opération
-+  }
-}
-
-

Une autre chose est qu'ils appellent event.preventDefault et event.stopPropagation. -Nous gérons déjà cela dans la page principale, donc ceux-ci peuvent également être un noop (sans opération).

-
+function noop() {
-+}
-
-class ElementProxyReceiver extends THREE.EventDispatcher {
-  constructor() {
-    super();
-  }
-  handleEvent(data) {
-+    data.preventDefault = noop;
-+    data.stopPropagation = noop;
-    this.dispatchEvent(data);
-  }
-  focus() {
-    // sans opération
-  }
-}
-
-

Une autre chose est qu'ils regardent clientWidth et clientHeight. Nous -passions la taille auparavant, mais nous pouvons mettre à jour la paire de proxies -pour passer cela également.

-

Dans le worker...

-
class ElementProxyReceiver extends THREE.EventDispatcher {
-  constructor() {
-    super();
-  }
-+  get clientWidth() {
-+    return this.width;
-+  }
-+  get clientHeight() {
-+    return this.height;
-+  }
-+  getBoundingClientRect() {
-+    return {
-+      left: this.left,
-+      top: this.top,
-+      width: this.width,
-+      height: this.height,
-+      right: this.left + this.width,
-+      bottom: this.top + this.height,
-+    };
-+  }
-  handleEvent(data) {
-+    if (data.type === 'size') {
-+      this.left = data.left;
-+      this.top = data.top;
-+      this.width = data.width;
-+      this.height = data.height;
-+      return;
-+    }
-    data.preventDefault = noop;
-    data.stopPropagation = noop;
-    this.dispatchEvent(data);
-  }
-  focus() {
-    // sans opération
-  }
-}
-
-

de retour dans la page principale, nous devons envoyer la taille ainsi que les positions gauche et haut également. -Notez qu'en l'état, nous ne gérons pas si le canevas se déplace, seulement s'il redimensionne. Si vous vouliez -gérer le déplacement, vous devriez appeler sendSize chaque fois que quelque chose déplace le canevas.

-
class ElementProxy {
-  constructor(element, worker, eventHandlers) {
-    this.id = nextProxyId++;
-    this.worker = worker;
-    const sendEvent = (data) => {
-      this.worker.postMessage({
-        type: 'event',
-        id: this.id,
-        data,
-      });
-    };
-
-    // register an id
-    worker.postMessage({
-      type: 'makeProxy',
-      id: this.id,
-    });
-+    sendSize();
-    for (const [eventName, handler] of Object.entries(eventHandlers)) {
-      element.addEventListener(eventName, function(event) {
-        handler(event, sendEvent);
-      });
-    }
-
-+    function sendSize() {
-+      const rect = element.getBoundingClientRect();
-+      sendEvent({
-+        type: 'size',
-+        left: rect.left,
-+        top: rect.top,
-+        width: element.clientWidth,
-+        height: element.clientHeight,
-+      });
-+    }
-+
-+    window.addEventListener('resize', sendSize);
-  }
-}
-
-

et dans notre code three.js partagé, nous n'avons plus besoin de state

-
-export const state = {
--  width: 300,   // par défaut du canevas
--  height: 150,  // par défaut du canevas
--};
-
-...
-
-function resizeRendererToDisplaySize(renderer) {
-  const canvas = renderer.domElement;
--  const width = state.width;
--  const height = state.height;
-+  const width = inputElement.clientWidth;
-+  const height = inputElement.clientHeight;
-  const needResize = canvas.width !== width || canvas.height !== height;
-  if (needResize) {
-    renderer.setSize(width, height, false);
-  }
-  return needResize;
-}
-
-function render(time) {
-  time *= 0.001;
-
-  if (resizeRendererToDisplaySize(renderer)) {
--    camera.aspect = state.width / state.height;
-+    camera.aspect = inputElement.clientWidth / inputElement.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-  ...
-
-

Quelques hacks supplémentaires. Les OrbitControls ajoutent des événements pointermove et pointerup à l'ownerDocument -de l'élément pour gérer la capture de la souris (lorsque la souris sort de la fenêtre).

-

De plus, le code référence le document global, mais il n'y a pas de document global -dans un worker.

-

Nous pouvons résoudre tout cela avec 2 hacks rapides. Dans notre code worker, -nous allons réutiliser notre proxy pour les deux problèmes.

-
function start(data) {
-  const proxy = proxyManager.getProxy(data.canvasId);
-+  proxy.ownerDocument = proxy; // HACK!
-+  self.document = {} // HACK!
-  init({
-    canvas: data.canvas,
-    inputElement: proxy,
-  });
-}
-
-

Cela donnera aux OrbitControls quelque chose à inspecter qui -correspond à leurs attentes.

-

Je sais que c'était un peu difficile à suivre. La version courte est la suivante : -ElementProxy s'exécute sur la page principale et transmet les événements DOM -à ElementProxyReceiver dans le worker, qui se fait passer pour un HTMLElement -que nous pouvons utiliser à la fois avec les OrbitControls et avec notre propre code.

-

La dernière chose est notre fallback lorsque nous n'utilisons pas OffscreenCanvas. -Tout ce que nous avons à faire est de passer le canevas lui-même comme notre inputElement.

-
function startMainPage(canvas) {
--  init({canvas});
-+  init({canvas, inputElement: canvas});
-  console.log('using regular canvas');
-}
-
-

et maintenant nous devrions avoir les OrbitControls fonctionnant avec OffscreenCanvas

-

- -

-

C'est probablement l'exemple le plus compliqué sur ce site. Il est un -peu difficile à suivre car il y a 3 fichiers impliqués pour chaque -exemple. Le fichier HTML, le fichier worker, le code three.js partagé.

-

J'espère que ce n'était pas trop difficile à comprendre et que cela a fourni -des exemples utiles pour travailler avec three.js, OffscreenCanvas et les web workers.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/optimize-lots-of-objects-animated.html b/manual/fr/optimize-lots-of-objects-animated.html deleted file mode 100644 index 1742cb77df7b30..00000000000000 --- a/manual/fr/optimize-lots-of-objects-animated.html +++ /dev/null @@ -1,492 +0,0 @@ - - - Optimiser de nombreux objets animés - - - - - - - - - - - - - -
-
-

Optimiser de nombreux objets animés

-
-
-
-

Cet article est une continuation de un article sur l'optimisation de nombreux objets -. Si vous ne l'avez pas encore lu, -veuillez le lire avant de poursuivre.

-

Dans l'article précédent, nous avons fusionné environ 19000 cubes en une -seule géométrie. Cela a eu l'avantage d'optimiser notre dessin -de 19000 cubes, mais cela a eu l'inconvénient de rendre plus difficile -le déplacement d'un cube individuel.

-

Selon ce que nous essayons d'accomplir, il existe différentes solutions. -Dans ce cas, affichons plusieurs ensembles de données et animons la transition entre les ensembles.

-

La première chose à faire est d'obtenir plusieurs ensembles de données. Idéalement, nous -pré-traiterions probablement les données hors ligne, mais dans ce cas, chargeons 2 ensembles de -données et générons-en 2 autres.

-

Voici notre ancien code de chargement

-
loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
-  .then(parseData)
-  .then(addBoxes)
-  .then(render);
-
-

Changeons-le pour quelque chose comme ceci

-
async function loadData(info) {
-  const text = await loadFile(info.url);
-  info.file = parseData(text);
-}
-
-async function loadAll() {
-  const fileInfos = [
-    {name: 'men',   hueRange: [0.7, 0.3], url: 'resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc' },
-    {name: 'women', hueRange: [0.9, 1.1], url: 'resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014ft_2010_cntm_1_deg.asc' },
-  ];
-
-  await Promise.all(fileInfos.map(loadData));
-
-  ...
-}
-loadAll();
-
-

Le code ci-dessus chargera tous les fichiers dans fileInfos et une fois terminé, chaque objet -dans fileInfos aura une propriété file contenant le fichier chargé. name et hueRange -seront utilisés plus tard. name sera pour un champ d'interface utilisateur. hueRange sera utilisé pour -choisir une plage de teintes à appliquer.

-

Les deux fichiers ci-dessus sont apparemment le nombre d'hommes par zone et le nombre de -femmes par zone en 2010. Notez que je n'ai aucune idée si ces données sont correctes, mais -ce n'est pas vraiment important. L'important est de montrer différents ensembles -de données.

-

Générons 2 ensembles de données supplémentaires. L'un représentant les lieux où le nombre -d'hommes est supérieur au nombre de femmes, et inversement, les lieux où -le nombre de femmes est supérieur au nombre d'hommes.

-

La première chose, écrivons une fonction qui, étant donné un tableau bidimensionnel -de tableaux comme nous avions précédemment, va l'appliquer pour générer un nouveau tableau bidimensionnel -de tableaux.

-
function mapValues(data, fn) {
-  return data.map((row, rowNdx) => {
-    return row.map((value, colNdx) => {
-      return fn(value, rowNdx, colNdx);
-    });
-  });
-}
-
-

Comme la fonction normale Array.map, la fonction mapValues appelle une fonction -fn pour chaque valeur dans le tableau de tableaux. Elle lui passe la valeur ainsi que les -indices de ligne et de colonne.

-

Maintenant, écrivons du code pour générer un nouveau fichier qui est une comparaison entre 2 -fichiers.

-
function makeDiffFile(baseFile, otherFile, compareFn) {
-  let min;
-  let max;
-  const baseData = baseFile.data;
-  const otherData = otherFile.data;
-  const data = mapValues(baseData, (base, rowNdx, colNdx) => {
-    const other = otherData[rowNdx][colNdx];
-      if (base === undefined || other === undefined) {
-        return undefined;
-      }
-      const value = compareFn(base, other);
-      min = Math.min(min === undefined ? value : min, value);
-      max = Math.max(max === undefined ? value : max, value);
-      return value;
-  });
-  // make a copy of baseFile and replace min, max, and data
-  // with the new data
-  return {...baseFile, min, max, data};
-}
-
-

Le code ci-dessus utilise mapValues pour générer un nouvel ensemble de données qui est -une comparaison basée sur la fonction compareFn passée en paramètre. Il suit également -les résultats min et max de la comparaison. Enfin, il crée un nouveau fichier avec -toutes les mêmes propriétés que baseFile, sauf avec de nouvelles valeurs pour min, max et data.

-

Ensuite, utilisons cela pour créer 2 nouveaux ensembles de données.

-
{
-  const menInfo = fileInfos[0];
-  const womenInfo = fileInfos[1];
-  const menFile = menInfo.file;
-  const womenFile = womenInfo.file;
-
-  function amountGreaterThan(a, b) {
-    return Math.max(a - b, 0);
-  }
-  fileInfos.push({
-    name: '>50%men',
-    hueRange: [0.6, 1.1],
-    file: makeDiffFile(menFile, womenFile, (men, women) => {
-      return amountGreaterThan(men, women);
-    }),
-  });
-  fileInfos.push({
-    name: '>50% women',
-    hueRange: [0.0, 0.4],
-    file: makeDiffFile(womenFile, menFile, (women, men) => {
-      return amountGreaterThan(women, men);
-    }),
-  });
-}
-
-

Maintenant, générons une interface utilisateur pour sélectionner parmi ces ensembles de données. Nous avons d'abord besoin -d'un peu de HTML pour l'interface utilisateur.

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="ui"></div>
-</body>
-
-

et du CSS pour le faire apparaître en haut à gauche

-
#ui {
-  position: absolute;
-  left: 1em;
-  top: 1em;
-}
-#ui>div {
-  font-size: 20pt;
-  padding: 1em;
-  display: inline-block;
-}
-#ui>div.selected {
-  color: red;
-}
-
-

Ensuite, nous pouvons parcourir chaque fichier et générer un ensemble de boîtes fusionnées par -ensemble de données, ainsi qu'un élément qui, au survol, affichera cet ensemble -et masquera tous les autres.

-
// afficher les données sélectionnées, masquer les autres
-function showFileInfo(fileInfos, fileInfo) {
-  fileInfos.forEach((info) => {
-    const visible = fileInfo === info;
-    info.root.visible = visible;
-    info.elem.className = visible ? 'selected' : '';
-  });
-  requestRenderIfNotRequested();
-}
-
-const uiElem = document.querySelector('#ui');
-fileInfos.forEach((info) => {
-  const boxes = addBoxes(info.file, info.hueRange);
-  info.root = boxes;
-  const div = document.createElement('div');
-  info.elem = div;
-  div.textContent = info.name;
-  uiElem.appendChild(div);
-  div.addEventListener('mouseover', () => {
-    showFileInfo(fileInfos, info);
-  });
-});
-// afficher le premier ensemble de données
-showFileInfo(fileInfos, fileInfos[0]);
-
-

Une autre modification dont nous avons besoin par rapport à l'exemple précédent est de faire en sorte -que addBoxes accepte un hueRange.

-
-function addBoxes(file) {
-+function addBoxes(file, hueRange) {
-
-  ...
-
-    // calculer une couleur
--    const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
-+    const hue = THREE.MathUtils.lerp(...hueRange, amount);
-
-  ...
-
-

et avec cela, nous devrions pouvoir afficher 4 ensembles de données. Survolez les étiquettes -avec la souris ou touchez-les pour changer d'ensemble.

-

- -

-

Notez qu'il y a quelques points de données étranges qui ressortent vraiment. Je me demande ce qui se passe -avec ceux-là !??! Dans tous les cas, comment animer entre ces 4 ensembles de données.

-

Beaucoup d'idées.

-
    -
  • Faites simplement un fondu entre eux en utilisant Material.opacity

    -

    Le problème avec cette solution est que les cubes se superposent parfaitement, ce qui -entraînera des problèmes de z-fighting. Il est possible de résoudre -cela en changeant la fonction de profondeur et en utilisant le blending. Nous devrions -probablement examiner cette option.

    -
  • -
  • Agrandissez l'ensemble que nous voulons voir et réduisez les autres ensembles

    -

    Parce que toutes les boîtes ont leur origine au centre de la planète, -si nous les réduisons en dessous de 1.0, elles s'enfonceront dans la planète. Au début, cela -semble une bonne idée, mais le problème est que toutes les boîtes de faible hauteur -disparaîtront presque immédiatement et ne seront pas remplacées tant que le nouvel -ensemble de données n'aura pas atteint 1.0. Cela rend la transition peu agréable. -On pourrait peut-être résoudre cela avec un shader personnalisé sophistiqué.

    -
  • -
  • Utiliser les Morphtargets

    -

    Les Morphtargets sont un moyen de fournir plusieurs valeurs pour chaque sommet -de la géométrie et de les morpher ou de les interpoler linéairement (lerp). -Les Morphtargets sont le plus souvent utilisés pour l'animation faciale de personnages -3D, mais ce n'est pas leur seule utilisation.

    -
  • -
-

Essayons les morphtargets.

-

Nous créerons toujours une géométrie pour chaque ensemble de données, mais nous extrairons -ensuite l'attribut position de chacun et les utiliserons comme morphtargets.

-

Changeons d'abord addBoxes pour qu'elle crée et retourne simplement la géométrie fusionnée.

-
-function addBoxes(file) {
-+function makeBoxes(file, hueRange) {
-  const {min, max, data} = file;
-  const range = max - min;
-
-  ...
-
--  const mergedGeometry = BufferGeometryUtils.mergeGeometries(
--      geometries, false);
--  const material = new THREE.MeshBasicMaterial({
--    vertexColors: true,
--  });
--  const mesh = new THREE.Mesh(mergedGeometry, material);
--  scene.add(mesh);
--  return mesh;
-+  return BufferGeometryUtils.mergeGeometries(
-+     geometries, false);
-}
-
-

Il y a cependant une autre chose que nous devons faire ici. Les morphtargets doivent -tous avoir exactement le même nombre de sommets. Le sommet #123 dans une cible doit -avoir un sommet correspondant #123 dans toutes les autres cibles. Mais, tel que c'est actuellement, -différents ensembles de données pourraient avoir des points de données sans données, donc aucune boîte ne sera -générée pour ce point, ce qui signifierait l'absence de sommets correspondants pour un autre -ensemble. Nous devons donc vérifier tous les ensembles de données et soit toujours générer -quelque chose s'il y a des données dans n'importe quel ensemble, soit ne rien générer s'il -manque des données dans n'importe quel ensemble. Faisons le second cas.

-
+function dataMissingInAnySet(fileInfos, latNdx, lonNdx) {
-+  for (const fileInfo of fileInfos) {
-+    if (fileInfo.file.data[latNdx][lonNdx] === undefined) {
-+      return true;
-+    }
-+  }
-+  return false;
-+}
-
--function makeBoxes(file, hueRange) {
-+function makeBoxes(file, hueRange, fileInfos) {
-  const {min, max, data} = file;
-  const range = max - min;
-
-  ...
-
-  const geometries = [];
-  data.forEach((row, latNdx) => {
-    row.forEach((value, lonNdx) => {
-+      if (dataMissingInAnySet(fileInfos, latNdx, lonNdx)) {
-+        return;
-+      }
-      const amount = (value - min) / range;
-
-  ...
-
-

Maintenant, nous allons changer le code qui appelait addBoxes pour qu'il utilise makeBoxes -et configure les morphtargets.

-
+// créer la géométrie pour chaque ensemble de données
-+const geometries = fileInfos.map((info) => {
-+  return makeBoxes(info.file, info.hueRange, fileInfos);
-+});
-+
-+// utiliser la première géométrie comme base
-+// et ajouter toutes les géométries comme morphtargets
-+const baseGeometry = geometries[0];
-+baseGeometry.morphAttributes.position = geometries.map((geometry, ndx) => {
-+  const attribute = geometry.getAttribute('position');
-+  const name = `target${ndx}`;
-+  attribute.name = name;
-+  return attribute;
-+});
-+baseGeometry.morphAttributes.color = geometries.map((geometry, ndx) => {
-+  const attribute = geometry.getAttribute('color');
-+  const name = `target${ndx}`;
-+  attribute.name = name;
-+  return attribute;
-+});
-+const material = new THREE.MeshBasicMaterial({
-+  vertexColors: true,
-+});
-+const mesh = new THREE.Mesh(baseGeometry, material);
-+scene.add(mesh);
-
-const uiElem = document.querySelector('#ui');
-fileInfos.forEach((info) => {
--  const boxes = addBoxes(info.file, info.hueRange);
--  info.root = boxes;
-  const div = document.createElement('div');
-  info.elem = div;
-  div.textContent = info.name;
-  uiElem.appendChild(div);
-  function show() {
-    showFileInfo(fileInfos, info);
-  }
-  div.addEventListener('mouseover', show);
-  div.addEventListener('touchstart', show);
-});
-// afficher le premier ensemble de données
-showFileInfo(fileInfos, fileInfos[0]);
-
-

Ci-dessus, nous créons une géométrie pour chaque ensemble de données, utilisons la première comme base, -puis obtenons un attribut position de chaque géométrie et l'ajoutons comme -morphtarget à la géométrie de base pour position.

-

Maintenant, nous devons changer la manière dont nous affichons et masquons les différents ensembles de données. -Au lieu d'afficher ou de masquer un maillage, nous devons modifier l'influence des -morphtargets. Pour l'ensemble de données que nous voulons voir, nous devons avoir une influence de 1, -et pour tous ceux que nous ne voulons pas voir, nous devons avoir une influence de 0.

-

Nous pourrions simplement les régler directement à 0 ou 1, mais si nous faisions cela, nous ne verrions aucune -animation, cela se ferait instantanément, ce qui ne serait pas différent de ce que nous avons déjà. -Nous pourrions également écrire du code d'animation personnalisé, ce qui serait facile, -mais comme le globe webgl original utilise -une bibliothèque d'animation, utilisons la même ici.

-

Nous devons inclure la bibliothèque

-
import * as THREE from 'three';
-import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
-import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-+import TWEEN from 'three/addons/libs/tween.module.js';
-
-

Et ensuite, créer un Tween pour animer les influences.

-
// afficher les données sélectionnées, masquer les autres
-function showFileInfo(fileInfos, fileInfo) {
-+  const targets = {};
--  fileInfos.forEach((info) => {
-+  fileInfos.forEach((info, i) => {
-    const visible = fileInfo === info;
--    info.root.visible = visible;
-    info.elem.className = visible ? 'selected' : '';
-+    targets[i] = visible ? 1 : 0;
-  });
-+  const durationInMs = 1000;
-+  new TWEEN.Tween(mesh.morphTargetInfluences)
-+    .to(targets, durationInMs)
-+    .start();
-  requestRenderIfNotRequested();
-}
-
-

Nous sommes également censés appeler TWEEN.update à chaque image dans notre boucle de rendu, -mais cela soulève un problème. « tween.js » est conçu pour un rendu continu, -mais nous rendons à la demande. Nous pourrions -passer au rendu continu, mais il est parfois agréable de ne rendre qu'à la demande -car cela permet d'économiser l'énergie de l'utilisateur lorsque rien ne se passe, -alors voyons si nous pouvons le faire animer à la demande.

-

Nous allons créer un TweenManager pour nous aider. Nous l'utiliserons pour créer les Tweens -et les suivre. Il aura une méthode update qui retournera true -si nous devons l'appeler à nouveau, et false si toutes les animations sont terminées.

-
class TweenManger {
-  constructor() {
-    this.numTweensRunning = 0;
-  }
-  _handleComplete() {
-    --this.numTweensRunning;
-    console.assert(this.numTweensRunning >= 0);
-  }
-  createTween(targetObject) {
-    const self = this;
-    ++this.numTweensRunning;
-    let userCompleteFn = () => {};
-    // create a new tween and install our own onComplete callback
-    const tween = new TWEEN.Tween(targetObject).onComplete(function(...args) {
-      self._handleComplete();
-      userCompleteFn.call(this, ...args);
-    });
-    // replace the tween's onComplete function with our own
-    // so we can call the user's callback if they supply one.
-    tween.onComplete = (fn) => {
-      userCompleteFn = fn;
-      return tween;
-    };
-    return tween;
-  }
-  update() {
-    TWEEN.update();
-    return this.numTweensRunning > 0;
-  }
-}
-
-

Pour l'utiliser, nous allons en créer un

-
function main() {
-  const canvas = document.querySelector('#c');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+  const tweenManager = new TweenManger();
-
-  ...
-
-

Nous l'utiliserons pour créer nos Tweens.

-
// afficher les données sélectionnées, masquer les autres
-function showFileInfo(fileInfos, fileInfo) {
-  const targets = {};
-  fileInfos.forEach((info, i) => {
-    const visible = fileInfo === info;
-    info.elem.className = visible ? 'selected' : '';
-    targets[i] = visible ? 1 : 0;
-  });
-  const durationInMs = 1000;
--  new TWEEN.Tween(mesh.morphTargetInfluences)
-+  tweenManager.createTween(mesh.morphTargetInfluences)
-    .to(targets, durationInMs)
-    .start();
-  requestRenderIfNotRequested();
-}
-
-

Ensuite, nous mettrons à jour notre boucle de rendu pour mettre à jour les tweens et continuer à rendre -s'il y a encore des animations en cours.

-
function render() {
-  renderRequested = false;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-+  if (tweenManager.update()) {
-+    requestRenderIfNotRequested();
-+  }
-
-  controls.update();
-  renderer.render(scene, camera);
-}
-render();
-
-

Et avec cela, nous devrions pouvoir animer entre les ensembles de données.

-

- -

-

J'espère que parcourir ceci a été utile. L'utilisation des morphtargets est une technique courante pour -déplacer de nombreux objets. Par exemple, nous pourrions donner à chaque cube un endroit aléatoire -dans une autre cible et morpher de là à leurs premières positions sur le globe. -Cela pourrait être une façon intéressante de présenter le globe.

-

Ensuite, vous pourriez être intéressé par l'ajout d'étiquettes à un globe, ce qui est abordé -dans Aligner les éléments HTML sur la 3D.

-

Note : Nous pourrions essayer de simplement représenter le pourcentage d'hommes ou de femmes, ou la différence -brute, mais compte tenu de la manière dont nous affichons les informations, des cubes qui poussent -depuis la surface de la terre, nous préférerions que la plupart des cubes soient bas. Si nous -utilisions l'une de ces autres comparaisons, la plupart des cubes auraient environ la moitié -de leur hauteur maximale, ce qui ne donnerait pas une bonne visualisation. N'hésitez pas -à changer amountGreaterThan de Math.max(a - b, 0) à quelque chose comme (a - b) -« différence brute » ou a / (a + b) « pourcentage » et vous verrez ce que je veux dire.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/optimize-lots-of-objects.html b/manual/fr/optimize-lots-of-objects.html deleted file mode 100644 index 99a40b4e78bfcd..00000000000000 --- a/manual/fr/optimize-lots-of-objects.html +++ /dev/null @@ -1,511 +0,0 @@ - - - Optimiser Beaucoup d'Objets - - - - - - - - - - - - - -
-
-

Optimiser Beaucoup d'Objets

-
-
-
-

Cet article fait partie d'une série d'articles sur three.js. Le premier article -est les bases de three.js. Si vous ne l'avez pas encore lu -et que vous débutez avec three.js, vous pourriez vouloir commencer par là.

-

Il existe de nombreuses façons d'optimiser les choses pour three.js. Une méthode est souvent appelée -fusion de géométrie. Chaque Mesh que vous créez et que three.js représente est 1 ou -plusieurs requêtes du système pour dessiner quelque chose. Dessiner 2 choses a plus -de surcoût que d'en dessiner 1, même si les résultats sont les mêmes, donc une façon d'optimiser -est de fusionner les maillages (meshes).

-

Voyons un exemple où cela est une bonne solution pour un problème. -Recréons le Globe WebGL.

-

La première chose à faire est d'obtenir des données. Le Globe WebGL a dit que les données -qu'ils utilisent proviennent de SEDAC. En consultant -le site, j'ai vu qu'il y avait des données démographiques au format grille. -J'ai téléchargé les données avec une résolution de 60 minutes. Ensuite, j'ai examiné les données

-

Cela ressemble à ceci

-
 ncols         360
- nrows         145
- xllcorner     -180
- yllcorner     -60
- cellsize      0.99999999999994
- NODATA_value  -9999
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- 9.241768 8.790958 2.095345 -9999 0.05114867 -9999 -9999 -9999 -9999 -999...
- 1.287993 0.4395509 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
-
-

Il y a quelques lignes qui sont comme des paires clé/valeur suivies de lignes avec une valeur -par point de grille, une ligne pour chaque rangée de points de données.

-

Pour nous assurer que nous comprenons les données, essayons de les tracer en 2D.

-

D'abord un peu de code pour charger le fichier texte

-
async function loadFile(url) {
-  const res = await fetch(url);
-  return res.text();
-}
-
-

Le code ci-dessus renvoie une Promise avec le contenu du fichier à l'url ;

-

Ensuite, nous avons besoin de code pour analyser le fichier

-
function parseData(text) {
-  const data = [];
-  const settings = {data};
-  let max;
-  let min;
-  // split into lines
-  text.split('\n').forEach((line) => {
-    // split the line by whitespace
-    const parts = line.trim().split(/\s+/);
-    if (parts.length === 2) {
-      // only 2 parts, must be a key/value pair
-      settings[parts[0]] = parseFloat(parts[1]);
-    } else if (parts.length > 2) {
-      // more than 2 parts, must be data
-      const values = parts.map((v) => {
-        const value = parseFloat(v);
-        if (value === settings.NODATA_value) {
-          return undefined;
-        }
-        max = Math.max(max === undefined ? value : max, value);
-        min = Math.min(min === undefined ? value : min, value);
-        return value;
-      });
-      data.push(values);
-    }
-  });
-  return Object.assign(settings, {min, max});
-}
-
-

Le code ci-dessus renvoie un objet avec toutes les paires clé/valeur du fichier ainsi -qu'une propriété data contenant toutes les données dans un grand tableau et les valeurs min et -max trouvées dans les données.

-

Ensuite, nous avons besoin de code pour dessiner ces données

-
function drawData(file) {
-  const {min, max, data} = file;
-  const range = max - min;
-  const ctx = document.querySelector('canvas').getContext('2d');
-  // make the canvas the same size as the data
-  ctx.canvas.width = ncols;
-  ctx.canvas.height = nrows;
-  // but display it double size so it's not too small
-  ctx.canvas.style.width = px(ncols * 2);
-  ctx.canvas.style.height = px(nrows * 2);
-  // fill the canvas to dark gray
-  ctx.fillStyle = '#444';
-  ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
-  // draw each data point
-  data.forEach((row, latNdx) => {
-    row.forEach((value, lonNdx) => {
-      if (value === undefined) {
-        return;
-      }
-      const amount = (value - min) / range;
-      const hue = 1;
-      const saturation = 1;
-      const lightness = amount;
-      ctx.fillStyle = hsl(hue, saturation, lightness);
-      ctx.fillRect(lonNdx, latNdx, 1, 1);
-    });
-  });
-}
-
-function px(v) {
-  return `${v | 0}px`;
-}
-
-function hsl(h, s, l) {
-  return `hsl(${h * 360 | 0},${s * 100 | 0}%,${l * 100 | 0}%)`;
-}
-
-

Et enfin, en liant le tout

-
loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
-  .then(parseData)
-  .then(drawData);
-
-

Nous donne ce résultat

-

- -

-

Donc, cela semble fonctionner.

-

Essayons-le en 3D. En partant du code de rendu à la -demande, nous allons créer une boîte par donnée dans -le fichier.

-

Commençons par créer une simple sphère avec une texture du monde. Voici la texture

-
- -

Et le code pour le mettre en place.

-
{
-  const loader = new THREE.TextureLoader();
-  const texture = loader.load('resources/images/world.jpg', render);
-  const geometry = new THREE.SphereGeometry(1, 64, 32);
-  const material = new THREE.MeshBasicMaterial({map: texture});
-  scene.add(new THREE.Mesh(geometry, material));
-}
-
-

Notez l'appel à render lorsque la texture a fini de charger. Nous en avons besoin -car nous faisons du rendu à la demande au lieu de le faire en -continu, nous devons donc rendre la scène une fois que la texture est chargée.

-

Ensuite, nous devons modifier le code qui dessinait un point par point de donnée ci-dessus pour -créer une boîte par point de donnée à la place.

-
function addBoxes(file) {
-  const {min, max, data} = file;
-  const range = max - min;
-
-  // make one box geometry
-  const boxWidth = 1;
-  const boxHeight = 1;
-  const boxDepth = 1;
-  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-  // make it so it scales away from the positive Z axis
-  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));
-
-  // these helpers will make it easy to position the boxes
-  // We can rotate the lon helper on its Y axis to the longitude
-  const lonHelper = new THREE.Object3D();
-  scene.add(lonHelper);
-  // We rotate the latHelper on its X axis to the latitude
-  const latHelper = new THREE.Object3D();
-  lonHelper.add(latHelper);
-  // The position helper moves the object to the edge of the sphere
-  const positionHelper = new THREE.Object3D();
-  positionHelper.position.z = 1;
-  latHelper.add(positionHelper);
-
-  const lonFudge = Math.PI * .5;
-  const latFudge = Math.PI * -0.135;
-  data.forEach((row, latNdx) => {
-    row.forEach((value, lonNdx) => {
-      if (value === undefined) {
-        return;
-      }
-      const amount = (value - min) / range;
-      const material = new THREE.MeshBasicMaterial();
-      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
-      const saturation = 1;
-      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
-      material.color.setHSL(hue, saturation, lightness);
-      const mesh = new THREE.Mesh(geometry, material);
-      scene.add(mesh);
-
-      // adjust the helpers to point to the latitude and longitude
-      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
-      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
-
-      // use the world matrix of the position helper to
-      // position this mesh.
-      positionHelper.updateWorldMatrix(true, false);
-      mesh.applyMatrix4(positionHelper.matrixWorld);
-
-      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
-    });
-  });
-}
-
-

Le code est principalement direct par rapport à notre code de dessin de test.

-

Nous créons une boîte et ajustons son centre de manière à ce qu'elle s'éloigne de l'axe Z positif. Si nous -ne faisions pas cela, elle s'agrandirait à partir du centre, mais nous voulons qu'elles s'éloignent de l'origine.

-
-
-
-
par défaut
-
-
-
-
ajusté
-
-
- -

Bien sûr, nous pourrions aussi résoudre cela en faisant de la boîte un enfant d'autres objets THREE.Object3D -comme nous l'avons vu dans les graphes de scène, mais plus nous ajoutons de -nœuds à un graphe de scène, plus cela devient lent.

-

Nous avons également mis en place cette petite hiérarchie de nœuds : lonHelper, latHelper, et -positionHelper. Nous utilisons ces objets pour calculer une position autour de la sphère -où placer la boîte.

-
-
-
- -

Ci-dessus, la barre verte représente lonHelper et -est utilisée pour pivoter vers la longitude sur l'équateur. La -barre bleue représente latHelper qui est utilisée pour pivoter vers une -latitude au-dessus ou en dessous de l'équateur. La sphère -rouge représente le décalage que fournit positionHelper.

-

Nous pourrions faire tous les calculs manuellement pour déterminer les positions sur le globe, mais -faire les choses de cette manière laisse la majeure partie des calculs à la librairie elle-même, de sorte que nous -n'avons pas à nous en occuper.

-

Pour chaque point de donnée, nous créons un MeshBasicMaterial et un Mesh et -puis nous demandons la matrice monde du positionHelper et l'appliquons au nouveau Mesh. -Enfin, nous mettons à l'échelle le maillage à sa nouvelle position.

-

Comme ci-dessus, nous aurions pu également créer un latHelper, un lonHelper et un -positionHelper pour chaque nouvelle boîte, mais cela aurait été encore plus lent.

-

Il y a jusqu'à 360x145 boîtes que nous allons créer. Cela représente jusqu'à 52000 boîtes. -Comme certains points de données sont marqués comme "NO_DATA", le nombre réel de boîtes -que nous allons créer est d'environ 19000. Si nous ajoutions 3 objets d'aide supplémentaires par boîte, -cela représenterait près de 80000 nœuds dans le graphe de scène pour lesquels THREE.js devrait -calculer les positions. En utilisant plutôt un seul ensemble d'aides pour positionner -simplement les maillages, nous économisons environ 60000 opérations.

-

Une note sur lonFudge et latFudge. lonFudge est π/2, ce qui correspond à un quart de tour. -Cela a du sens. Cela signifie simplement que la texture ou les coordonnées de texture commencent à un -décalage différent autour du globe. latFudge, d'un autre côté, je n'ai aucune idée -pourquoi il doit être π * -0.135, c'est juste un montant qui a permis aux boîtes de s'aligner -avec la texture.

-

La dernière chose à faire est d'appeler notre chargeur

-
loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
-  .then(parseData)
--  .then(drawData)
-+  .then(addBoxes)
-+  .then(render);
-

Une fois que les données ont fini de charger et d'être analysées, nous devons rendre la scène au moins -une fois puisque nous faisons du rendu à la demande.

-

- -

-

Si vous essayez de faire pivoter l'exemple ci-dessus en faisant glisser la souris sur l'échantillon, vous -remarquerez probablement que c'est lent.

-

Nous pouvons vérifier la fréquence d'images en ouvrant les -outils de développement et en activant l'indicateur de fréquence d'images -du navigateur.

-
- -

Sur ma machine, je vois une fréquence d'images inférieure à 20 ips.

-
- -

Cela ne me semble pas très fluide et je suspecte que beaucoup de gens ont des machines -plus lentes, ce qui rendrait la situation encore pire. Nous ferions mieux d'étudier l'optimisation.

-

Pour ce problème particulier, nous pouvons fusionner toutes les boîtes en une seule géométrie. -Nous dessinons actuellement environ 19000 boîtes. En les fusionnant en une seule -géométrie, nous supprimerions 18999 opérations.

-

Voici le nouveau code pour fusionner les boîtes en une seule géométrie.

-
function addBoxes(file) {
-  const {min, max, data} = file;
-  const range = max - min;
-
--  // make one box geometry
--  const boxWidth = 1;
--  const boxHeight = 1;
--  const boxDepth = 1;
--  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
--  // make it so it scales away from the positive Z axis
--  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));
-
-  // these helpers will make it easy to position the boxes
-  // We can rotate the lon helper on its Y axis to the longitude
-  const lonHelper = new THREE.Object3D();
-  scene.add(lonHelper);
-  // We rotate the latHelper on its X axis to the latitude
-  const latHelper = new THREE.Object3D();
-  lonHelper.add(latHelper);
-  // The position helper moves the object to the edge of the sphere
-  const positionHelper = new THREE.Object3D();
-  positionHelper.position.z = 1;
-  latHelper.add(positionHelper);
-+  // Utilisé pour déplacer le centre de la boîte afin qu'elle s'agrandisse à partir de l'axe Z positif
-+  const originHelper = new THREE.Object3D();
-+  originHelper.position.z = 0.5;
-+  positionHelper.add(originHelper);
-
-  const lonFudge = Math.PI * .5;
-  const latFudge = Math.PI * -0.135;
-+  const geometries = [];
-  data.forEach((row, latNdx) => {
-    row.forEach((value, lonNdx) => {
-      if (value === undefined) {
-        return;
-      }
-      const amount = (value - min) / range;
-
--      const material = new THREE.MeshBasicMaterial();
--      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
--      const saturation = 1;
--      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
--      material.color.setHSL(hue, saturation, lightness);
--      const mesh = new THREE.Mesh(geometry, material);
--      scene.add(mesh);
-
-+      const boxWidth = 1;
-+      const boxHeight = 1;
-+      const boxDepth = 1;
-+      const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-
-      // adjust the helpers to point to the latitude and longitude
-      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
-      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
-
--      // use the world matrix of the position helper to
--      // position this mesh.
--      positionHelper.updateWorldMatrix(true, false);
--      mesh.applyMatrix4(positionHelper.matrixWorld);
--
--      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
-
-+      // use the world matrix of the origin helper to
-+      // position this geometry
-+      positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
-+      originHelper.updateWorldMatrix(true, false);
-+      geometry.applyMatrix4(originHelper.matrixWorld);
-+
-+      geometries.push(geometry);
-    });
-  });
-
-+  const mergedGeometry = BufferGeometryUtils.mergeGeometries(
-+      geometries, false);
-+  const material = new THREE.MeshBasicMaterial({color:'red'});
-+  const mesh = new THREE.Mesh(mergedGeometry, material);
-+  scene.add(mesh);
-
-}
-
-

Ci-dessus, nous avons supprimé le code qui modifiait le point central de la géométrie de la boîte -et le faisons à la place en ajoutant un originHelper. Auparavant, nous utilisions la même -géométrie 19000 fois. Cette fois, nous créons une nouvelle géométrie pour chaque boîte et -comme nous allons utiliser applyMatrix pour déplacer les sommets de chaque géométrie de boîte, -autant le faire une fois au lieu de deux.

-

À la fin, nous passons un tableau de toutes les géométries à -BufferGeometryUtils.mergeGeometries, ce qui les combinera toutes -en un seul maillage.

-

Nous devons également inclure le BufferGeometryUtils

-
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
-
-

Et maintenant, du moins sur ma machine, j'obtiens 60 images par seconde

-

- -

-

Cela a donc fonctionné, mais comme il s'agit d'un seul maillage, nous n'obtenons qu'un seul matériau, ce qui -signifie que nous n'avons qu'une seule couleur, alors qu'avant, nous avions une couleur différente sur chaque boîte. Nous pouvons -y remédier en utilisant les couleurs de sommet.

-

Les couleurs de sommet ajoutent une couleur par sommet. En réglant toutes les couleurs de chaque sommet -de chaque boîte sur des couleurs spécifiques, chaque boîte aura une couleur différente.

-
+const color = new THREE.Color();
-
-const lonFudge = Math.PI * .5;
-const latFudge = Math.PI * -0.135;
-const geometries = [];
-data.forEach((row, latNdx) => {
-  row.forEach((value, lonNdx) => {
-    if (value === undefined) {
-      return;
-    }
-    const amount = (value - min) / range;
-
-    const boxWidth = 1;
-    const boxHeight = 1;
-    const boxDepth = 1;
-    const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-
-    // adjust the helpers to point to the latitude and longitude
-    lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
-    latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
-
-    // use the world matrix of the origin helper to
-    // position this geometry
-    positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
-    originHelper.updateWorldMatrix(true, false);
-    geometry.applyMatrix4(originHelper.matrixWorld);
-
-+    // calculer une couleur
-+    const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
-+    const saturation = 1;
-+    const lightness = THREE.MathUtils.lerp(0.4, 1.0, amount);
-+    color.setHSL(hue, saturation, lightness);
-+    // obtenir les couleurs sous forme de tableau de valeurs de 0 à 255
-+    const rgb = color.toArray().map(v => v * 255);
-+
-+    // créer un tableau pour stocker les couleurs pour chaque sommet
-+    const numVerts = geometry.getAttribute('position').count;
-+    const itemSize = 3;  // r, g, b
-+    const colors = new Uint8Array(itemSize * numVerts);
-+
-+    // copier la couleur dans le tableau de couleurs pour chaque sommet
-+    colors.forEach((v, ndx) => {
-+      colors[ndx] = rgb[ndx % 3];
-+    });
-+
-+    const normalized = true;
-+    const colorAttrib = new THREE.BufferAttribute(colors, itemSize, normalized);
-+    geometry.setAttribute('color', colorAttrib);
-
-    geometries.push(geometry);
-  });
-});
-
-

Le code ci-dessus recherche le nombre ou les sommets nécessaires en obtenant l'attribut position -de la géométrie. Nous créons ensuite un Uint8Array pour y mettre les couleurs. -Il ajoute ensuite cela comme un attribut en appelant geometry.setAttribute.

-

Enfin, nous devons dire à three.js d'utiliser les couleurs de sommet.

-
const mergedGeometry = BufferGeometryUtils.mergeGeometries(
-    geometries, false);
--const material = new THREE.MeshBasicMaterial({color:'red'});
-+const material = new THREE.MeshBasicMaterial({
-+  vertexColors: true,
-+});
-const mesh = new THREE.Mesh(mergedGeometry, material);
-scene.add(mesh);
-
-

Et avec cela, nous retrouvons nos couleurs

-

- -

-

La fusion de géométrie est une technique d'optimisation courante. Par exemple, au lieu de -100 arbres, vous pourriez fusionner les arbres en 1 seule géométrie, un tas de roches individuelles -en une seule géométrie de roches, une clôture de piquets individuels en un seul maillage de clôture. -Un autre exemple dans Minecraft, il ne dessine probablement pas chaque cube individuellement, -mais crée plutôt des groupes de cubes fusionnés et supprime également sélectivement les faces qui ne -sont jamais visibles.

-

Le problème avec le fait de tout transformer en un seul maillage est qu'il n'est plus facile -de déplacer une partie qui était auparavant séparée. Cependant, selon notre cas d'utilisation, -il existe des solutions créatives. Nous en explorerons une dans -un autre article.

-

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/picking.html b/manual/fr/picking.html deleted file mode 100644 index eef73d366dc3fc..00000000000000 --- a/manual/fr/picking.html +++ /dev/null @@ -1,395 +0,0 @@ - - - Sélection - - - - - - - - - - - - - -
-
-

Sélection

-
-
-
-

La sélection (picking) désigne le processus qui consiste à déterminer sur quel objet un utilisateur a cliqué ou touché. Il existe de nombreuses façons d'implémenter la sélection, chacune ayant ses avantages et ses inconvénients. Passons en revue les 2 méthodes les plus courantes.

-

La méthode de sélection (picking) probablement la plus courante est le lancé de rayon (raycasting), ce qui signifie lancer un rayon à partir de la souris à travers le frustum (volume de visualisation) de la scène et calculer les objets que ce rayon intersecte. Conceptuellement, c'est très simple.

-

D'abord, nous prendrions la position de la souris. Nous la convertirions en espace monde en appliquant la projection et l'orientation de la caméra. Nous calculerions un rayon allant du plan proche du frustum de la caméra au plan éloigné. Ensuite, pour chaque triangle de chaque objet dans la scène, nous vérifierions si ce rayon intersecte ce triangle. Si votre scène contient 1000 objets et que chaque objet a 1000 triangles, alors 1 million de triangles devront être vérifiés.

-

Quelques optimisations incluraient de vérifier d'abord si le rayon intersecte la sphère englobante (bounding sphere) ou la boîte englobante (bounding box) d'un objet, c'est-à-dire la sphère ou la boîte qui contient l'objet entier. Si le rayon n'intersecte pas l'une d'elles, nous n'avons pas besoin de vérifier les triangles de cet objet.

-

THREE.js fournit une classe RayCaster qui fait exactement cela.

-

Créons une scène avec 100 objets et essayons de les sélectionner. Nous commencerons avec un exemple tiré de l'article sur les pages responsives

-

Quelques changements

-

Nous allons faire de la caméra l'enfant d'un autre objet afin que nous puissions faire tourner cet autre objet et que la caméra se déplace autour de la scène comme un perche à selfie.

-
*const fov = 60;
-const aspect = 2;  // L'aspect par défaut du canvas
-const near = 0.1;
-*const far = 200;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-*camera.position.z = 30;
-
-const scene = new THREE.Scene();
-+scene.background = new THREE.Color('white');
-
-+// Place la caméra sur un poteau (la rend enfant d'un objet)
-+// afin que nous puissions faire tourner le poteau pour déplacer la caméra autour de la scène
-+const cameraPole = new THREE.Object3D();
-+scene.add(cameraPole);
-+cameraPole.add(camera);
-
-

et dans la fonction render, nous ferons tourner le poteau de la caméra.

-
cameraPole.rotation.y = time * .1;
-
-

Mettons aussi la lumière sur la caméra pour qu'elle bouge avec elle.

-
-scene.add(light);
-+camera.add(light);
-
-

Générons 100 cubes avec des couleurs aléatoires dans des positions, orientations et échelles aléatoires.

-
const boxWidth = 1;
-const boxHeight = 1;
-const boxDepth = 1;
-const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-
-function rand(min, max) {
-  if (max === undefined) {
-    max = min;
-    min = 0;
-  }
-  return min + (max - min) * Math.random();
-}
-
-function randomColor() {
-  return `hsl(${rand(360) | 0}, ${rand(50, 100) | 0}%, 50%)`;
-}
-
-const numObjects = 100;
-for (let i = 0; i < numObjects; ++i) {
-  const material = new THREE.MeshPhongMaterial({
-    color: randomColor(),
-  });
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  cube.position.set(rand(-20, 20), rand(-20, 20), rand(-20, 20));
-  cube.rotation.set(rand(Math.PI), rand(Math.PI), 0);
-  cube.scale.set(rand(3, 6), rand(3, 6), rand(3, 6));
-}
-
-

Et enfin, effectuons la sélection.

-

Créons une classe simple pour gérer la sélection

-
class PickHelper {
-  constructor() {
-    this.raycaster = new THREE.Raycaster();
-    this.pickedObject = null;
-    this.pickedObjectSavedColor = 0;
-  }
-  pick(normalizedPosition, scene, camera, time) {
-    // Rétablit la couleur s'il y a un objet sélectionné
-    if (this.pickedObject) {
-      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
-      this.pickedObject = undefined;
-    }
-
-    // Lance un rayon à travers le frustum
-    this.raycaster.setFromCamera(normalizedPosition, camera);
-    // Obtient la liste des objets intersectés par le rayon
-    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
-    if (intersectedObjects.length) {
-      // Sélectionne le premier objet. C'est le plus proche
-      this.pickedObject = intersectedObjects[0].object;
-      // Sauvegarde sa couleur
-      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
-      // Définit sa couleur émissive sur un rouge/jaune clignotant
-      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
-    }
-  }
-}
-
-

Vous pouvez voir que nous créons un RayCaster et que nous pouvons ensuite appeler la fonction pick pour lancer un rayon à travers la scène. Si le rayon touche quelque chose, nous changeons la couleur du premier objet qu'il touche.

-

Bien sûr, nous pourrions appeler cette fonction uniquement lorsque l'utilisateur appuie sur le bouton de la souris (mouse down), ce qui est probablement ce que vous voulez généralement, mais pour cet exemple, nous sélectionnerons à chaque image ce qui se trouve sous la souris. Pour ce faire, nous devons d'abord suivre la position de la souris.

-
const pickPosition = {x: 0, y: 0};
-clearPickPosition();
-
-...
-
-function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function setPickPosition(event) {
-  const pos = getCanvasRelativePosition(event);
-  pickPosition.x = (pos.x / canvas.width ) *  2 - 1;
-  pickPosition.y = (pos.y / canvas.height) * -2 + 1;  // Note : on inverse Y
-}
-
-function clearPickPosition() {
-  // Contrairement à la souris qui a toujours une position
-  // si l'utilisateur arrête de toucher l'écran, nous voulons
-  // arrêter la sélection. Pour l'instant, nous choisissons simplement une valeur
-  // peu susceptible de sélectionner quelque chose
-  pickPosition.x = -100000;
-  pickPosition.y = -100000;
-}
-
-window.addEventListener('mousemove', setPickPosition);
-window.addEventListener('mouseout', clearPickPosition);
-window.addEventListener('mouseleave', clearPickPosition);
-
-

Remarquez que nous enregistrons une position de souris normalisée. Indépendamment de la taille du canvas, nous avons besoin d'une valeur qui va de -1 à gauche à +1 à droite. De même, nous avons besoin d'une valeur qui va de -1 en bas à +1 en haut.

-

Pendant que nous y sommes, prenons également en charge les appareils mobiles.

-
window.addEventListener('touchstart', (event) => {
-  // Empêche le défilement de la fenêtre
-  event.preventDefault();
-  setPickPosition(event.touches[0]);
-}, {passive: false});
-
-window.addEventListener('touchmove', (event) => {
-  setPickPosition(event.touches[0]);
-});
-
-window.addEventListener('touchend', clearPickPosition);
-
-

Et enfin, dans notre fonction render, nous appelons la fonction pick de PickHelper.

-
+const pickHelper = new PickHelper();
-
-function render(time) {
-  time *= 0.001;  // Convertit en secondes ;
-
-  ...
-
-+  pickHelper.pick(pickPosition, scene, camera, time);
-
-  renderer.render(scene, camera);
-
-  ...
-
-

et voici le résultat

-

- -

-

Cela semble fonctionner parfaitement et c'est probablement le cas pour de nombreuses utilisations, mais il y a plusieurs problèmes.

-
    -
  1. C'est basé sur le CPU.

    -

    JavaScript parcourt chaque objet et vérifie si le rayon intersecte la boîte ou la sphère englobante de cet objet. Si c'est le cas, JavaScript doit parcourir chaque triangle de cet objet et vérifier si le rayon intersecte le triangle.

    -

    Le bon côté de cela est que JavaScript peut facilement calculer exactement où le rayon a intersecté le triangle et nous fournir ces données. Par exemple, si vous vouliez placer un marqueur là où l'intersection s'est produite.

    -

    Le mauvais côté est que cela représente beaucoup de travail pour le CPU. Si vous avez des objets avec beaucoup de triangles, cela pourrait être lent.

    -
  2. -
  3. Cela ne gère pas les shaders étranges ou les déplacements.

    -

    Si vous avez un shader qui déforme ou morph le géométrie, JavaScript n'a aucune connaissance de cette déformation et donnera donc la mauvaise réponse. Par exemple, à ma connaissance, vous ne pouvez pas utiliser cette méthode avec des objets skinnés (avec animation par squelette).

    -
  4. -
  5. Cela ne gère pas les trous transparents.

    -
  6. -
-

À titre d'exemple, appliquons cette texture aux cubes.

-
- -

Nous allons juste apporter ces modifications

-
+const loader = new THREE.TextureLoader();
-+const texture = loader.load('resources/images/frame.png');
-
-const numObjects = 100;
-for (let i = 0; i < numObjects; ++i) {
-  const material = new THREE.MeshPhongMaterial({
-    color: randomColor(),
-    +map: texture,
-    +transparent: true,
-    +side: THREE.DoubleSide,
-    +alphaTest: 0.1,
-  });
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  ...
-
-

Et en exécutant cela, vous devriez rapidement voir le problème

-

- -

-

Essayez de sélectionner quelque chose à travers une boîte et vous ne le pouvez pas

-
- -

C'est parce que JavaScript ne peut pas facilement examiner les textures et les matériaux et déterminer si une partie de votre objet est réellement transparente ou non.

-

Une solution à tous ces problèmes est d'utiliser la sélection basée sur le GPU. Malheureusement, bien que conceptuellement simple, elle est plus compliquée à utiliser que la méthode de lancé de rayon ci-dessus.

-

Pour faire de la sélection par GPU, nous rendons chaque objet dans une couleur unique hors écran. Nous consultons ensuite la couleur du pixel correspondant à la position de la souris. La couleur nous indique quel objet a été sélectionné.

-

Cela peut résoudre les problèmes 2 et 3 ci-dessus. Quant au problème 1, la vitesse, cela dépend vraiment. Chaque objet doit être dessiné deux fois. Une fois pour l'affichage normal et encore pour la sélection. Il est possible avec des solutions plus sophistiquées que les deux puissent être faites en même temps, mais nous n'allons pas essayer cela.

-

Une chose que nous pouvons faire, cependant, puisque nous ne lirons qu'un seul pixel, est de configurer la caméra de manière à ce que seul ce pixel soit dessiné. Nous pouvons le faire en utilisant PerspectiveCamera.setViewOffset qui nous permet de dire à THREE.js de calculer une caméra qui rend juste une partie plus petite d'un rectangle plus grand. Cela devrait faire gagner du temps.

-

Pour effectuer ce type de sélection dans THREE.js à l'heure actuelle, il faut créer 2 scènes. L'une que nous remplirons avec nos maillages normaux. L'autre que nous remplirons avec des maillages qui utilisent notre matériau de sélection.

-

Donc, d'abord, créez une deuxième scène et assurez-vous qu'elle se vide en noir.

-
const scene = new THREE.Scene();
-scene.background = new THREE.Color('white');
-const pickingScene = new THREE.Scene();
-pickingScene.background = new THREE.Color(0);
-
-

Ensuite, pour chaque cube que nous plaçons dans la scène principale, nous créons un "cube de sélection" correspondant à la même position que le cube original, le plaçons dans la pickingScene, et définissons son matériau de manière à dessiner l'identifiant de l'objet comme sa couleur. Nous conservons également une carte des identifiants vers les objets afin que lorsque nous recherchons un identifiant plus tard, nous puissions le mapper à l'objet correspondant.

-
const idToObject = {};
-+const numObjects = 100;
-for (let i = 0; i < numObjects; ++i) {
-+  const id = i + 1;
-  const material = new THREE.MeshPhongMaterial({
-    color: randomColor(),
-    map: texture,
-    transparent: true,
-    side: THREE.DoubleSide,
-    alphaTest: 0.1,
-  });
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-+  idToObject[id] = cube;
-
-  cube.position.set(rand(-20, 20), rand(-20, 20), rand(-20, 20));
-  cube.rotation.set(rand(Math.PI), rand(Math.PI), 0);
-  cube.scale.set(rand(3, 6), rand(3, 6), rand(3, 6));
-
-+  const pickingMaterial = new THREE.MeshPhongMaterial({
-+    emissive: new THREE.Color().setHex(id, THREE.NoColorSpace),
-+    color: new THREE.Color(0, 0, 0),
-+    specular: new THREE.Color(0, 0, 0),
-+    map: texture,
-+    transparent: true,
-+    side: THREE.DoubleSide,
-+    alphaTest: 0.5,
-+    blending: THREE.NoBlending,
-+  });
-+  const pickingCube = new THREE.Mesh(geometry, pickingMaterial);
-+  pickingScene.add(pickingCube);
-+  pickingCube.position.copy(cube.position);
-+  pickingCube.rotation.copy(cube.rotation);
-+  pickingCube.scale.copy(cube.scale);
-}
-
-

Notez que nous faisons un usage "abusif" du MeshPhongMaterial ici. En définissant son emissive sur notre identifiant et les attributs color et specular à 0, cela finira par rendre l'identifiant uniquement là où l'alpha de la texture est supérieur à alphaTest. Nous devons également définir blending à NoBlending afin que l'identifiant ne soit pas multiplié par l'alpha.

-

Notez que l'abus du MeshPhongMaterial pourrait ne pas être la meilleure solution car il calculera toujours toutes nos lumières lors du dessin de la scène de sélection, même si nous n'avons pas besoin de ces calculs. Une solution plus optimisée créerait un shader personnalisé qui écrit simplement l'identifiant là où l'alpha de la texture est supérieur à alphaTest.

-

Comme nous sélectionnons à partir de pixels au lieu de lancer des rayons, nous pouvons modifier le code qui définit la position de sélection pour utiliser simplement des pixels.

-
function setPickPosition(event) {
-  const pos = getCanvasRelativePosition(event);
--  pickPosition.x = (pos.x / canvas.clientWidth ) *  2 - 1;
--  pickPosition.y = (pos.y / canvas.clientHeight) * -2 + 1;  // Note : on inverse Y
-+  pickPosition.x = pos.x;
-+  pickPosition.y = pos.y;
-}
-
-

Ensuite, changeons la classe PickHelper en GPUPickHelper. Elle utilisera un WebGLRenderTarget comme nous l'avons vu dans l'article sur les render targets. Notre render target ici ne fait qu'un seul pixel, 1x1.

-
-class PickHelper {
-+class GPUPickHelper {
-  constructor() {
--    this.raycaster = new THREE.Raycaster();
-+    // Crée un render target de 1x1 pixel
-+    this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
-+    this.pixelBuffer = new Uint8Array(4);
-    this.pickedObject = null;
-    this.pickedObjectSavedColor = 0;
-  }
-  pick(cssPosition, scene, camera, time) {
-+    const {pickingTexture, pixelBuffer} = this;
-
-    // Rétablit la couleur s'il y a un objet sélectionné
-    if (this.pickedObject) {
-      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
-      this.pickedObject = undefined;
-    }
-
-+    // Définit le décalage de vue pour représenter juste un seul pixel sous la souris
-+    const pixelRatio = renderer.getPixelRatio();
-+    camera.setViewOffset(
-+        renderer.getContext().drawingBufferWidth,   // Largeur totale
-+        renderer.getContext().drawingBufferHeight,  // Hauteur totale
-+        cssPosition.x * pixelRatio | 0,             // rect x
-+        cssPosition.y * pixelRatio | 0,             // rect y
-+        1,                                          // rect largeur
-+        1,                                          // rect hauteur
-+    );
-+    // Rend la scène
-+    renderer.setRenderTarget(pickingTexture)
-+    renderer.render(scene, camera);
-+    renderer.setRenderTarget(null);
-+
-+    // Efface le décalage de vue pour que le rendu redevienne normal
-+    camera.clearViewOffset();
-+    // Lit le pixel
-+    renderer.readRenderTargetPixels(
-+        pickingTexture,
-+        0,   // x
-+        0,   // y
-+        1,   // largeur
-+        1,   // hauteur
-+        pixelBuffer);
-+
-+    const id =
-+        (pixelBuffer[0] << 16) |
-+        (pixelBuffer[1] <<  8) |
-+        (pixelBuffer[2]      );
-
--    // Lance un rayon à travers le frustum
--    this.raycaster.setFromCamera(normalizedPosition, camera);
--    // Obtient la liste des objets intersectés par le rayon
--    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
--    if (intersectedObjects.length) {
--      // Sélectionne le premier objet. C'est le plus proche
--      this.pickedObject = intersectedObjects[0].object;
-
-+    const intersectedObject = idToObject[id];
-+    if (intersectedObject) {
-+      // Sélectionne le premier objet. C'est le plus proche
-+      this.pickedObject = intersectedObject;
-      // Sauvegarde sa couleur
-      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
-      // Définit sa couleur émissive sur un rouge/jaune clignotant
-      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
-    }
-  }
-}
-
-

Et ensuite, nous devons juste l'utiliser

-
-const pickHelper = new PickHelper();
-+const pickHelper = new GPUPickHelper();
-
-

et lui passer la pickScene au lieu de la scene.

-
-  pickHelper.pick(pickPosition, scene, camera, time);
-+  pickHelper.pick(pickPosition, pickScene, camera, time);
-
-

Et maintenant, cela devrait vous permettre de sélectionner à travers les parties transparentes.

-

- -

-

J'espère que cela vous donne une idée de la manière d'implémenter la sélection. Dans un futur article, nous pourrons peut-être aborder la manière de manipuler des objets avec la souris.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/post-processing.html b/manual/fr/post-processing.html deleted file mode 100644 index c422e49e085578..00000000000000 --- a/manual/fr/post-processing.html +++ /dev/null @@ -1,221 +0,0 @@ - - - Post-traitement - - - - - - - - - - - - - -
-
-

Post-traitement

-
-
-
-

Le post-traitement fait généralement référence à l'application d'une sorte d'effet ou de filtre à une image 2D. Dans le cas de THREE.js, nous avons une scène avec un ensemble de maillages. Nous rendons cette scène en une image 2D. Normalement, cette image est rendue directement dans le canvas et affichée dans le navigateur, mais nous pouvons au lieu de cela la rendre sur une cible de rendu (render target) et ensuite appliquer des effets de post-traitement au résultat avant de le dessiner sur le canvas. On appelle cela post-traitement parce que cela se produit après (post) le traitement principal de la scène.

-

Des exemples de post-traitement sont les filtres de type Instagram, les filtres Photoshop, etc...

-

THREE.js propose des classes d'exemple pour aider à mettre en place un pipeline de post-traitement. La manière dont cela fonctionne est de créer un EffectComposer et d'y ajouter plusieurs objets Pass. Ensuite, vous appelez EffectComposer.render et cela rend votre scène sur une cible de rendu puis applique chaque Pass.

-

Chaque Pass peut être un effet de post-traitement comme l'ajout d'une vignette, le flou, l'application d'un effet de lumière (bloom), l'application d'un grain de film, le réglage de la teinte, de la saturation, du contraste, etc... et enfin le rendu du résultat sur le canvas.

-

Il est un peu important de comprendre comment fonctionne EffectComposer. Il crée deux cibles de rendu. Appelons-les rtA et rtB.

-

Ensuite, vous appelez EffectComposer.addPass pour ajouter chaque pass dans l'ordre où vous voulez les appliquer. Les passes sont ensuite appliquées à peu près comme ceci.

-
- -

D'abord, la scène que vous avez passée à RenderPass est rendue sur rtA, puis rtA est passée à la passe suivante, quelle qu'elle soit. Cette passe utilise rtA comme entrée pour faire ce qu'elle a à faire et écrit les résultats sur rtB. rtB est ensuite passé à la passe suivante qui utilise rtB comme entrée et écrit de nouveau sur rtA. Cela continue à travers toutes les passes.

-

Chaque Pass a 4 options de base

-

enabled

-

Indique si cette passe doit être utilisée ou non

-

needsSwap

-

Indique s'il faut échanger rtA et rtB après avoir terminé cette passe

-

clear

-

Indique s'il faut effacer avant de rendre cette passe

-

renderToScreen

-

Indique s'il faut rendre sur le canvas au lieu de la cible de rendu de destination actuelle. Dans la plupart des cas d'utilisation, vous ne définissez pas explicitement ce drapeau car la dernière passe de la chaîne est automatiquement rendue sur l'écran.

-

Mettons en place un exemple de base. Nous allons commencer avec l'exemple de l'article sur la réactivité.

-

Pour cela, nous créons d'abord un EffectComposer.

-
const composer = new EffectComposer(renderer);
-
-

Ensuite, comme première passe, nous ajoutons un RenderPass qui rendra notre scène avec notre caméra dans la première cible de rendu.

-
composer.addPass(new RenderPass(scene, camera));
-
-

Ensuite, nous ajoutons un BloomPass. Un BloomPass rend son entrée sur une cible de rendu généralement plus petite et floute le résultat. Il ajoute ensuite ce résultat flouté par-dessus l'entrée originale. Cela fait fleurir (bloom) la scène.

-
const bloomPass = new BloomPass(
-    1,    // strength
-    25,   // kernel size
-    4,    // sigma ?
-    256,  // blur render target resolution
-);
-composer.addPass(bloomPass);
-
-

Ensuite, nous ajoutons un FilmPass qui dessine du bruit et des lignes de balayage par-dessus son entrée.

-
const filmPass = new FilmPass(
-    0.5,   // intensity
-    false,  // grayscale
-);
-composer.addPass(filmPass);
-
-

Enfin, nous ajoutons un OutputPass qui effectue la conversion de l'espace couleur en sRGB et un mappage tonal (tone mapping) optionnel. Cette passe est généralement la dernière de la chaîne.

-
const outputPass = new OutputPass();
-composer.addPass(outputPass);
-
-

Pour utiliser ces classes, nous devons importer un certain nombre de scripts.

-
import {EffectComposer} from 'three/addons/postprocessing/EffectComposer.js';
-import {RenderPass} from 'three/addons/postprocessing/RenderPass.js';
-import {BloomPass} from 'three/addons/postprocessing/BloomPass.js';
-import {FilmPass} from 'three/addons/postprocessing/FilmPass.js';
-import {OutputPass} from 'three/addons/postprocessing/OutputPass.js';
-
-

Pour pratiquement n'importe quel post-traitement, EffectComposer.js, RenderPass.js et OutputPass.js sont requis.

-

Les dernières choses que nous devons faire sont d'utiliser EffectComposer.render au lieu de WebGLRenderer.render et de dire à l'EffectComposer de correspondre à la taille du canvas.

-
-function render(now) {
--  time *= 0.001;
-+let then = 0;
-+function render(now) {
-+  now *= 0.001;  // convertir en secondes
-+  const deltaTime = now - then;
-+  then = now;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-+    composer.setSize(canvas.width, canvas.height);
-  }
-
-  cubes.forEach((cube, ndx) => {
-    const speed = 1 + ndx * .1;
--    const rot = time * speed;
-+    const rot = now * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
--  renderer.render(scene, camera);
-+  composer.render(deltaTime);
-
-  requestAnimationFrame(render);
-}
-
-

EffectComposer.render prend un deltaTime qui est le temps en secondes depuis le rendu de la dernière frame. Il passe cela aux différents effets au cas où certains d'entre eux seraient animés. Dans ce cas, le FilmPass est animé.

-

- -

-

Pour changer les paramètres d'effet à l'exécution, il faut généralement définir des valeurs d'uniformes. Ajoutons une interface graphique (GUI) pour ajuster certains paramètres. Déterminer quelles valeurs vous pouvez facilement ajuster et comment les ajuster nécessite de fouiller dans le code de cet effet.

-

En regardant à l'intérieur de BloomPass.js, j'ai trouvé cette ligne :

-
this.combineUniforms[ 'strength' ].value = strength;
-
-

Nous pouvons donc définir la force (strength) en définissant

-
bloomPass.combineUniforms.strength.value = someValue;
-
-

De même, en regardant dans FilmPass.js, j'ai trouvé ces lignes :

-
this.uniforms.intensity.value = intensity;
-this.uniforms.grayscale.value = grayscale;
-
-

Ce qui indique assez clairement comment les définir.

-

Faisons une petite interface graphique rapide pour définir ces valeurs

-
import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
-
-

et

-
const gui = new GUI();
-{
-  const folder = gui.addFolder('BloomPass');
-  folder.add(bloomPass.combineUniforms.strength, 'value', 0, 2).name('strength');
-  folder.open();
-}
-{
-  const folder = gui.addFolder('FilmPass');
-  folder.add(filmPass.uniforms.grayscale, 'value').name('grayscale');
-  folder.add(filmPass.uniforms.intensity, 'value', 0, 1).name('intensity');
-  folder.open();
-}
-
-

et maintenant nous pouvons ajuster ces paramètres

-

- -

-

Ce fut une petite étape pour créer notre propre effet.

-

Les effets de post-traitement utilisent des shaders. Les shaders sont écrits dans un langage appelé GLSL (Graphics Library Shading Language). Passer en revue l'intégralité du langage est un sujet beaucoup trop vaste pour ces articles. Quelques ressources pour commencer seraient peut-être cet article et peut-être le Livre des Shaders.

-

Je pense qu'un exemple pour vous aider à démarrer serait utile, alors créons un simple shader de post-traitement GLSL. Nous en créerons un qui nous permette de multiplier l'image par une couleur.

-

Pour le post-traitement, THREE.js fournit un outil utile appelé ShaderPass. Il prend un objet avec des informations définissant un shader de vertex, un shader de fragment, et les entrées par défaut. Il gérera la configuration de la texture à lire pour obtenir les résultats de la passe précédente et l'endroit où rendre, soit sur une des cibles de rendu de l'EffectComposer, soit sur le canvas.

-

Voici un simple shader de post-traitement qui multiplie le résultat de la passe précédente par une couleur.

-
const colorShader = {
-  uniforms: {
-    tDiffuse: { value: null },
-    color:    { value: new THREE.Color(0x88CCFF) },
-  },
-  vertexShader: `
-    varying vec2 vUv;
-    void main() {
-      vUv = uv;
-      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1);
-    }
-  `,
-  fragmentShader: `
-    varying vec2 vUv;
-    uniform sampler2D tDiffuse;
-    uniform vec3 color;
-    void main() {
-      vec4 previousPassColor = texture2D(tDiffuse, vUv);
-      gl_FragColor = vec4(
-          previousPassColor.rgb * color,
-          previousPassColor.a);
-    }
-  `,
-};
-
-

Ci-dessus, tDiffuse est le nom que ShaderPass utilise pour passer la texture résultat de la passe précédente, donc nous en avons pratiquement toujours besoin. Nous déclarons ensuite color comme une Color de THREE.js.

-

Ensuite, nous avons besoin d'un shader de vertex. Pour le post-traitement, le shader de vertex montré ici est à peu près standard et n'a que rarement besoin d'être modifié. Sans entrer dans trop de détails (voir les articles liés ci-dessus), les variables uv, projectionMatrix, modelViewMatrix et position sont toutes ajoutées comme par magie par THREE.js.

-

Enfin, nous créons un shader de fragment. Dans celui-ci, nous obtenons une couleur de pixel de la passe précédente avec cette ligne

-
vec4 previousPassColor = texture2D(tDiffuse, vUv);
-
-

nous la multiplions par notre couleur et définissons gl_FragColor au résultat

-
gl_FragColor = vec4(
-    previousPassColor.rgb * color,
-    previousPassColor.a);
-
-

Ajoutons une simple interface graphique (GUI) pour définir les 3 valeurs de la couleur

-
const gui = new GUI();
-gui.add(colorPass.uniforms.color.value, 'r', 0, 4).name('red');
-gui.add(colorPass.uniforms.color.value, 'g', 0, 4).name('green');
-gui.add(colorPass.uniforms.color.value, 'b', 0, 4).name('blue');
-
-

Ce qui nous donne un simple effet de post-traitement qui multiplie par une couleur.

-

- -

-

Comme mentionné précédemment, tous les détails sur la manière d'écrire du GLSL et des shaders personnalisés sont trop complexes pour ces articles. Si vous voulez vraiment savoir comment fonctionne WebGL lui-même, consultez ces articles. Une autre excellente ressource est simplement de lire les shaders de post-traitement existants dans le dépôt THREE.js. Certains sont plus compliqués que d'autres, mais si vous commencez par les plus petits, vous pourrez, je l'espère, vous faire une idée de leur fonctionnement.

-

La plupart des effets de post-traitement dans le dépôt THREE.js ne sont malheureusement pas documentés, donc pour les utiliser, vous devrez lire les exemples ou le code des effets eux-mêmes. J'espère que ces simples exemples et l'article sur les cibles de rendu vous fourniront suffisamment de contexte pour commencer.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/prerequisites.html b/manual/fr/prerequisites.html deleted file mode 100644 index 6a8911cdcfaf8e..00000000000000 --- a/manual/fr/prerequisites.html +++ /dev/null @@ -1,332 +0,0 @@ - - - Conditions préalables - - - - - - - - - - - - - -
-
-

Conditions préalables

-
-
-
-

Ces articles sont destinés à vous aider à apprendre à utiliser three.js. -Ils supposent que vous savez programmer en JavaScript. Ils supposent -que vous savez ce qu'est le DOM, comment écrire du code HTML et comment créer des éléments DOM -en JavaScript. Ils supposent que vous savez utiliser -les modules es6 -via import et via les balises <script type="module">. Ils supposent que vous savez utiliser les import maps. -Ils supposent que vous connaissez un peu de CSS et que vous savez ce que -sont les sélecteurs CSS. -Ils supposent également que vous connaissez ES5, ES6 et peut-être un peu ES7. -Ils supposent que vous savez que le navigateur n'exécute du JavaScript que via des événements et des callbacks. -Ils supposent que vous savez ce qu'est une closure.

-

Voici quelques rappels et notes

-

modules es6

-

Les modules es6 peuvent être chargés via le mot-clé import dans un script -ou en ligne via une balise <script type="module">. Voici un exemple

-
-<script type="importmap">
-{
-  "imports": {
-    "three": "./path/to/three.module.js",
-    "three/addons/": "./different/path/to/examples/jsm/"
-  }
-}
-</script>
-
-<script type="module">
-import * as THREE from 'three';
-import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-
-...
-
-</script>
-
-

Voir plus de détails au bas de cet article.

-

document.querySelector et document.querySelectorAll

-

Vous pouvez utiliser document.querySelector pour sélectionner le premier élément -qui correspond à un sélecteur CSS. document.querySelectorAll retourne -tous les éléments qui correspondent à un sélecteur CSS.

-

Vous n'avez pas besoin de onload

-

Beaucoup de pages vieilles de 20 ans utilisent du HTML comme ceci

-
<body onload="somefunction()">
-

Ce style est obsolète. Mettez vos scripts -au bas de la page.

-
<html>
-  <head>
-    ...
-  </head>
-  <body>
-     ...
-  </body>
-  <script>
-    // javascript inline
-  </script>
-</html>
-
-

ou utilisez la propriété defer.

-

Savoir comment fonctionnent les closures

-
function a(v) {
-  const foo = v;
-  return function() {
-     return foo;
-  };
-}
-
-const f = a(123);
-const g = a(456);
-console.log(f());  // imprime 123
-console.log(g());  // imprime 456
-
-

Dans le code ci-dessus, la fonction a crée une nouvelle fonction à chaque fois qu'elle est appelée. Cette -fonction englobe la variable foo. Voici plus d'informations.

-

Comprendre comment fonctionne this

-

this n'est pas magique. C'est effectivement une variable qui est automatiquement passée aux fonctions, tout comme -un argument est passé à une fonction. L'explication simple est que lorsque vous appelez une fonction directement -comme ceci

-
somefunction(a, b, c);
-

this sera null (en mode strict ou dans un module), alors que lorsque vous appelez une fonction via l'opérateur point . comme ceci

-
someobject.somefunction(a, b, c);
-

this sera défini sur someobject.

-

Là où les gens se perdent, c'est avec les callbacks.

-
 const callback = someobject.somefunction;
- loader.load(callback);
-

ne fonctionne pas comme une personne inexpérimentée pourrait s'attendre, car lorsque -loader.load appelle le callback, il ne l'appelle pas avec l'opérateur point . -donc par défaut this sera null (sauf si le loader le définit explicitement sur autre chose). -Si vous voulez que this soit someobject lorsque le callback a lieu, vous devez -le dire à JavaScript en le liant à la fonction.

-
 const callback = someobject.somefunction.bind(someobject);
- loader.load(callback);
-

Cet article pourrait aider à expliquer this.

-

Éléments ES5/ES6/ES7

-

var est obsolète. Utilisez const et/ou let

-

Il n'y a aucune raison d'utiliser var JAMAIS et à ce stade, il est considéré comme une mauvaise pratique -de l'utiliser du tout. Utilisez const si la variable ne sera jamais réassignée, ce qui est la plupart du temps. -Utilisez let dans les cas où la valeur change. Cela aidera à éviter des tonnes de bugs.

-

Utilisez for(elem of collection) jamais for(elem in collection)

-

for of est nouveau, for in est ancien. for in avait des problèmes qui sont résolus par for of

-

Par exemple, vous pouvez itérer sur toutes les paires clé/valeur d'un objet avec

-
for (const [key, value] of Object.entries(someObject)) {
-  console.log(key, value);
-}
-
-

Utilisez forEach, map et filter là où c'est utile

-

Les tableaux ont ajouté les fonctions forEach, -map et -filter et -sont assez largement utilisés dans le JavaScript moderne.

-

Utilisez la déstructuration

-

Supposons un objet const dims = {width: 300, height: 150}

-

ancien code

-
const width = dims.width;
-const height = dims.height;
-
-

nouveau code

-
const {width, height} = dims;
-
-

La déstructuration fonctionne aussi avec les tableaux. Supposons un tableau const position = [5, 6, 7, 1];

-

ancien code

-
const y = position[1];
-const z = position[2];
-
-

nouveau code

-
const [, y, z] = position;
-
-

La déstructuration fonctionne également dans les arguments de fonction

-
const dims = {width: 300, height: 150};
-const vector = [3, 4];
-
-function lengthOfVector([x, y]) {
-  return Math.sqrt(x * x + y * y);
-}
-
-const dist = lengthOfVector(vector);  // dist = 5
-
-function area({width, height}) {
-  return width * height;
-}
-const a = area(dims);  // a = 45000
-
-

Utilisez les raccourcis de déclaration d'objet

-

ancien code

-
 const width = 300;
- const height = 150;
- const obj = {
-   width: width,
-   height: height,
-   area: function() {
-     return this.width * this.height
-   },
- };
-
-

nouveau code

-
 const width = 300;
- const height = 150;
- const obj = {
-   width,
-   height,
-   area() {
-     return this.width * this.height;
-   },
- };
-
-

Utilisez le paramètre rest et l'opérateur spread ...

-

Le paramètre rest peut être utilisé pour consommer un nombre quelconque de paramètres. Exemple

-
 function log(className, ...args) {
-   const elem = document.createElement('div');
-   elem.className = className;
-   elem.textContent = args.join(' ');
-   document.body.appendChild(elem);
- }
-
-

L'opérateur spread peut être utilisé pour étendre un itérable en arguments

-
const position = [1, 2, 3];
-someMesh.position.set(...position);
-
-

ou copier un tableau

-
const copiedPositionArray = [...position];
-copiedPositionArray.push(4); // [1,2,3,4]
-console.log(position); // [1,2,3] position n'est pas affectée
-
-

ou pour fusionner des objets

-
const a = {abc: 123};
-const b = {def: 456};
-const c = {...a, ...b};  // c est maintenant {abc: 123, def: 456}
-

Utilisez class

-

La syntaxe pour créer des objets de type classe avant ES5 était peu familière à la plupart -des programmeurs. À partir d'ES5, vous pouvez maintenant utiliser le mot-clé class -qui est plus proche du style C++/C#/Java.

-

Comprendre les getters et setters

-

Les getters et -setters sont -courants dans la plupart des langages modernes. La syntaxe class -d'ES5 les rend beaucoup plus faciles qu'avant ES5.

-

Utilisez les fonctions fléchées (arrow functions) là où c'est approprié

-

C'est particulièrement utile avec les callbacks et les promises.

-
loader.load((texture) => {
-  // utiliser la texture
-});
-
-

Les fonctions fléchées lient this au contexte dans lequel vous créez la fonction fléchée.

-
const foo = (args) => {/* code */};
-
-

est un raccourci pour

-
const foo = (function(args) {/* code */}).bind(this));
-
-

Voir le lien ci-dessus pour plus d'informations sur this.

-

Promises ainsi que async/await

-

Les Promises aident avec le code asynchrone. Async/await aident à -utiliser les promises.

-

C'est un sujet trop vaste pour être abordé ici, mais vous pouvez lire sur -les promises ici -et sur async/await ici.

-

Utilisez les littéraux de gabarit (Template Literals)

-

Les littéraux de gabarit sont des chaînes utilisant des accents graves (backticks) au lieu de guillemets.

-
const foo = `this is a template literal`;
-

Les littéraux de gabarit ont fondamentalement 2 fonctionnalités. La première est qu'ils peuvent être multi-lignes

-
const foo = `this
-is
-a
-template
-literal`;
-const bar = "this\nis\na\ntemplate\nliteral";
-
-

foo et bar ci-dessus sont identiques.

-

L'autre est que vous pouvez sortir du mode chaîne et insérer des fragments de -JavaScript en utilisant ${javascript-expression}. C'est la partie gabarit. Exemple :

-
const r = 192;
-const g = 255;
-const b = 64;
-const rgbCSSColor = `rgb(${r},${g},${b})`;
-
-

ou

-
const color = [192, 255, 64];
-const rgbCSSColor = `rgb(${color.join(',')})`;
-
-

ou

-
const aWidth = 10;
-const bWidth = 20;
-someElement.style.width = `${aWidth + bWidth}px`;
-
-

Apprenez les conventions de codage JavaScript.

-

Bien que vous soyez libre de formater votre code comme bon vous semble, il existe au moins une -convention dont vous devriez être conscient. Les variables, noms de fonctions, noms de méthodes, en -JavaScript sont tous en lowerCasedCamelCase. Les constructeurs, les noms de classes sont -en CapitalizedCamelCase. Si vous suivez cette règle, votre code correspondra à la plupart des autres -codes JavaScript. Beaucoup de linters, des programmes qui vérifient les erreurs évidentes dans votre code, -vous signaleront des erreurs si vous utilisez la mauvaise casse, car en suivant la convention -ci-dessus, ils peuvent savoir quand vous utilisez quelque chose de manière incorrecte.

-
const v = new vector(); // clairement une erreur si toutes les classes commencent par une majuscule
-const v = Vector();     // clairement une erreur si toutes les fonctions commencent par une minuscule.
-
-

Envisagez d'utiliser Visual Studio Code

-

Bien sûr, utilisez l'éditeur que vous voulez, mais si vous ne l'avez pas essayé, envisagez -d'utiliser Visual Studio Code pour JavaScript et -après l'avoir installé, configurez -eslint. -Cela pourrait prendre quelques minutes à configurer, mais cela vous aidera énormément à trouver -les bugs dans votre JavaScript.

-

Quelques exemples

-

Si vous activez la règle no-undef alors -VSCode via ESLint vous avertira de nombreuses variables non définies.

-
- -

Ci-dessus, vous pouvez voir que j'ai mal orthographié doTheThing en doThing. Il y a un trait ondulé rouge -sous doThing et en survolant, il me dit qu'il n'est pas défini. Une erreur -évitée.

-

Si vous utilisez des balises <script> pour inclure three.js, vous recevrez des avertissements en utilisant THREE, alors ajoutez /* global THREE */ en haut de vos -fichiers JavaScript pour dire à eslint que THREE existe. (ou mieux, utilisez import 😉)

-
- -

Ci-dessus, vous pouvez voir qu'eslint connaît la règle selon laquelle les UpperCaseNames sont des constructeurs -et que vous devriez donc utiliser new. Une autre erreur détectée et évitée. C'est la -règle new-cap.

-

Il existe des centaines de règles que vous pouvez activer ou désactiver ou -personnaliser. Par exemple, j'ai mentionné ci-dessus que vous -devriez utiliser const et let plutôt que var.

-

Ici, j'ai utilisé var et il m'a averti que je devrais utiliser let ou const

-
- -

Ici, j'ai utilisé let, mais il a vu que je ne changeais jamais la valeur, alors il a suggéré que j'utilise const.

-
- -

Bien sûr, si vous préférez continuer à utiliser var, vous pouvez simplement désactiver cette règle. -Comme je l'ai dit ci-dessus, je préfère utiliser const et let plutôt que var car ils -fonctionnent mieux et préviennent les bugs.

-

Dans les cas où vous avez vraiment besoin de outrepasser une règle, vous pouvez ajouter des commentaires pour les désactiver -pour une seule ligne ou une section de code.

-

Si vous avez vraiment besoin de prendre en charge les anciens navigateurs, utilisez un transpiler

-

La plupart des navigateurs modernes sont mis à jour automatiquement, donc l'utilisation de toutes ces fonctionnalités vous aidera à -être productif et à éviter les bugs. Cela dit, si vous êtes sur un projet qui doit absolument -prendre en charge les anciens navigateurs, il existe des outils qui prendront votre code ES5/ES6/ES7 -et le transpileront vers du JavaScript pré-ES5.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/primitives.html b/manual/fr/primitives.html deleted file mode 100644 index 3b2cbdb179abe6..00000000000000 --- a/manual/fr/primitives.html +++ /dev/null @@ -1,360 +0,0 @@ - - - Primitives - - - - - - - - - - - - - -
-
-

Primitives

-
-
-
-

Cet article fait partie d'une série d'articles sur three.js. -Le premier article traitait des notions fondamentales. -Si vous ne l'avez pas encore lu, vous pourriez vouloir commencer par là.

-

Three.js dispose d'un grand nombre de primitives. Les primitives -sont généralement des formes 3D qui sont générées au moment de l'exécution -avec un ensemble de paramètres.

-

Il est courant d'utiliser des primitives pour des choses comme une sphère -pour un globe ou un ensemble de boîtes pour dessiner un graphique 3D. Il est -particulièrement courant d'utiliser des primitives pour expérimenter -et commencer avec la 3D. Pour la majorité des applications 3D, -il est plus courant qu'un artiste crée des modèles 3D -dans un programme de modélisation 3D comme Blender -ou Maya ou Cinema 4D. Plus tard dans cette série, nous aborderons -la création et le chargement de données à partir de plusieurs programmes de modélisation -3D. Pour l'instant, passons en revue quelques-unes des primitives disponibles.

-

Beaucoup des primitives ci-dessous ont des valeurs par défaut pour tout ou partie de leurs -paramètres, de sorte que vous pouvez les utiliser plus ou moins selon vos besoins.

-
Une Boîte
-
Un cercle plat
-
Un Cône
-
Un Cylindre
-
Un dodécaèdre (12 faces)
-
Une forme 2D extrudée avec biseautage optionnel. -Ici, nous extrudons une forme de cœur. Notez que c'est la base -de TextGeometry.
-
Un icosaèdre (20 faces)
-
Une forme générée en faisant tourner une ligne. Exemples : lampes, quilles de bowling, bougies, chandeliers, verres à vin, verres à boire, etc... Vous fournissez la silhouette 2D comme une série de points, puis vous indiquez à three.js combien de subdivisions créer en faisant tourner la silhouette autour d'un axe.
-
Un Octaèdre (8 faces)
-
Une surface générée en fournissant une fonction qui prend un point 2D d'une grille et renvoie le point 3D correspondant.
-
Un plan 2D
-
Prend un ensemble de triangles centrés autour d'un point et les projette sur une sphère
-
Un disque 2D avec un trou au centre
-
Un contour 2D qui est triangulé
-
Une sphère
-
Un tétraèdre (4 faces)
-
Texte 3D généré à partir d'une police 3D et d'une chaîne de caractères
-
Un tore (beignet)
-
Un nœud torique
-
Un cercle tracé le long d'un chemin
-
Un objet d'aide qui prend une autre géométrie en entrée et génère des arêtes seulement si l'angle entre les faces est supérieur à un certain seuil. Par exemple, si vous regardez la boîte en haut, elle montre une ligne traversant chaque face, montrant chaque triangle qui compose la boîte. En utilisant un EdgesGeometry à la place, les lignes du milieu sont supprimées. Ajustez le seuil `thresholdAngle` ci-dessous et vous verrez les arêtes en dessous de ce seuil disparaître.
-
Génère une géométrie qui contient un segment de ligne (2 points) par arête dans la géométrie donnée. Sans cela, il vous manquerait souvent des arêtes ou vous obtiendriez des arêtes supplémentaires car WebGL nécessite généralement 2 points par segment de ligne. Par exemple, si vous n'aviez qu'un seul triangle, il n'y aurait que 3 points. Si vous essayiez de le dessiner en utilisant un matériau avec wireframe: true, vous n'obtiendriez qu'une seule ligne. Passer cette géométrie de triangle à un WireframeGeometry générera une nouvelle géométrie qui a 3 segments de ligne utilisant 6 points.
- -

Nous aborderons la création de géométries personnalisées dans un autre article. Pour l'instant, -faisons un exemple créant chaque type de primitive. Nous commencerons -avec les exemples de l'article précédent.

-

Près du haut, définissons une couleur de fond

-
const scene = new THREE.Scene();
-+scene.background = new THREE.Color(0xAAAAAA);
-
-

Cela indique à three.js d'effacer avec un gris clair.

-

La caméra doit changer de position afin que nous puissions voir tous les -objets.

-
-const fov = 75;
-+const fov = 40;
-const aspect = 2;  // the canvas default
-const near = 0.1;
--const far = 5;
-+const far = 1000;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
--camera.position.z = 2;
-+camera.position.z = 120;
-
-

Ajoutons une fonction, addObject, qui prend une position x, y et un Object3D et ajoute -l'objet à la scène.

-
const objects = [];
-const spread = 15;
-
-function addObject(x, y, obj) {
-  obj.position.x = x * spread;
-  obj.position.y = y * spread;
-
-  scene.add(obj);
-  objects.push(obj);
-}
-
-

Créons également une fonction pour créer un matériau de couleur aléatoire. -Nous utiliserons une fonctionnalité de Color -qui vous permet de définir une couleur -basée sur la teinte, la saturation et la luminance.

-

hue (teinte) va de 0 à 1 autour de la roue chromatique avec -le rouge à 0, le vert à 0.33 et le bleu à 0.66. saturation -va de 0 à 1, 0 n'ayant pas de couleur et 1 étant -la plus saturée. luminance va de 0 à 1 -avec 0 étant le noir, 1 étant le blanc et 0.5 étant -la quantité maximale de couleur. En d'autres termes, -lorsque la luminance passe de 0.0 à 0.5, la couleur -passe du noir à la hue (teinte). De 0.5 à 1.0, -la couleur passe de la hue (teinte) au blanc.

-
function createMaterial() {
-  const material = new THREE.MeshPhongMaterial({
-    side: THREE.DoubleSide,
-  });
-
-  const hue = Math.random();
-  const saturation = 1;
-  const luminance = .5;
-  material.color.setHSL(hue, saturation, luminance);
-
-  return material;
-}
-
-

Nous avons également passé side: THREE.DoubleSide au matériau. -Cela indique à three de dessiner les deux côtés des triangles -qui composent une forme. Pour une forme solide comme une sphère -ou un cube, il n'y a généralement aucune raison de dessiner les -côtés arrière des triangles car ils font tous face à l'intérieur de la -forme. Dans notre cas cependant, nous dessinons quelques éléments -comme le PlaneGeometry et le ShapeGeometry -qui sont bidimensionnels et n'ont donc pas d'intérieur. Sans -définir side: THREE.DoubleSide, ils disparaîtraient -en regardant leurs côtés arrière.

-

Je dois noter qu'il est plus rapide de dessiner lorsque l'on ne définit pas -side: THREE.DoubleSide, donc idéalement nous ne le définirions que sur -les matériaux qui en ont vraiment besoin, mais dans ce cas, nous -ne dessinons pas trop, donc il n'y a pas beaucoup de raison de -s'en soucier.

-

Créons une fonction, addSolidGeometry, à laquelle -nous passons une géométrie, et elle crée un matériau de couleur aléatoire -via createMaterial et l'ajoute à la scène -via addObject.

-
function addSolidGeometry(x, y, geometry) {
-  const mesh = new THREE.Mesh(geometry, createMaterial());
-  addObject(x, y, mesh);
-}
-
-

Maintenant, nous pouvons l'utiliser pour la majorité des primitives que nous créons. -Par exemple, pour créer une boîte

-
{
-  const width = 8;
-  const height = 8;
-  const depth = 8;
-  addSolidGeometry(-2, -2, new THREE.BoxGeometry(width, height, depth));
-}
-
-

Si vous regardez le code ci-dessous, vous verrez une section similaire pour chaque type de géométrie.

-

Voici le résultat :

-

- -

-

Il y a quelques exceptions notables au modèle ci-dessus. -La plus importante est probablement la TextGeometry. Elle nécessite de charger -les données de police 3D avant de pouvoir générer un maillage pour le texte. -Ces données se chargent de manière asynchrone, nous devons donc attendre qu'elles -soient chargées avant d'essayer de créer la géométrie. En "promisifiant" -le chargement de la police, nous pouvons rendre les choses beaucoup plus faciles. -Nous créons un FontLoader, puis une fonction loadFont qui renvoie -une promesse qui, une fois résolue, nous donnera la police. Nous créons ensuite -une fonction async appelée doit et chargeons la police en utilisant await. -Et enfin, nous créons la géométrie et appelons addObject pour l'ajouter à la scène.

-
{
-  const loader = new FontLoader();
-  // promisify font loading
-  function loadFont(url) {
-    return new Promise((resolve, reject) => {
-      loader.load(url, resolve, undefined, reject);
-    });
-  }
-
-  async function doit() {
-    const font = await loadFont('resources/threejs/fonts/helvetiker_regular.typeface.json');  /* threejs.org : URL */
-    const geometry = new TextGeometry('three.js', {
-      font: font,
-      size: 3.0,
-      depth: .2,
-      curveSegments: 12,
-      bevelEnabled: true,
-      bevelThickness: 0.15,
-      bevelSize: .3,
-      bevelSegments: 5,
-    });
-    const mesh = new THREE.Mesh(geometry, createMaterial());
-    geometry.computeBoundingBox();
-    geometry.boundingBox.getCenter(mesh.position).multiplyScalar(-1);
-
-    const parent = new THREE.Object3D();
-    parent.add(mesh);
-
-    addObject(-1, -1, parent);
-  }
-  doit();
-}
-
-

Il y a une autre différence. Nous voulons faire tourner le texte autour de son -centre, mais par défaut, three.js crée le texte de manière à ce que son centre de rotation -soit sur le bord gauche. Pour contourner ce problème, nous pouvons demander à three.js de calculer la -boîte englobante (bounding box) de la géométrie. Nous pouvons ensuite appeler la méthode getCenter -de la boîte englobante et lui passer l'objet position de notre maillage. -getCenter copie le centre de la boîte dans la position. -Elle renvoie également l'objet position afin que nous puissions appeler multiplyScalar(-1) -pour positionner l'objet entier de sorte que son centre de rotation -soit au centre de l'objet.

-

Si nous appelions simplement addSolidGeometry comme avec les exemples précédents, -cela redéfinirait la position, ce qui n'est pas bon. -Donc, dans ce cas, nous créons un Object3D qui -est le nœud standard pour le graphe de scène de three.js. Mesh -est également hérité de Object3D. Nous aborderons le fonctionnement du graphe de scène -dans un autre article. -Pour l'instant, il suffit de savoir que, -comme les nœuds DOM, les enfants sont dessinés par rapport à leur parent. -En créant un Object3D et en faisant de notre maillage un enfant de celui-ci, -nous pouvons positionner l'Object3D où nous voulons tout en -conservant le décalage central que nous avons défini précédemment.

-

Si nous ne faisions pas cela, le texte tournerait de manière décentrée.

-

- -

-

Notez que celui de gauche ne tourne pas autour de son centre -tandis que celui de droite le fait.

-

Les autres exceptions sont les 2 exemples basés sur des lignes pour EdgesGeometry -et WireframeGeometry. Au lieu d'appeler addSolidGeometry, elles appellent -addLineGeometry qui ressemble à ceci

-
function addLineGeometry(x, y, geometry) {
-  const material = new THREE.LineBasicMaterial({color: 0x000000});
-  const mesh = new THREE.LineSegments(geometry, material);
-  addObject(x, y, mesh);
-}
-
-

Elle crée un LineBasicMaterial noir et crée ensuite un objet LineSegments -qui est un wrapper pour Mesh et aide three à savoir que vous rendez -des segments de ligne (2 points par segment).

-

Chacune des primitives possède plusieurs paramètres que vous pouvez passer lors de sa création -et il est préférable de consulter la documentation pour les voir tous plutôt que -de les répéter ici. Vous pouvez également cliquer sur les liens ci-dessus à côté de chaque forme -pour accéder directement à la documentation de cette forme.

-

Il existe une autre paire de classes qui ne correspondent pas vraiment aux modèles ci-dessus. Ce sont -les classes PointsMaterial et Points. Points est similaire à LineSegments ci-dessus en ce sens qu'elle prend une -BufferGeometry mais dessine des points à chaque sommet au lieu de lignes. -Pour l'utiliser, vous devez également lui passer un PointsMaterial qui -prend un paramètre size pour définir la taille des points.

-
const radius = 7;
-const widthSegments = 12;
-const heightSegments = 8;
-const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
-const material = new THREE.PointsMaterial({
-    color: 'red',
-    size: 0.2,     // in world units
-});
-const points = new THREE.Points(geometry, material);
-scene.add(points);
-
-
-
-
- -

Vous pouvez désactiver sizeAttenuation en le définissant à false si vous souhaitez que les points -aient la même taille quelle que soit leur distance par rapport à la caméra.

-
const material = new THREE.PointsMaterial({
-    color: 'red',
-+    sizeAttenuation: false,
-+    size: 3,       // in pixels
--    size: 0.2,     // in world units
-});
-...
-
-
-
-
- -

Une autre chose importante à aborder est que presque toutes les formes -ont divers paramètres pour déterminer combien les subdiviser. Un bon exemple -pourrait être les géométries de sphères. Les sphères prennent des paramètres pour -le nombre de divisions à faire autour et de haut en bas. Par exemple

-
-
-
-
-
- -

La première sphère a 5 segments autour et 3 en hauteur, soit 15 segments -ou 30 triangles. La deuxième sphère a 24 segments sur 10, soit 240 segments -ou 480 triangles. La dernière a 50 sur 50, soit 2500 segments ou 5000 triangles.

-

C'est à vous de décider du nombre de subdivisions dont vous avez besoin. Il peut -sembler que vous ayez besoin d'un grand nombre de segments, mais supprimez les lignes -et l'ombrage plat, et nous obtenons ceci

-
-
-
-
-
- -

Il n'est maintenant plus si clair que celle de droite avec 5000 triangles -soit entièrement meilleure que celle du milieu avec seulement 480.

-

Si vous ne dessinez que quelques sphères, comme par exemple un seul globe pour -une carte de la terre, alors une seule sphère de 10000 triangles n'est pas un mauvais -choix. Si par contre vous essayez de dessiner 1000 sphères, alors -1000 sphères multipliées par 10000 triangles chacune donnent 10 millions de triangles. -Pour animer fluidement, vous avez besoin que le navigateur dessine à 60 images par -seconde, donc vous demanderiez au navigateur de dessiner 600 millions de triangles -par seconde. C'est beaucoup de calcul.

-

Parfois, il est facile de choisir. Par exemple, vous pouvez également choisir -de subdiviser un plan.

-
-
-
-
- -

Le plan de gauche est composé de 2 triangles. Le plan de droite -est composé de 200 triangles. Contrairement à la sphère, il n'y a vraiment aucun compromis sur la qualité pour la plupart -des cas d'utilisation d'un plan. Vous ne subdiviseriez très probablement un plan -que si vous vous attendiez à vouloir le modifier ou le déformer d'une manière ou d'une autre. Une boîte -est similaire.

-

Alors, choisissez ce qui convient le mieux à votre situation. Moins -vous choisissez de subdivisions, plus il est probable que les choses fonctionneront fluidement et moins -elles consommeront de mémoire. Vous devrez décider vous-même quel est le bon -compromis pour votre situation particulière.

-

Si aucune des formes ci-dessus ne correspond à votre cas d'utilisation, vous pouvez charger -une géométrie, par exemple à partir d'un fichier .obj -ou d'un fichier .gltf. -Vous pouvez également créer votre propre BufferGeometry personnalisée.

-

Ensuite, passons en revue le fonctionnement du graphe de scène de three et comment -l'utiliser.

-

- - - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/rendering-on-demand.html b/manual/fr/rendering-on-demand.html deleted file mode 100644 index 72bfd38777e967..00000000000000 --- a/manual/fr/rendering-on-demand.html +++ /dev/null @@ -1,196 +0,0 @@ - - - Rendu à la demande - - - - - - - - - - - - - -
-
-

Rendu à la demande

-
-
-
-

Le sujet peut sembler évident pour beaucoup, mais au cas où... la plupart des exemples Three.js rendent en continu. En d'autres termes, ils mettent en place une boucle requestAnimationFrame ou "boucle rAF" comme ceci

-
function render() {
-  ...
-  requestAnimationFrame(render);
-}
-requestAnimationFrame(render);
-
-

Pour quelque chose qui s'anime, cela a du sens, mais qu'en est-il de quelque chose qui ne s'anime pas ? Dans ce cas, rendre en continu est un gaspillage de la puissance de l'appareil et si l'utilisateur est sur un appareil portable, cela gaspille la batterie de l'utilisateur.

-

La façon la plus évidente de résoudre ce problème est de rendre une fois au début, puis de ne rendre que lorsque quelque chose change. Les changements incluent le chargement final des textures ou des modèles, l'arrivée de données depuis une source externe, l'ajustement d'un paramètre par l'utilisateur, le changement de caméra ou d'autres entrées pertinentes.

-

Prenons un exemple de l'article sur la réactivité et modifions-le pour qu'il rende à la demande.

-

D'abord, nous allons ajouter les OrbitControls afin qu'il y ait quelque chose qui puisse changer et auquel nous puissions réagir en rendant.

-
import * as THREE from 'three';
-+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-
-

et les configurer

-
const fov = 75;
-const aspect = 2;  // the canvas default
-const near = 0.1;
-const far = 5;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-camera.position.z = 2;
-
-+const controls = new OrbitControls(camera, canvas);
-+controls.target.set(0, 0, 0);
-+controls.update();
-
-

Puisque nous n'animerons plus les cubes, nous n'avons plus besoin de les suivre

-
-const cubes = [
--  makeInstance(geometry, 0x44aa88,  0),
--  makeInstance(geometry, 0x8844aa, -2),
--  makeInstance(geometry, 0xaa8844,  2),
--];
-+makeInstance(geometry, 0x44aa88,  0);
-+makeInstance(geometry, 0x8844aa, -2);
-+makeInstance(geometry, 0xaa8844,  2);
-
-

Nous pouvons supprimer le code d'animation des cubes et les appels à requestAnimationFrame

-
-function render(time) {
--  time *= 0.001;
-+function render() {
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
--  cubes.forEach((cube, ndx) => {
--    const speed = 1 + ndx * .1;
--    const rot = time * speed;
--    cube.rotation.x = rot;
--    cube.rotation.y = rot;
-  });
-
-  renderer.render(scene, camera);
-
--  requestAnimationFrame(render);
-}
-
--requestAnimationFrame(render);
-
-

puis nous devons rendre une fois

-
render();
-
-

Nous devons rendre chaque fois que les OrbitControls modifient les paramètres de la caméra. Heureusement, les OrbitControls déclenchent un événement change chaque fois que quelque chose change.

-
controls.addEventListener('change', render);
-
-

Nous devons également gérer le cas où l'utilisateur redimensionne la fenêtre. C'était géré automatiquement auparavant puisque nous rendions en continu, mais maintenant que nous ne le faisons plus, nous devons rendre lorsque la taille de la fenêtre change.

-
window.addEventListener('resize', render);
-
-

Et avec cela, nous obtenons quelque chose qui rend à la demande.

-

- -

-

Les OrbitControls ont des options pour ajouter une sorte d'inertie afin de les rendre moins rigides. Nous pouvons l'activer en définissant la propriété enableDamping sur true.

-
controls.enableDamping = true;
-
-

Avec enableDamping activé, nous devons appeler controls.update dans notre fonction de rendu afin que les OrbitControls puissent continuer à nous fournir de nouveaux paramètres de caméra pendant qu'ils lissent le mouvement. Mais cela signifie que nous ne pouvons pas appeler render directement depuis l'événement change car nous nous retrouverions dans une boucle infinie. Les contrôles nous enverraient un événement change et appelleraient render, render appellerait controls.update. controls.update enverrait un autre événement change.

-

Nous pouvons résoudre cela en utilisant requestAnimationFrame pour appeler render, mais nous devons nous assurer de ne demander une nouvelle image que si une n'a pas déjà été demandée, ce que nous pouvons faire en conservant une variable qui suit si nous avons déjà demandé une image.

-
+let renderRequested = false;
-
-function render() {
-+  renderRequested = false;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-  renderer.render(scene, camera);
-}
-render();
-
-+function requestRenderIfNotRequested() {
-+  if (!renderRequested) {
-+    renderRequested = true;
-+    requestAnimationFrame(render);
-+  }
-+}
-
--controls.addEventListener('change', render);
-+controls.addEventListener('change', requestRenderIfNotRequested);
-
-

Nous devrions probablement aussi utiliser requestRenderIfNotRequested pour le redimensionnement également

-
-window.addEventListener('resize', render);
-+window.addEventListener('resize', requestRenderIfNotRequested);
-
-

Il peut être difficile de voir la différence. Essayez de cliquer sur l'exemple ci-dessous et utilisez les touches fléchées pour vous déplacer ou faites glisser pour faire tourner. Ensuite, essayez de cliquer sur l'exemple ci-dessus et faites la même chose, et vous devriez pouvoir faire la différence. Celui d'en haut s'accroche lorsque vous appuyez sur une touche fléchée ou faites glisser, celui d'en bas glisse.

-

- -

-

Ajoutons également une simple GUI lil-gui et faisons en sorte que ses modifications rendent à la demande.

-
import * as THREE from 'three';
-import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-+import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
-
-

Permettons de définir la couleur et l'échelle x de chaque cube. Pour pouvoir définir la couleur, nous utiliserons le ColorGUIHelper que nous avons créé dans l'article sur les lumières.

-

Tout d'abord, nous devons créer une GUI

-
const gui = new GUI();
-
-

puis pour chaque cube, nous créerons un dossier et ajouterons 2 contrôles, un pour material.color et un autre pour cube.scale.x.

-
function makeInstance(geometry, color, x) {
-  const material = new THREE.MeshPhongMaterial({color});
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  cube.position.x = x;
-
-+  const folder = gui.addFolder(`Cube${x}`);
-+  folder.addColor(new ColorGUIHelper(material, 'color'), 'value')
-+      .name('color')
-+      .onChange(requestRenderIfNotRequested);
-+  folder.add(cube.scale, 'x', .1, 1.5)
-+      .name('scale x')
-+      .onChange(requestRenderIfNotRequested);
-+  folder.open();
-
-  return cube;
-}
-
-

Vous pouvez voir ci-dessus que les contrôles lil-gui ont une méthode onChange à laquelle vous pouvez passer une fonction de rappel à appeler lorsque la GUI modifie une valeur. Dans notre cas, nous avons juste besoin qu'elle appelle requestRenderIfNotRequested. L'appel à folder.open fait que le dossier s'ouvre dès le départ.

-

- -

-

J'espère que cela vous donne une idée de la façon de faire en sorte que three.js rende à la demande plutôt qu'en continu. Les applications/pages qui rendent three.js à la demande ne sont pas aussi courantes que la plupart des pages utilisant three.js qui sont soit des jeux, soit de l'art animé en 3D, mais des exemples de pages qui pourraient mieux rendre à la demande seraient, par exemple, une visionneuse de carte, un éditeur 3D, un générateur de graphiques 3D, un catalogue de produits, etc...

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/rendertargets.html b/manual/fr/rendertargets.html deleted file mode 100644 index dc8fde818ee9c4..00000000000000 --- a/manual/fr/rendertargets.html +++ /dev/null @@ -1,168 +0,0 @@ - - - Cibles de rendu - - - - - - - - - - - - - -
-
-

Cibles de rendu

-
-
-
-

Une cible de rendu dans three.js est essentiellement une texture sur laquelle vous pouvez effectuer un rendu. -Une fois le rendu effectué, vous pouvez utiliser cette texture comme n'importe quelle autre texture.

-

Faisons un exemple simple. Nous allons commencer par un exemple tiré de l'article sur la responsivité.

-

Rendre sur une cible de rendu est presque exactement la même chose qu'un rendu normal. D'abord, nous créons un WebGLRenderTarget.

-
const rtWidth = 512;
-const rtHeight = 512;
-const renderTarget = new THREE.WebGLRenderTarget(rtWidth, rtHeight);
-
-

Ensuite, nous avons besoin d'une Camera et d'une Scene

-
const rtFov = 75;
-const rtAspect = rtWidth / rtHeight;
-const rtNear = 0.1;
-const rtFar = 5;
-const rtCamera = new THREE.PerspectiveCamera(rtFov, rtAspect, rtNear, rtFar);
-rtCamera.position.z = 2;
-
-const rtScene = new THREE.Scene();
-rtScene.background = new THREE.Color('red');
-
-

Notez que nous avons défini l'aspect sur celui de la cible de rendu, et non sur celui du canvas. -Le bon aspect à utiliser dépend de ce pour quoi nous rendons. Dans ce cas, -nous utiliserons la texture de la cible de rendu sur la face d'un cube. Puisque les faces de -du cube sont carrées, nous voulons un aspect de 1.0.

-

Nous remplissons la scène. Dans ce cas, nous utilisons la lumière et les 3 cubes de l'article précédent.

-
{
-  const color = 0xFFFFFF;
-  const intensity = 1;
-  const light = new THREE.DirectionalLight(color, intensity);
-  light.position.set(-1, 2, 4);
-*  rtScene.add(light);
-}
-
-const boxWidth = 1;
-const boxHeight = 1;
-const boxDepth = 1;
-const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-
-function makeInstance(geometry, color, x) {
-  const material = new THREE.MeshPhongMaterial({color});
-
-  const cube = new THREE.Mesh(geometry, material);
-*  rtScene.add(cube);
-
-  cube.position.x = x;
-
-  return cube;
-}
-
-*const rtCubes = [
-  makeInstance(geometry, 0x44aa88,  0),
-  makeInstance(geometry, 0x8844aa, -2),
-  makeInstance(geometry, 0xaa8844,  2),
-];
-
-

La Scene et la Camera de l'article précédent sont toujours là. Nous les utiliserons pour rendre sur le canvas. -Il nous suffit d'ajouter des éléments à rendre.

-

Ajoutons un cube qui utilise la texture de la cible de rendu.

-
const material = new THREE.MeshPhongMaterial({
-  map: renderTarget.texture,
-});
-const cube = new THREE.Mesh(geometry, material);
-scene.add(cube);
-
-

Maintenant, au moment du rendu, nous rendons d'abord la scène de la cible de rendu sur la cible de rendu.

-
function render(time) {
-  time *= 0.001;
-
-  ...
-
-  // faire tourner tous les cubes dans la scène de la cible de rendu
-  rtCubes.forEach((cube, ndx) => {
-    const speed = 1 + ndx * .1;
-    const rot = time * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
-  // dessiner la scène de la cible de rendu sur la cible de rendu
-  renderer.setRenderTarget(renderTarget);
-  renderer.render(rtScene, rtCamera);
-  renderer.setRenderTarget(null);
-
-

Ensuite, nous rendons la scène avec le cube unique qui utilise la texture de la cible de rendu sur le canvas.

-
  // faire tourner le cube dans la scène
-  cube.rotation.x = time;
-  cube.rotation.y = time * 1.1;
-
-  // rendre la scène sur le canvas
-  renderer.render(scene, camera);
-
-

And voilà

-

- -

-

Le cube est rouge car nous avons défini le background de la rtScene sur rouge, de sorte que la -texture de la cible de rendu est effacée en rouge.

-

Les cibles de rendu sont utilisées pour toutes sortes de choses. Les ombres utilisent des cibles de rendu. -La sélection (picking) peut utiliser une cible de rendu. Divers types d'effets de post-traitement -nécessitent des cibles de rendu. Rendre un rétroviseur dans une voiture ou une vue en direct sur un moniteur à l'intérieur d'une scène 3D -pourrait utiliser une cible de rendu.

-

Quelques notes sur l'utilisation de WebGLRenderTarget.

-
    -
  • Par défaut, WebGLRenderTarget crée 2 textures. Une texture de couleur et une texture de profondeur/stencil. Si vous n'avez pas besoin des textures de profondeur ou de stencil, vous pouvez demander à ne pas les créer en passant des options. Exemple :

    -
      const rt = new THREE.WebGLRenderTarget(width, height, {
    -    depthBuffer: false,
    -    stencilBuffer: false,
    -  });
    -
    -
  • -
  • Vous pourriez avoir besoin de changer la taille d'une cible de rendu

    -

    Dans l'exemple ci-dessus, nous créons une cible de rendu de taille fixe, 512x512. Pour des choses comme le post-traitement, vous devez généralement créer une cible de rendu de la même taille que votre canvas. Dans notre code, cela signifierait que lorsque nous changeons la taille du canvas, nous mettons également à jour la taille de la cible de rendu et la caméra que nous utilisons lors du rendu sur la cible de rendu. Exemple :

    -
    function render(time) {
    -  time *= 0.001;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -
    -+    renderTarget.setSize(canvas.width, canvas.height);
    -+    rtCamera.aspect = camera.aspect;
    -+    rtCamera.updateProjectionMatrix();
    -}
    -
  • -
- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/responsive.html b/manual/fr/responsive.html deleted file mode 100644 index 1d76fee24e8432..00000000000000 --- a/manual/fr/responsive.html +++ /dev/null @@ -1,260 +0,0 @@ - - - Conception Réactive - - - - - - - - - - - - - -
-
-

Conception Réactive

-
-
-
-

Ceci est le deuxième article d'une série d'articles sur three.js. -Le premier article traitait des fondamentaux. -Si vous ne l'avez pas encore lu, vous voudrez peut-être commencer par là.

-

Cet article explique comment rendre votre application three.js réactive à toute situation. Rendre une page web réactive fait généralement référence à la capacité de la page à s'afficher correctement sur des écrans de différentes tailles, des ordinateurs de bureau aux tablettes et téléphones.

-

Pour three.js, il y a encore plus de situations à considérer. Par exemple, un éditeur 3D avec des contrôles à gauche, à droite, en haut ou en bas est quelque chose que nous pourrions vouloir gérer. Un diagramme interactif au milieu d'un document est un autre exemple.

-

Le dernier exemple que nous avions utilisait un simple canevas sans CSS et sans taille

-
<canvas id="c"></canvas>
-
-

Ce canevas a par défaut une taille de 300x150 pixels CSS.

-

Dans la plateforme web, la méthode recommandée pour définir la taille d'un élément est d'utiliser CSS.

-

Faisons en sorte que le canevas remplisse la page en ajoutant du CSS

-
<style>
-html, body {
-   margin: 0;
-   height: 100%;
-}
-#c {
-   width: 100%;
-   height: 100%;
-   display: block;
-}
-</style>
-
-

En HTML, le corps (body) a une marge de 5 pixels par défaut, donc définir la marge à 0 supprime cette marge. Définir la hauteur de html et body à 100 % leur permet de remplir la fenêtre. Sinon, ils ne sont que de la taille du contenu qui les remplit.

-

Ensuite, nous disons à l'élément id=c de prendre -100 % de la taille de son conteneur, qui est ici le corps du -document.

-

Enfin, nous définissons son mode display à block. Le mode d'affichage par défaut d'un canevas est inline. Les éléments inline -peuvent finir par ajouter des espaces blancs à l'affichage. En -définissant le canevas à block, ce problème disparaît.

-

Voici le résultat

-

- -

-

Vous pouvez voir que le canevas remplit maintenant la page, mais il y a 2 -problèmes. Premièrement, nos cubes sont étirés. Ce ne sont pas des cubes, -mais plutôt des boîtes. Trop hauts ou trop larges. Ouvrez l'exemple -dans sa propre fenêtre et redimensionnez-le. Vous verrez comment -les cubes s'étirent en largeur et en hauteur.

-

-

Le deuxième problème est qu'ils semblent avoir une faible résolution ou être pixellisés et -flous. Élargissez beaucoup la fenêtre et vous verrez vraiment -le problème.

-

-

Résolvons d'abord le problème de l'étirement. Pour ce faire, nous devons -définir l'aspect de la caméra sur l'aspect de la taille d'affichage du canevas. -Nous pouvons le faire en examinant les propriétés clientWidth -et clientHeight du canevas.

-

Nous allons mettre à jour notre boucle de rendu comme ceci

-
function render(time) {
-  time *= 0.001;
-
-+  const canvas = renderer.domElement;
-+  camera.aspect = canvas.clientWidth / canvas.clientHeight;
-+  camera.updateProjectionMatrix();
-
-  ...
-
-

Maintenant, les cubes ne devraient plus être déformés.

-

- -

-

Ouvrez l'exemple dans une fenêtre séparée et redimensionnez la fenêtre. -Vous devriez voir que les cubes ne sont plus étirés en hauteur ou en largeur. -Ils conservent le bon aspect quelle que soit la taille de la fenêtre.

-

-

Maintenant, résolvons le problème de la pixellisation.

-

Les éléments Canvas ont 2 tailles. Une taille est la taille à laquelle le canevas est affiché -sur la page. C'est ce que nous définissons avec CSS. L'autre taille est le -nombre de pixels dans le canevas lui-même. Ce n'est pas différent d'une image. -Par exemple, nous pourrions avoir une image de 128x64 pixels et -l'afficher en 400x200 pixels en utilisant CSS.

-
<img src="some128x64image.jpg" style="width:400px; height:200px">
-
-

La taille interne d'un canevas, sa résolution, est souvent appelée sa taille de drawingbuffer. -Dans three.js, nous pouvons définir la taille du drawingbuffer du canevas en appelant renderer.setSize. -Quelle taille devrions-nous choisir ? La réponse la plus évidente est « la même taille que celle affichée par le canevas ». -Encore une fois, pour ce faire, nous pouvons examiner les propriétés clientWidth et clientHeight -du canevas.

-

Écrivons une fonction qui vérifie si le canevas du renderer n'a pas -déjà la taille à laquelle il est affiché et, si ce n'est pas le cas, définit sa taille.

-
function resizeRendererToDisplaySize(renderer) {
-  const canvas = renderer.domElement;
-  const width = canvas.clientWidth;
-  const height = canvas.clientHeight;
-  const needResize = canvas.width !== width || canvas.height !== height;
-  if (needResize) {
-    renderer.setSize(width, height, false);
-  }
-  return needResize;
-}
-
-

Notez que nous vérifions si le canevas a réellement besoin d'être redimensionné. Le redimensionnement du canevas -est une partie intéressante de la spécification du canevas, et il est préférable de ne pas définir la même -taille si elle est déjà celle que nous souhaitons.

-

Une fois que nous savons si nous devons redimensionner ou non, nous appelons alors renderer.setSize et -passons la nouvelle largeur et hauteur. Il est important de passer false à la fin. -renderer.setSize définit par défaut la taille CSS du canevas, mais ce n'est pas -ce que nous voulons. Nous voulons que le navigateur continue à fonctionner comme il le fait pour tous les autres -éléments, c'est-à-dire en utilisant CSS pour déterminer la taille d'affichage de l'élément. Nous ne -voulons pas que les canevas utilisés par three soient différents des autres éléments.

-

Notez que notre fonction retourne true si le canevas a été redimensionné. -Nous pouvons l'utiliser pour vérifier s'il y a d'autres éléments que nous devrions mettre à jour. Modifions -notre boucle de rendu pour utiliser la nouvelle fonction

-
function render(time) {
-  time *= 0.001;
-
-+  if (resizeRendererToDisplaySize(renderer)) {
-+    const canvas = renderer.domElement;
-+    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-+    camera.updateProjectionMatrix();
-+  }
-
-  ...
-
-

Comme l'aspect ne changera que si la taille d'affichage du canevas a changé, -nous ne définissons l'aspect de la caméra que si resizeRendererToDisplaySize -retourne true.

-

- -

-

Il devrait maintenant rendre avec une résolution qui correspond à la taille d'affichage -du canevas.

-

Pour illustrer le fait de laisser CSS gérer le redimensionnement, prenons -notre code et mettons-le dans un fichier .js séparé. -Voici ensuite quelques exemples supplémentaires où nous laissons CSS choisir la taille, et notez -que nous n'avons eu à modifier aucun code pour qu'ils fonctionnent.

-

Mettons nos cubes au milieu d'un paragraphe de texte.

-

- -

-

et voici notre même code utilisé dans une disposition de style éditeur -où la zone de contrôle à droite peut être redimensionnée.

-

- -

-

Le point important à noter est qu'aucun code n'a été modifié. Seuls notre HTML et notre CSS -ont changé.

-

Gestion des écrans HD-DPI

-

HD-DPI signifie écrans à haute densité de points par pouce (high-density dot per inch displays). -C'est le cas de la plupart des Macs actuels et de nombreuses machines Windows, -ainsi que de la quasi-totalité des smartphones.

-

La façon dont cela fonctionne dans le navigateur est qu'ils utilisent -des pixels CSS pour définir les tailles, qui sont censées être les mêmes -quelle que soit la résolution de l'écran. Le navigateur -se contentera de rendre le texte avec plus de détails, mais avec la même -taille physique.

-

Il existe différentes façons de gérer les écrans HD-DPI avec three.js.

-

La première consiste simplement à ne rien faire de spécial. C'est sans doute -la méthode la plus courante. Le rendu graphique 3D nécessite beaucoup de -puissance de traitement GPU. Les GPU mobiles ont moins de puissance que les ordinateurs de bureau, -du moins en 2018, et pourtant les téléphones portables ont souvent des écrans à très -haute résolution. Les téléphones haut de gamme actuels ont un rapport HD-DPI -de 3x, ce qui signifie que pour chaque pixel d'un écran non HD-DPI, -ces téléphones ont 9 pixels. Cela signifie qu'ils doivent effectuer 9 fois -le rendu.

-

Calculer 9 fois les pixels représente beaucoup de travail, donc si nous -laissons le code tel quel, nous calculerons 1x les pixels et le -navigateur se contentera de l'afficher à 3x la taille (3x par 3x = 9x pixels).

-

Pour toute application three.js lourde, c'est probablement ce que vous voudrez, -sinon vous risquez d'avoir une fréquence d'images lente.

-

Cela dit, si vous souhaitez réellement rendre à la résolution -de l'appareil, il existe plusieurs façons de le faire dans three.js.

-

L'une consiste à indiquer à three.js un multiplicateur de résolution en utilisant renderer.setPixelRatio. -Vous demandez au navigateur quel est le multiplicateur entre les pixels CSS et les pixels de l'appareil, -et vous le passez à three.js

-
 renderer.setPixelRatio(window.devicePixelRatio);
-

Après cela, tout appel à renderer.setSize utilisera magiquement -la taille demandée multipliée par le rapport de pixels -que vous avez passé. Ceci est fortement DÉCONSEILLÉ. Voir ci-dessous

-

L'autre méthode consiste à le faire vous-même lorsque vous redimensionnez le canevas.

-
    function resizeRendererToDisplaySize(renderer) {
-      const canvas = renderer.domElement;
-      const pixelRatio = window.devicePixelRatio;
-      const width  = Math.floor( canvas.clientWidth  * pixelRatio );
-      const height = Math.floor( canvas.clientHeight * pixelRatio );
-      const needResize = canvas.width !== width || canvas.height !== height;
-      if (needResize) {
-        renderer.setSize(width, height, false);
-      }
-      return needResize;
-    }
-
-

Cette seconde méthode est objectivement meilleure. Pourquoi ? Parce que cela signifie que j'obtiens ce que je demande. -Il existe de nombreux cas lors de l'utilisation de three.js où nous devons connaître la taille réelle -du drawingBuffer du canevas. Par exemple, lors de la création d'un filtre de post-traitement, -ou si nous créons un shader qui accède à gl_FragCoord, si nous faisons -une capture d'écran, ou lisons des pixels pour la sélection GPU, pour dessiner dans un canevas 2D, -etc... Il existe de nombreux cas où si nous utilisons setPixelRatio, notre taille réelle sera différente -de la taille demandée, et nous devrons deviner quand utiliser la taille -que nous avons demandée et quand utiliser la taille que three.js utilise réellement. -En le faisant nous-mêmes, nous savons toujours que la taille utilisée est la taille que nous avons demandée. -Il n'y a pas de cas particulier où de la magie opère en coulisses.

-

Voici un exemple utilisant le code ci-dessus.

-

- -

-

Il peut être difficile de voir la différence, mais si vous avez un écran HD-DPI -et que vous comparez cet exemple à ceux ci-dessus, vous devriez -remarquer que les bords sont plus nets.

-

Cet article a couvert un sujet très basique mais fondamental. -Ensuite, passons rapidement en revue les primitives de base que three.js fournit.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/scenegraph.html b/manual/fr/scenegraph.html deleted file mode 100644 index 3f169c54201ebb..00000000000000 --- a/manual/fr/scenegraph.html +++ /dev/null @@ -1,475 +0,0 @@ - - - Graphe de scène - - - - - - - - - - - - - -
-
-

Graphe de scène

-
-
-
-

Cet article fait partie d'une série d'articles sur three.js. Le -premier article est les bases de three.js. Si -vous ne l'avez pas encore lu, vous pourriez envisager de commencer par là.

-

Le cœur de Three.js est sans doute son graphe de scène. Un graphe de scène dans un moteur 3D -est une hiérarchie de nœuds dans un graphe où chaque nœud représente -un espace local.

-

-

C'est un peu abstrait, alors essayons de donner quelques exemples.

-

Un exemple pourrait être le système solaire : soleil, terre, lune.

-

-

La Terre tourne autour du Soleil. La Lune tourne autour de la Terre. La Lune -se déplace en cercle autour de la Terre. Du point de -vue de la Lune, elle tourne dans l'« espace local » de la Terre. Même si -son mouvement par rapport au Soleil est une courbe folle ressemblant à un spirographe, du point de vue de la Lune, elle n'a qu'à se soucier de tourner -autour de l'espace local de la Terre.

-

- -
- -

-

Pour le voir autrement, vous qui vivez sur Terre n'avez pas à penser -à la rotation de la Terre sur son axe ni à sa rotation autour du -Soleil. Vous marchez, conduisez, nagez ou courez comme si la Terre -ne bougeait ni ne tournait pas du tout. Vous marchez, conduisez, nagez, courez et vivez -dans l'« espace local » de la Terre, même si, par rapport au Soleil, vous tournez -autour de la Terre à environ 1600 kilomètres par heure et autour -du Soleil à environ 108 000 kilomètres par heure. Votre position dans le système solaire -est similaire à celle de la Lune ci-dessus, mais vous n'avez pas à vous en soucier. -Vous vous préoccupez simplement de votre position par rapport à la Terre dans son -« espace local ».

-

Prenons les choses étape par étape. Imaginez que nous voulions faire -un diagramme du soleil, de la terre et de la lune. Nous commencerons par le soleil en -créant simplement une sphère et en la plaçant à l'origine. Remarque : Nous utilisons -le soleil, la terre, la lune pour illustrer comment utiliser un graphe de scène. Bien sûr, -le vrai soleil, la terre et la lune utilisent la physique, mais pour nos besoins, nous allons -simuler cela avec un graphe de scène.

-
// un tableau d'objets dont la rotation doit être mise à jour
-const objects = [];
-
-// utiliser une seule sphère pour tout
-const radius = 1;
-const widthSegments = 6;
-const heightSegments = 6;
-const sphereGeometry = new THREE.SphereGeometry(
-    radius, widthSegments, heightSegments);
-
-const sunMaterial = new THREE.MeshPhongMaterial({emissive: 0xFFFF00});
-const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
-sunMesh.scale.set(5, 5, 5);  // agrandir le soleil
-scene.add(sunMesh);
-objects.push(sunMesh);
-
-

Nous utilisons une sphère avec très peu de polygones. Seulement 6 subdivisions autour de son équateur. -C'est pour qu'il soit facile de voir la rotation.

-

Nous allons réutiliser la même sphère pour tout, nous allons donc définir une échelle -de 5x pour le maillage du soleil.

-

Nous définissons également la propriété emissive du matériau phong en jaune. La propriété emissive d'un matériau phong est essentiellement la couleur qui sera dessinée sans lumière frappant -la surface. La lumière est ajoutée à cette couleur.

-

Plaçons également une seule lumière ponctuelle au centre de la scène. Nous reviendrons plus tard sur -les détails des lumières ponctuelles, mais pour l'instant, la version simple est qu'une lumière ponctuelle -représente la lumière qui émane d'un point unique.

-
{
-  const color = 0xFFFFFF;
-  const intensity = 500;
-  const light = new THREE.PointLight(color, intensity);
-  scene.add(light);
-}
-
-

Pour faciliter la visualisation, nous allons placer la caméra juste au-dessus de l'origine, -regardant vers le bas. La façon la plus simple de faire cela est d'utiliser la fonction lookAt. La fonction lookAt -orientera la caméra depuis sa position pour « regarder » la position -que nous lui passons. Cependant, avant de faire cela, nous devons indiquer à la caméra -dans quelle direction se trouve le haut de la caméra, ou plutôt quelle direction est le « haut » pour la -caméra. Dans la plupart des situations, Y positif vers le haut est suffisant, mais puisque -nous regardons directement vers le bas, nous devons indiquer à la caméra que Z positif est vers le haut.

-
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-camera.position.set(0, 50, 0);
-camera.up.set(0, 0, 1);
-camera.lookAt(0, 0, 0);
-
-

Dans la boucle de rendu, adaptée des exemples précédents, nous faisons pivoter tous -les objets de notre tableau objects avec ce code.

-
objects.forEach((obj) => {
-  obj.rotation.y = time;
-});
-
-

Comme nous avons ajouté le sunMesh au tableau objects, il va tourner.

-

- -

-

Maintenant, ajoutons la Terre.

-
const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
-const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
-earthMesh.position.x = 10;
-scene.add(earthMesh);
-objects.push(earthMesh);
-
-

Nous créons un matériau bleu, mais nous lui donnons une petite quantité de bleu émissif -afin qu'il ressorte sur notre fond noir.

-

Nous utilisons la même sphereGeometry avec notre nouveau earthMaterial bleu pour créer -un earthMesh. Nous le positionnons à 10 unités à gauche du soleil -et l'ajoutons à la scène. Comme nous l'avons ajouté à notre tableau objects, il tournera aussi.

-

- -

-

Vous pouvez voir que le soleil et la terre tournent, mais la terre ne -tourne pas autour du soleil. Faisons de la terre un enfant du soleil

-
-scene.add(earthMesh);
-+sunMesh.add(earthMesh);
-
-

et...

-

- -

-

Que s'est-il passé ? Pourquoi la Terre a-t-elle la même taille que le Soleil et pourquoi est-elle si loin ? -J'ai en fait dû déplacer la caméra de 50 unités au-dessus à 150 unités au-dessus pour voir la Terre.

-

Nous avons fait du earthMesh un enfant du sunMesh. L'échelle du sunMesh est -réglée à 5x avec sunMesh.scale.set(5, 5, 5). Cela signifie que -l'espace local du sunMesh est 5 fois plus grand. Tout ce qui est placé dans cet espace -sera multiplié par 5. Cela signifie que la terre est maintenant 5 fois plus grande et -que sa distance par rapport au soleil (earthMesh.position.x = 10) est également -multipliée par 5.

-

Notre graphe de scène ressemble actuellement à ceci

-

-

Pour corriger cela, ajoutons un nœud de graphe de scène vide. Nous allons rendre le soleil et la terre -enfants de ce nœud.

-
+const solarSystem = new THREE.Object3D();
-+scene.add(solarSystem);
-+objects.push(solarSystem);
-
-const sunMaterial = new THREE.MeshPhongMaterial({emissive: 0xFFFF00});
-const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
-sunMesh.scale.set(5, 5, 5);
--scene.add(sunMesh);
-+solarSystem.add(sunMesh);
-objects.push(sunMesh);
-
-const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
-const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
-earthMesh.position.x = 10;
--sunMesh.add(earthMesh);
-+solarSystem.add(earthMesh);
-objects.push(earthMesh);
-
-

Ici, nous avons créé un Object3D. Comme un Mesh, c'est aussi un nœud dans le graphe de scène, -mais contrairement à un Mesh, il n'a ni matériau ni géométrie. Il représente simplement un espace local.

-

Notre nouveau graphe de scène ressemble à ceci

-

-

Le sunMesh et le earthMesh sont tous deux enfants du solarSystem. Les 3 -tournent et maintenant, parce que le earthMesh n'est pas un enfant du sunMesh, -il n'est plus mis à l'échelle par 5x.

-

- -

-

Bien mieux. La Terre est plus petite que le soleil et elle tourne autour du soleil -tout en tournant sur elle-même.

-

En continuant sur ce même schéma, ajoutons une lune.

-
+const earthOrbit = new THREE.Object3D();
-+earthOrbit.position.x = 10;
-+solarSystem.add(earthOrbit);
-+objects.push(earthOrbit);
-
-const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
-const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
--earthMesh.position.x = 10; // notez que ce décalage est déjà défini dans l'objet THREE.Object3D parent "earthOrbit"
--solarSystem.add(earthMesh);
-+earthOrbit.add(earthMesh);
-objects.push(earthMesh);
-
-+const moonOrbit = new THREE.Object3D();
-+moonOrbit.position.x = 2;
-+earthOrbit.add(moonOrbit);
-
-+const moonMaterial = new THREE.MeshPhongMaterial({color: 0x888888, emissive: 0x222222});
-+const moonMesh = new THREE.Mesh(sphereGeometry, moonMaterial);
-+moonMesh.scale.set(.5, .5, .5);
-+moonOrbit.add(moonMesh);
-+objects.push(moonMesh);
-
-

Encore une fois, nous avons ajouté d'autres nœuds de graphe de scène invisibles. Le premier, un Object3D appelé earthOrbit -et auquel nous avons ajouté le earthMesh et le moonOrbit, également nouveau. Nous avons ensuite ajouté le moonMesh -au moonOrbit. Le nouveau graphe de scène ressemble à ceci.

-

-

et voici le résultat

-

- -

-

Vous pouvez voir que la lune suit le motif spirographe montré en haut -de cet article, mais nous n'avons pas eu à le calculer manuellement. Nous avons simplement -configuré notre graphe de scène pour qu'il le fasse pour nous.

-

Il est souvent utile de dessiner quelque chose pour visualiser les nœuds dans le graphe de scène. -Three.js dispose de quelques ummmm, helpers utiles pour ummm, ... aider à cela.

-

L'un s'appelle AxesHelper. Il dessine 3 lignes représentant les axes -locaux X, -Y et -Z. Ajoutons-en un à chaque nœud que nous -avons créé.

-
// ajouter un AxesHelper à chaque nœud
-objects.forEach((node) => {
-  const axes = new THREE.AxesHelper();
-  axes.material.depthTest = false;
-  axes.renderOrder = 1;
-  node.add(axes);
-});
-
-

Dans notre cas, nous voulons que les axes apparaissent même s'ils sont à l'intérieur des sphères. -Pour ce faire, nous réglons la propriété depthTest de leur matériau sur false, ce qui signifie qu'ils ne -vérifieront pas s'ils dessinent derrière quelque chose d'autre. Nous réglons également -leur renderOrder à 1 (la valeur par défaut est 0) afin qu'ils soient dessinés après -toutes les sphères. Sinon, une sphère pourrait dessiner par-dessus et les masquer.

-

- -

-

Nous pouvons voir les axes -x (rouge) et -z (bleu). Comme nous regardons -directement vers le bas et que chacun de nos objets ne tourne que autour de son -axe y, nous ne voyons pas beaucoup les axes y (vert).

-

Il peut être difficile d'en voir certains car il y a 2 paires d'axes qui se chevauchent. Le sunMesh -et le solarSystem sont tous deux à la même position. De même, le earthMesh et -le earthOrbit sont à la même position. Ajoutons quelques contrôles simples pour nous permettre -de les activer/désactiver pour chaque nœud. -Tant qu'on y est, ajoutons également un autre helper appelé GridHelper. Il -crée une grille 2D sur le plan XZ. Par défaut, la grille est de 10x10 unités.

-

Nous allons également utiliser lil-gui, une bibliothèque d'interface utilisateur -très populaire dans les projets three.js. lil-gui prend un -objet et le nom d'une propriété sur cet objet et, en fonction du type de la propriété, -crée automatiquement une interface utilisateur pour manipuler cette propriété.

-

Nous voulons créer à la fois un GridHelper et un AxesHelper pour chaque nœud. Nous avons besoin -d'une étiquette pour chaque nœud, nous allons donc nous débarrasser de l'ancienne boucle et passer à l'appel -d'une fonction pour ajouter les helpers pour chaque nœud

-
-// ajouter un AxesHelper à chaque nœud
--objects.forEach((node) => {
--  const axes = new THREE.AxesHelper();
--  axes.material.depthTest = false;
--  axes.renderOrder = 1;
--  node.add(axes);
--});
-
-+function makeAxisGrid(node, label, units) {
-+  const helper = new AxisGridHelper(node, units);
-+  gui.add(helper, 'visible').name(label);
-+}
-+
-+makeAxisGrid(solarSystem, 'solarSystem', 25);
-+makeAxisGrid(sunMesh, 'sunMesh');
-+makeAxisGrid(earthOrbit, 'earthOrbit');
-+makeAxisGrid(earthMesh, 'earthMesh');
-+makeAxisGrid(moonOrbit, 'moonOrbit');
-+makeAxisGrid(moonMesh, 'moonMesh');
-
-

makeAxisGrid crée un AxisGridHelper, une classe que nous allons créer -pour rendre lil-gui heureux. Comme indiqué ci-dessus, lil-gui -créera automatiquement une interface utilisateur qui manipule la propriété nommée -de certains objets. Il créera une interface utilisateur différente en fonction du type -de propriété. Nous voulons qu'il crée une case à cocher, nous devons donc spécifier -une propriété bool. Mais, nous voulons que les axes et la grille -apparaissent/disparaissent en fonction d'une seule propriété, nous allons donc créer une classe -qui a un getter et un setter pour une propriété. De cette façon, nous pouvons laisser lil-gui -penser qu'il manipule une seule propriété, mais en interne, nous pouvons définir -la propriété visible à la fois de l'AxesHelper et du GridHelper pour un nœud.

-
// Active/désactive la visibilité des axes et de la grille
-// lil-gui nécessite une propriété qui renvoie un booléen
-// pour décider de créer une case à cocher, nous créons donc un setter
-// et un getter pour `visible` que nous pouvons dire à lil-gui
-// de regarder.
-class AxisGridHelper {
-  constructor(node, units = 10) {
-    const axes = new THREE.AxesHelper();
-    axes.material.depthTest = false;
-    axes.renderOrder = 2;  // après la grille
-    node.add(axes);
-
-    const grid = new THREE.GridHelper(units, units);
-    grid.material.depthTest = false;
-    grid.renderOrder = 1;
-    node.add(grid);
-
-    this.grid = grid;
-    this.axes = axes;
-    this.visible = false;
-  }
-  get visible() {
-    return this._visible;
-  }
-  set visible(v) {
-    this._visible = v;
-    this.grid.visible = v;
-    this.axes.visible = v;
-  }
-}
-
-

Une chose à noter est que nous avons défini le renderOrder de l'AxesHelper -à 2 et celui du GridHelper à 1 afin que les axes soient dessinés après la grille. -Sinon, la grille pourrait écraser les axes.

-

- -

-

Activez le solarSystem et vous verrez que la terre se trouve exactement à 10 -unités du centre, comme nous l'avons réglé ci-dessus. Vous pouvez voir comment la -terre se trouve dans l'espace local du solarSystem. De même, si vous -activez l'earthOrbit, vous verrez que la lune se trouve exactement à 2 unités -du centre de l'espace local de l'earthOrbit.

-

Quelques autres exemples de graphes de scène. Une automobile dans un monde de jeu simple pourrait avoir un graphe de scène comme celui-ci

-

-

Si vous déplacez la carrosserie de la voiture, toutes les roues bougeront avec elle. Si vous vouliez que la carrosserie -rebondisse séparément des roues, vous pourriez rendre la carrosserie et les roues enfants d'un nœud « châssis » -qui représente le châssis de la voiture.

-

Un autre exemple est un humain dans un monde de jeu.

-

-

Vous pouvez voir que le graphe de scène devient assez complexe pour un humain. En fait, -le graphe de scène ci-dessus est simplifié. Par exemple, vous pourriez l'étendre -pour couvrir chaque doigt (au moins 28 nœuds supplémentaires) et chaque orteil -(encore 28 nœuds) plus ceux pour le visage et la mâchoire, les yeux et peut-être plus.

-

Créons un graphe de scène semi-complexe. Nous allons faire un char. Le char aura -6 roues et une tourelle. Le char suivra un chemin. Il y aura une sphère qui -se déplacera et le char ciblera la sphère.

-

Voici le graphe de scène. Les maillages sont colorés en vert, les Object3D en bleu, -les lumières en or et les caméras en violet. Une caméra n'a pas été ajoutée -au graphe de scène.

-
- -

Regardez le code pour voir la configuration de tous ces nœuds.

-

Pour la cible, la chose que le char vise, il y a un targetOrbit -(Object3D) qui tourne simplement comme l'earthOrbit ci-dessus. Un -targetElevation (Object3D), qui est un enfant du targetOrbit, fournit un -décalage par rapport au targetOrbit et une élévation de base. Un autre -Object3D appelé targetBob est enfant de celui-ci et il monte et descend simplement -par rapport au targetElevation. Enfin, il y a le targetMesh qui est juste un cube que nous -faisons pivoter et dont nous changeons les couleurs

-
// déplacer la cible
-targetOrbit.rotation.y = time * .27;
-targetBob.position.y = Math.sin(time * 2) * 4;
-targetMesh.rotation.x = time * 7;
-targetMesh.rotation.y = time * 13;
-targetMaterial.emissive.setHSL(time * 10 % 1, 1, .25);
-targetMaterial.color.setHSL(time * 10 % 1, 1, .25);
-
-

Pour le char, il y a un Object3D appelé tank qui est utilisé pour déplacer tout ce qui se -trouve en dessous. Le code utilise une SplineCurve à laquelle il peut demander les positions -le long de cette courbe. 0.0 est le début de la courbe. 1.0 est la fin de la courbe. Il -demande la position actuelle où il place le char. Il demande ensuite une -position légèrement plus loin le long de la courbe et l'utilise pour orienter le char dans cette -direction en utilisant Object3D.lookAt.

-
const tankPosition = new THREE.Vector2();
-const tankTarget = new THREE.Vector2();
-
-...
-
-// déplacer le char
-const tankTime = time * .05;
-curve.getPointAt(tankTime % 1, tankPosition);
-curve.getPointAt((tankTime + 0.01) % 1, tankTarget);
-tank.position.set(tankPosition.x, 0, tankPosition.y);
-tank.lookAt(tankTarget.x, 0, tankTarget.y);
-
-

La tourelle sur le dessus du char est déplacée automatiquement en étant un enfant -du char. Pour la pointer vers la cible, nous demandons simplement la position mondiale de la cible -et ensuite utilisons à nouveau Object3D.lookAt.

-
const targetPosition = new THREE.Vector3();
-
-...
-
-// orienter la tourelle vers la cible
-targetMesh.getWorldPosition(targetPosition);
-turretPivot.lookAt(targetPosition);
-
-

Il y a une turretCamera qui est un enfant du turretMesh, donc -elle montera et descendra et tournera avec la tourelle. Nous la faisons -viser la cible.

-
// faire pointer la turretCamera vers la cible
-turretCamera.lookAt(targetPosition);
-
-

Il y a aussi un targetCameraPivot qui est un enfant du targetBob, donc il flotte -autour de la cible. Nous le faisons viser le char. Son but est de permettre à la -targetCamera d'être décalée par rapport à la cible elle-même. Si nous avions fait de la caméra -un enfant du targetBob et l'avions simplement fait pointer, elle serait à l'intérieur de la -cible.

-
// faire pointer le targetCameraPivot vers le char
-tank.getWorldPosition(targetPosition);
-targetCameraPivot.lookAt(targetPosition);
-
-

Enfin, nous faisons tourner toutes les roues

-
wheelMeshes.forEach((obj) => {
-  obj.rotation.x = time * 3;
-});
-
-

Pour les caméras, nous avons configuré un tableau de toutes les 4 caméras au moment de l'initialisation avec des descriptions.

-
const cameras = [
-  { cam: camera, desc: 'caméra détachée', },
-  { cam: turretCamera, desc: 'sur tourelle regardant la cible', },
-  { cam: targetCamera, desc: 'près de la cible regardant le char', },
-  { cam: tankCamera, desc: 'au-dessus de l'arrière du char', },
-];
-
-const infoElem = document.querySelector('#info');
-
-

et passons en revue nos caméras au moment du rendu.

-
const camera = cameras[time * .25 % cameras.length | 0];
-infoElem.textContent = camera.desc;
-
-

- -

-

J'espère que cela vous donne une idée du fonctionnement des graphes de scène et de la manière dont vous pourriez les utiliser. -Créer des nœuds Object3D et les rendre parents d'autres objets est une étape importante pour bien utiliser -un moteur 3D comme three.js. Souvent, il peut sembler qu'il soit nécessaire de faire des calculs mathématiques complexes -pour faire bouger et pivoter quelque chose comme vous le souhaitez. Par exemple, sans graphe de scène, -calculer le mouvement de la lune ou où placer les roues de la voiture par rapport à sa -carrosserie serait très compliqué, mais en utilisant un graphe de scène, cela devient beaucoup plus facile.

-

Ensuite, nous aborderons les matériaux.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/setup.html b/manual/fr/setup.html deleted file mode 100644 index 1d3ce4215fdb8c..00000000000000 --- a/manual/fr/setup.html +++ /dev/null @@ -1,84 +0,0 @@ - - - Configuration - - - - - - - - - - - - - -
-
-

Configuration

-
-
-
-

Cet article fait partie d'une série d'articles sur three.js. -Le premier article portait sur les fondamentaux de three.js. -Si vous ne l'avez pas encore lu, vous pourriez vouloir commencer par là.

-

Avant d'aller plus loin, nous devons parler de la configuration de votre -ordinateur en tant qu'environnement de développement. En particulier, pour des raisons de sécurité, -WebGL ne peut pas utiliser directement les images de votre disque dur. Cela signifie -que pour développer, vous devez utiliser un serveur web. Heureusement, -les serveurs web de développement sont très faciles à configurer et à utiliser.

-

Tout d'abord, si vous le souhaitez, vous pouvez télécharger l'intégralité de ce site à partir de ce lien. -Une fois téléchargé, double-cliquez sur le fichier zip pour décompresser les fichiers.

-

Ensuite, téléchargez l'un de ces serveurs web simples.

-

Si vous préférez un serveur web avec une interface utilisateur, il existe -Servez.

-

- -
- -

-

Il suffit de le pointer vers le dossier où vous avez décompressé les fichiers, cliquez sur "Démarrer", puis rendez-vous -dans votre navigateur à l'adresse http://localhost:8080/ ou si vous souhaitez -parcourir les exemples, allez à l'adresse http://localhost:8080/threejs.

-

Pour arrêter le service, cliquez sur stop ou quittez Servez.

-

Si vous préférez la ligne de commande (c'est mon cas), une autre solution est d'utiliser node.js. -Téléchargez-le, installez-le, puis ouvrez une fenêtre d'invite de commandes / console / terminal. Si vous êtes sous Windows, le programme d'installation ajoutera une "Invite de commandes Node" spéciale, utilisez donc celle-ci.

-

Ensuite, installez servez en tapant

-
npm -g install servez
-

Si vous êtes sur OSX, utilisez

-
sudo npm -g install servez
-

Une fois que vous avez fait cela, tapez

-
servez path/to/folder/where/you/unzipped/files
-

Ou si vous êtes comme moi

-
cd path/to/folder/where/you/unzipped/files
-servez
-

Il devrait afficher quelque chose comme

-

- -
- -

-

Puis, dans votre navigateur, allez à l'adresse http://localhost:8080/.

-

Si vous ne spécifiez pas de chemin, servez servira le dossier actuel.

-

Si aucune de ces options ne vous convient, -il existe de nombreux autres serveurs simples parmi lesquels choisir.

-

Maintenant que vous avez configuré un serveur, nous pouvons passer aux textures.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/shadertoy.html b/manual/fr/shadertoy.html deleted file mode 100644 index f7a6d74eaa053a..00000000000000 --- a/manual/fr/shadertoy.html +++ /dev/null @@ -1,322 +0,0 @@ - - - Three.js et Shadertoy - - - - - - - - - - - - - -
-
-

Three.js et Shadertoy

-
-
-
-

Shadertoy est un site web célèbre qui héberge des expériences de shaders incroyables. Les gens demandent souvent comment ils peuvent utiliser ces shaders avec Three.js.

-

Il est important de reconnaître que cela s'appelle ShaderTOY pour une raison. En général, les shaders shadertoy ne sont pas axés sur les meilleures pratiques. Il s'agit plutôt d'un défi amusant, similaire à dwitter (écrire du code en 140 caractères) ou js13kGames (faire un jeu en 13k ou moins).

-

Dans le cas de Shadertoy, le défi est : écrire une fonction qui, pour une position de pixel donnée, produit une couleur qui dessine quelque chose d'intéressant. C'est un défi amusant et de nombreux résultats sont incroyables. Mais ce n'est pas la meilleure pratique.

-

Comparez ce shader shadertoy incroyable qui dessine une ville entière

-
- -

En plein écran sur mon GPU, il tourne à environ 5 images par seconde. Comparez cela à un jeu comme Cities: Skylines

-
- -

Ce jeu tourne à 30-60 images par seconde sur la même machine car il utilise des techniques plus traditionnelles, dessinant des bâtiments faits de triangles avec des textures, etc...

-

Néanmoins, passons en revue l'utilisation d'un shader Shadertoy avec three.js.

-

C'est le shader shadertoy par défaut si vous choisissez "New" sur shadertoy.com, du moins en janvier 2019.

-
// Par iq: https://www.shadertoy.com/user/iq
-// licence: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
-void mainImage( out vec4 fragColor, in vec2 fragCoord )
-{
-    // Coordonnées normalisées des pixels (de 0 à 1)
-    vec2 uv = fragCoord/iResolution.xy;
-
-    // Couleur variable des pixels avec le temps
-    vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx+vec3(0,2,4));
-
-    // Sortie à l'écran
-    fragColor = vec4(col,1.0);
-}
-
-

Une chose importante à comprendre à propos des shaders est qu'ils sont écrits dans un langage appelé GLSL (Graphics Library Shading Language) conçu pour les mathématiques 3D qui inclut des types spéciaux. Ci-dessus, nous voyons vec4, vec2, vec3 comme 3 de ces types spéciaux. Un vec2 a 2 valeurs, un vec3 3, un vec4 4 valeurs. Ils peuvent être adressés de plusieurs manières. Les manières les plus courantes sont avec x, y, z et w comme dans

-
vec4 v1 = vec4(1.0, 2.0, 3.0, 4.0);
-float v2 = v1.x + v1.y;  // adds 1.0 + 2.0
-
-

Contrairement à JavaScript, GLSL ressemble plus à C/C++ où les variables doivent avoir leur type déclaré, donc au lieu de var v = 1.2; c'est float v = 1.2; déclarant v comme un nombre à virgule flottante.

-

Expliquer GLSL en détail dépasse le cadre de cet article. Pour un aperçu rapide, consultez cet article et peut-être poursuivez avec cette série.

-

Il convient de noter que, du moins en janvier 2019, shadertoy.com ne s'occupe que des fragment shaders. La responsabilité d'un fragment shader est, étant donné une position de pixel, de produire une couleur pour ce pixel.

-

En regardant la fonction ci-dessus, nous pouvons voir que le shader a un paramètre out appelé fragColor. out signifie output (sortie). C'est un paramètre pour lequel la fonction est censée fournir une valeur. Nous devons le définir à une certaine couleur.

-

Il a également un paramètre in (pour input, entrée) appelé fragCoord. C'est la coordonnée du pixel qui est sur le point d'être dessinée. Nous pouvons utiliser cette coordonnée pour décider d'une couleur. Si le canevas sur lequel nous dessinons est de 400x300 pixels, alors la fonction sera appelée 400x300 fois, soit 120 000 fois. À chaque fois, fragCoord sera une coordonnée de pixel différente.

-

Il y a 2 autres variables utilisées qui ne sont pas définies dans le code. L'une est iResolution. Elle est définie à la résolution du canevas. Si le canevas est de 400x300, alors iResolution serait 400,300, donc au fur et à mesure que les coordonnées des pixels changent, cela fait passer uv de 0.0 à 1.0 en travers et vers le haut de la texture. Travailler avec des valeurs normalisées rend souvent les choses plus faciles, c'est pourquoi la majorité des shaders shadertoy commencent par quelque chose comme ça.

-

L'autre variable non définie dans le shader est iTime. C'est le temps écoulé depuis le chargement de la page en secondes.

-

Dans le jargon des shaders, ces variables globales sont appelées variables uniformes. Elles sont appelées uniformes car elles ne changent pas, elles restent uniformes d'une itération du shader à la suivante. Il est important de noter qu'elles sont toutes spécifiques à shadertoy. Ce ne sont pas des variables GLSL officielles. Ce sont des variables que les créateurs de shadertoy ont inventées.

-

La documentation de Shadertoy en définit plusieurs autres. Pour l'instant, écrivons quelque chose qui gère les deux utilisées dans le shader ci-dessus.

-

La première chose à faire est de créer un simple plan qui remplit le canevas. Si vous ne l'avez pas encore lu, nous l'avons fait dans l'article sur les arrière-plans, alors prenons cet exemple mais retirons les cubes. C'est assez court, voici donc l'intégralité :

-
function main() {
-  const canvas = document.querySelector('#c');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-  renderer.autoClearColor = false;
-
-  const camera = new THREE.OrthographicCamera(
-    -1, // left
-     1, // right
-     1, // top
-    -1, // bottom
-    -1, // near,
-     1, // far
-  );
-  const scene = new THREE.Scene();
-  const plane = new THREE.PlaneGeometry(2, 2);
-  const material = new THREE.MeshBasicMaterial({
-      color: 'red',
-  });
-  scene.add(new THREE.Mesh(plane, material));
-
-  function resizeRendererToDisplaySize(renderer) {
-    const canvas = renderer.domElement;
-    const width = canvas.clientWidth;
-    const height = canvas.clientHeight;
-    const needResize = canvas.width !== width || canvas.height !== height;
-    if (needResize) {
-      renderer.setSize(width, height, false);
-    }
-    return needResize;
-  }
-
-  function render() {
-    resizeRendererToDisplaySize(renderer);
-
-    renderer.render(scene, camera);
-
-    requestAnimationFrame(render);
-  }
-
-  requestAnimationFrame(render);
-}
-
-main();
-
-

Comme expliqué dans l'article sur les arrière-plans, une OrthographicCamera avec ces paramètres et un plan de 2 unités remplira le canevas. Pour l'instant, tout ce que nous obtiendrons est un canevas rouge car notre plan utilise un MeshBasicMaterial rouge.

-

- -

-

Maintenant que nous avons quelque chose qui fonctionne, ajoutons le shader shadertoy.

-
const fragmentShader = `
-#include <common>
-
-uniform vec3 iResolution;
-uniform float iTime;
-
-// Par iq: https://www.shadertoy.com/user/iq
-// licence: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
-void mainImage( out vec4 fragColor, in vec2 fragCoord )
-{
-    // Coordonnées normalisées des pixels (de 0 à 1)
-    vec2 uv = fragCoord/iResolution.xy;
-
-    // Couleur variable des pixels avec le temps
-    vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx+vec3(0,2,4));
-
-    // Sortie à l'écran
-    fragColor = vec4(col,1.0);
-}
-
-void main() {
-  mainImage(gl_FragColor, gl_FragCoord.xy);
-}
-`;
-
-

Ci-dessus, nous avons déclaré les 2 variables uniformes dont nous avons parlé. Ensuite, nous avons inséré le code GLSL du shader de shadertoy. Enfin, nous avons appelé mainImage en lui passant gl_FragColor et gl_FragCoord.xy. gl_FragColor est une variable globale WebGL officielle que le shader est responsable de définir à la couleur qu'il souhaite pour le pixel actuel. gl_FragCoord est une autre variable globale WebGL officielle qui nous indique la coordonnée du pixel pour lequel nous choisissons actuellement une couleur.

-

Nous devons ensuite configurer les uniformes de three.js afin de pouvoir fournir des valeurs au shader.

-
const uniforms = {
-  iTime: { value: 0 },
-  iResolution:  { value: new THREE.Vector3() },
-};
-
-

Chaque uniforme dans THREE.js a un paramètre value. Cette valeur doit correspondre au type de l'uniforme.

-

Ensuite, nous passons le fragment shader et les uniformes à un ShaderMaterial.

-
-const material = new THREE.MeshBasicMaterial({
--    color: 'red',
--});
-+const material = new THREE.ShaderMaterial({
-+  fragmentShader,
-+  uniforms,
-+});
-
-

et avant de rendre, nous devons définir les valeurs des uniformes

-
-function render() {
-+function render(time) {
-+  time *= 0.001;  // convertir en secondes
-
-  resizeRendererToDisplaySize(renderer);
-
-+  const canvas = renderer.domElement;
-+  uniforms.iResolution.value.set(canvas.width, canvas.height, 1);
-+  uniforms.iTime.value = time;
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-
-

Note : Je n'ai aucune idée pourquoi iResolution est un vec3 et ce que contient la 3ème valeur n'est pas documenté sur shadertoy.com. Elle n'est pas utilisée ci-dessus, donc je la définis juste à 1 pour l'instant. ¯\_(ツ)_/¯

-
-

- -

-

Cela correspond à ce que nous voyons sur Shadertoy pour un nouveau shader, du moins en janvier 2019 😉. Que fait le shader ci-dessus ?

-
    -
  • uv va de 0 à 1.
  • -
  • cos(uv.xyx) nous donne 3 valeurs de cosinus sous forme de vec3. Une pour uv.x, une autre pour uv.y et une autre pour uv.x à nouveau.
  • -
  • L'ajout du temps, cos(iTime+uv.xyx), les rend animés.
  • -
  • L'ajout de vec3(0,2,4) comme dans cos(iTime+uv.xyx+vec3(0,2,4)) décale les ondes cosinusoïdales
  • -
  • cos va de -1 à 1, donc 0.5 * 0.5 + cos(...) convertit de -1 <-> 1 à 0.0 <-> 1.0
  • -
  • les résultats sont ensuite utilisés comme couleur RVB pour le pixel actuel
  • -
-

Un petit changement facilitera la visualisation des ondes cosinusoïdales. Actuellement, uv ne va que de 0 à 1. Un cosinus se répète à 2π, alors faisons-le aller de 0 à 40 en multipliant par 40.0. Cela devrait le faire se répéter environ 6,3 fois.

-
-vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx+vec3(0,2,4));
-+vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx*40.0+vec3(0,2,4));
-
-

En comptant ci-dessous, je vois environ 6,3 répétitions. Nous pouvons voir le bleu entre le rouge car il est décalé de 4 via le +vec3(0,2,4). Sans cela, le bleu et le rouge se chevaucheraient parfaitement, créant du violet.

-

- -

-

Savoir à quel point les entrées sont simples et ensuite voir des résultats comme un canal urbain, une forêt, un escargot, un champignon rend le défi d'autant plus impressionnant. Espérons qu'ils expliquent également clairement pourquoi ce n'est généralement pas la bonne approche par rapport aux méthodes plus traditionnelles de création de scènes à partir de triangles. Le fait qu'il faille faire autant de calculs pour déterminer la couleur de chaque pixel signifie que ces exemples tournent très lentement.

-

Certains shaders shadertoy prennent des textures en entrée, comme celui-ci.

-
// Par Daedelus: https://www.shadertoy.com/user/Daedelus
-// licence: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
-#define TIMESCALE 0.25
-#define TILES 8
-#define COLOR 0.7, 1.6, 2.8
-
-void mainImage( out vec4 fragColor, in vec2 fragCoord )
-{
-    vec2 uv = fragCoord.xy / iResolution.xy;
-    uv.x *= iResolution.x / iResolution.y;
-
-    vec4 noise = texture2D(iChannel0, floor(uv * float(TILES)) / float(TILES));
-    float p = 1.0 - mod(noise.r + noise.g + noise.b + iTime * float(TIMESCALE), 1.0);
-    p = min(max(p * 3.0 - 1.8, 0.1), 2.0);
-
-    vec2 r = mod(uv * float(TILES), 1.0);
-    r = vec2(pow(r.x - 0.5, 2.0), pow(r.y - 0.5, 2.0));
-    p *= 1.0 - pow(min(1.0, 12.0 * dot(r, r)), 2.0);
-
-    fragColor = vec4(COLOR, 1.0) * p;
-}
-
-

Passer une texture à un shader est similaire à en passer une à un matériau normal, mais nous devons configurer la texture sur les uniformes.

-

Tout d'abord, ajoutons l'uniforme pour la texture au shader. Ils sont appelés sampler2D en GLSL.

-
const fragmentShader = `
-#include <common>
-
-uniform vec3 iResolution;
-uniform float iTime;
-+uniform sampler2D iChannel0;
-
-...
-
-

Ensuite, nous pouvons charger une texture comme nous l'avons vu ici et affecter la valeur de l'uniforme.

-
+const loader = new THREE.TextureLoader();
-+const texture = loader.load('resources/images/bayer.png');
-+texture.minFilter = THREE.NearestFilter;
-+texture.magFilter = THREE.NearestFilter;
-+texture.wrapS = THREE.RepeatWrapping;
-+texture.wrapT = THREE.RepeatWrapping;
-const uniforms = {
-  iTime: { value: 0 },
-  iResolution:  { value: new THREE.Vector3() },
-+  iChannel0: { value: texture },
-};
-
-

- -

-

Jusqu'à présent, nous avons utilisé les shaders Shadertoy tels qu'ils sont utilisés sur Shadertoy.com, à savoir pour couvrir le canevas. Il n'y a cependant aucune raison de nous limiter à ce cas d'utilisation. L'important à retenir est que les fonctions que les gens écrivent sur shadertoy prennent généralement juste une entrée fragCoord et une iResolution. fragCoord n'a pas à provenir des coordonnées de pixels ; nous pourrions utiliser autre chose, comme des coordonnées de texture, et les utiliser ensuite un peu comme d'autres textures. Cette technique d'utilisation d'une fonction pour générer des textures est souvent appelée une texture procédurale.

-

Modifions le shader ci-dessus pour faire cela. La chose la plus simple à faire pourrait être de prendre les coordonnées de texture que three.js fournit normalement, de les multiplier par iResolution et de les passer pour fragCoords.

-

Pour ce faire, nous ajoutons un varying. Un varying est une valeur passée du vertex shader au fragment shader qui est interpolée (ou varie) entre les sommets. Pour l'utiliser dans notre fragment shader, nous la déclarons. Three.js fait référence à ses coordonnées de texture comme uv avec le v devant signifiant varying (variable).

-
...
-
-+varying vec2 vUv;
-
-void main() {
--  mainImage(gl_FragColor, gl_FragCoord.xy);
-+  mainImage(gl_FragColor, vUv * iResolution.xy);
-}
-
-

Ensuite, nous devons également fournir notre propre vertex shader. Voici un vertex shader three.js minimal assez courant. Three.js déclare et fournira des valeurs pour uv, projectionMatrix, modelViewMatrix et position.

-
const vertexShader = `
-  varying vec2 vUv;
-  void main() {
-    vUv = uv;
-    gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
-  }
-`;
-
-

Nous devons passer le vertex shader au ShaderMaterial

-
const material = new THREE.ShaderMaterial({
-  vertexShader,
-  fragmentShader,
-  uniforms,
-});
-
-

Nous pouvons définir la valeur de l'uniforme iResolution au moment de l'initialisation car elle ne changera plus.

-
const uniforms = {
-  iTime: { value: 0 },
--  iResolution:  { value: new THREE.Vector3() },
-+  iResolution:  { value: new THREE.Vector3(1, 1, 1) },
-  iChannel0: { value: texture },
-};
-
-

et nous n'avons plus besoin de la définir au moment du rendu

-
-const canvas = renderer.domElement;
--uniforms.iResolution.value.set(canvas.width, canvas.height, 1);
-uniforms.iTime.value = time;
-
-

Sinon, j'ai copié à nouveau la caméra originale et le code qui configure 3 cubes en rotation de l'article sur la réactivité. Le résultat :

-

- -

-

J'espère que cela vous donnera au moins une base sur la façon d'utiliser un shader shadertoy avec three.js. Encore une fois, il est important de se rappeler que la plupart des shaders shadertoy sont un défi intéressant (tout dessiner avec une seule fonction) plutôt que la méthode recommandée pour réellement afficher des choses de manière performante. Néanmoins, ils sont incroyables, impressionnants, beaux, et vous pouvez apprendre énormément en voyant comment ils fonctionnent.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/shadows.html b/manual/fr/shadows.html deleted file mode 100644 index a1d75a9e98d8d2..00000000000000 --- a/manual/fr/shadows.html +++ /dev/null @@ -1,464 +0,0 @@ - - - Ombres - - - - - - - - - - - - - -
-
-

Ombres

-
-
-
-

Cet article fait partie d'une série d'articles sur three.js. Le -premier article est les fondamentaux de three.js. Si -vous ne l'avez pas encore lu et que vous débutez avec three.js, vous pourriez envisager de -commencer par là. L'article précédent portait sur les caméras, ce qui est -important à lire avant de lire cet article, tout comme -l'article précédent sur les lumières.

-

Les ombres sur ordinateur peuvent être un sujet complexe. Il existe diverses -solutions et toutes impliquent des compromis, y compris les solutions -disponibles dans three.js.

-

Three.js utilise par défaut des cartes d'ombres. Le fonctionnement d'une carte d'ombres -est le suivant : pour chaque lumière qui projette des ombres, tous les objets marqués pour projeter -des ombres sont rendus du point de vue de la lumière. **LISEZ CELA -À NOUVEAU !** et laissez-le s'imprégner.

-

En d'autres termes, si vous avez 20 objets et 5 lumières, et que -les 20 objets projettent des ombres et les 5 lumières projettent -des ombres, alors toute votre scène sera dessinée 6 fois. Les 20 objets -seront dessinés pour la lumière n°1, puis les 20 objets seront dessinés pour -la lumière n°2, puis n°3, etc., et enfin la scène réelle sera dessinée -en utilisant les données des 5 premiers rendus.

-

Pire encore, si vous avez une lumière ponctuelle (point light) qui projette des ombres, la scène -doit être dessinée 6 fois juste pour cette lumière !

-

Pour ces raisons, il est courant de trouver d'autres solutions plutôt que d'avoir -un tas de lumières générant toutes des ombres. Une solution courante -consiste à avoir plusieurs lumières mais seulement une lumière directionnelle (directional light) générant -des ombres.

-

Une autre solution consiste à utiliser des lightmaps (cartes d'éclairage) et/ou des ambient occlusion maps -(cartes d'occlusion ambiante) pour pré-calculer les effets d'éclairage hors ligne. Cela se traduit par un éclairage statique -ou des indices d'éclairage statique, mais au moins c'est rapide. Nous -aborderons ces deux points dans un autre article.

-

Une autre solution consiste à utiliser de fausses ombres. Créez un plan, placez une texture en niveaux de gris -sur le plan qui approxime une ombre, -dessinez-le au-dessus du sol, sous votre objet.

-

Par exemple, utilisons cette texture comme fausse ombre

-
- -

Nous utiliserons une partie du code de l'article précédent.

-

Définissons la couleur de fond sur blanc.

-
const scene = new THREE.Scene();
-+scene.background = new THREE.Color('white');
-
-

Ensuite, nous allons configurer le même sol en damier, mais cette fois-ci en utilisant -un MeshBasicMaterial car nous n'avons pas besoin d'éclairage pour le sol.

-
+const loader = new THREE.TextureLoader();
-
-{
-  const planeSize = 40;
-
--  const loader = new THREE.TextureLoader();
-  const texture = loader.load('resources/images/checker.png');
-  texture.wrapS = THREE.RepeatWrapping;
-  texture.wrapT = THREE.RepeatWrapping;
-  texture.magFilter = THREE.NearestFilter;
-  const repeats = planeSize / 2;
-  texture.repeat.set(repeats, repeats);
-
-  const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
-  const planeMat = new THREE.MeshBasicMaterial({
-    map: texture,
-    side: THREE.DoubleSide,
-  });
-+  planeMat.color.setRGB(1.5, 1.5, 1.5);
-  const mesh = new THREE.Mesh(planeGeo, planeMat);
-  mesh.rotation.x = Math.PI * -.5;
-  scene.add(mesh);
-}
-
-

Notez que nous définissons la couleur sur 1.5, 1.5, 1.5. Cela multipliera les couleurs -de la texture en damier par 1.5, 1.5, 1.5. Étant donné que les couleurs de la texture sont 0x808080 et 0xC0C0C0, -ce qui correspond à un gris moyen et un gris clair, les multiplier par 1.5 nous donnera un damier -blanc et gris clair.

-

Chargeons la texture d'ombre

-
const shadowTexture = loader.load('resources/images/roundshadow.png');
-
-

et créons un tableau pour mémoriser chaque sphère et les objets associés.

-
const sphereShadowBases = [];
-
-

Ensuite, nous allons créer une géométrie de sphère

-
const sphereRadius = 1;
-const sphereWidthDivisions = 32;
-const sphereHeightDivisions = 16;
-const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
-
-

Et une géométrie de plan pour la fausse ombre

-
const planeSize = 1;
-const shadowGeo = new THREE.PlaneGeometry(planeSize, planeSize);
-
-

Maintenant, nous allons créer un tas de sphères. Pour chaque sphère, nous créerons une base -THREE.Object3D et nous ferons du maillage du plan d'ombre et du maillage de la sphère des enfants de la base. -De cette façon, si nous déplaçons la base, la sphère et l'ombre se déplaceront. Nous devons placer l'ombre légèrement au-dessus du sol pour éviter le z-fighting. -Nous définissons également depthWrite à false afin que les ombres ne s'entremêlent pas. -Nous aborderons ces deux problèmes dans un autre article. -L'ombre est un MeshBasicMaterial car elle n'a pas besoin d'éclairage.

-

Nous donnons à chaque sphère une teinte différente, puis nous enregistrons la base, le maillage de la sphère, -le maillage de l'ombre et la position y initiale de chaque sphère.

-
const numSpheres = 15;
-for (let i = 0; i < numSpheres; ++i) {
-  // créer une base pour l'ombre et la sphère
-  // afin qu'elles se déplacent ensemble.
-  const base = new THREE.Object3D();
-  scene.add(base);
-
-  // ajouter l'ombre à la base
-  // note : nous créons un nouveau matériau pour chaque sphère
-  // afin de pouvoir définir la transparence du matériau de cette sphère
-  // séparément.
-  const shadowMat = new THREE.MeshBasicMaterial({
-    map: shadowTexture,
-    transparent: true,    // pour que nous puissions voir le sol
-    depthWrite: false,    // pour ne pas avoir à trier
-  });
-  const shadowMesh = new THREE.Mesh(shadowGeo, shadowMat);
-  shadowMesh.position.y = 0.001;  // pour être légèrement au-dessus du sol
-  shadowMesh.rotation.x = Math.PI * -.5;
-  const shadowSize = sphereRadius * 4;
-  shadowMesh.scale.set(shadowSize, shadowSize, shadowSize);
-  base.add(shadowMesh);
-
-  // ajouter la sphère à la base
-  const u = i / numSpheres;   // va de 0 à 1 au fur et à mesure que nous parcourons les sphères.
-  const sphereMat = new THREE.MeshPhongMaterial();
-  sphereMat.color.setHSL(u, 1, .75);
-  const sphereMesh = new THREE.Mesh(sphereGeo, sphereMat);
-  sphereMesh.position.set(0, sphereRadius + 2, 0);
-  base.add(sphereMesh);
-
-  // mémoriser les 3 plus la position y
-  sphereShadowBases.push({base, sphereMesh, shadowMesh, y: sphereMesh.position.y});
-}
-
-

Nous configurons 2 lumières. L'une est une HemisphereLight avec l'intensité définie à 2 pour vraiment -éclaircir les choses.

-
{
-  const skyColor = 0xB1E1FF;  // bleu clair
-  const groundColor = 0xB97A20;  // orange brunâtre
-  const intensity = 2;
-  const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
-  scene.add(light);
-}
-
-

L'autre est une DirectionalLight afin que les sphères obtiennent une certaine définition

-
{
-  const color = 0xFFFFFF;
-  const intensity = 1;
-  const light = new THREE.DirectionalLight(color, intensity);
-  light.position.set(0, 10, 5);
-  light.target.position.set(-5, 0, 0);
-  scene.add(light);
-  scene.add(light.target);
-}
-
-

Cela rendrait tel quel, mais animons ces sphères. -Pour chaque ensemble sphère, ombre, base, nous déplaçons la base dans le plan xz, nous -déplaçons la sphère de haut en bas en utilisant Math.abs(Math.sin(time)) -ce qui nous donne une animation rebondissante. Et nous définissons également l'opacité du matériau de l'ombre -afin que, à mesure que chaque sphère monte, son ombre s'estompe.

-
function render(time) {
-  time *= 0.001;  // convertir en secondes
-
-  ...
-
-  sphereShadowBases.forEach((sphereShadowBase, ndx) => {
-    const {base, sphereMesh, shadowMesh, y} = sphereShadowBase;
-
-    // u est une valeur qui va de 0 à 1 au fur et à mesure que nous parcourons les sphères
-    const u = ndx / sphereShadowBases.length;
-
-    // calculer une position pour la base. Cela déplacera
-    // à la fois la sphère et son ombre
-    const speed = time * .2;
-    const angle = speed + u * Math.PI * 2 * (ndx % 1 ? 1 : -1);
-    const radius = Math.sin(speed - ndx) * 10;
-    base.position.set(Math.cos(angle) * radius, 0, Math.sin(angle) * radius);
-
-    // yOff est une valeur qui va de 0 à 1
-    const yOff = Math.abs(Math.sin(time * 2 + ndx));
-    // déplacer la sphère de haut en bas
-    sphereMesh.position.y = y + THREE.MathUtils.lerp(-2, 2, yOff);
-    // estomper l'ombre à mesure que la sphère monte
-    shadowMesh.material.opacity = THREE.MathUtils.lerp(1, .25, yOff);
-  });
-
-  ...
-
-

Et voici 15 sortes de balles rebondissantes.

-

- -

-

Dans certaines applications, il est courant d'utiliser une ombre ronde ou ovale pour tout, mais -bien sûr, vous pourriez aussi utiliser des textures d'ombre de formes différentes. Vous pourriez également -donner à l'ombre un bord plus net. Un bon exemple de l'utilisation de ce type -d'ombre est Animal Crossing Pocket Camp -où vous pouvez voir que chaque personnage a une simple ombre ronde. C'est efficace et peu coûteux. -Monument Valley -semble également utiliser ce type d'ombre pour le personnage principal.

-

Passons maintenant aux cartes d'ombres. Il existe 3 types de lumières qui peuvent projeter des ombres : la DirectionalLight, -la PointLight et la SpotLight.

-

Commençons par la DirectionalLight en utilisant l'exemple avec helper de l'article sur les lumières.

-

La première chose à faire est d'activer les ombres dans le rendu (renderer).

-
const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+renderer.shadowMap.enabled = true;
-
-

Ensuite, nous devons également dire à la lumière de projeter une ombre

-
const light = new THREE.DirectionalLight(color, intensity);
-+light.castShadow = true;
-
-

Nous devons également parcourir chaque maillage dans la scène et décider s'il doit -à la fois projeter des ombres et/ou recevoir des ombres.

-

Faisons en sorte que le plan (le sol) reçoive uniquement les ombres, car nous ne -nous soucions pas vraiment de ce qui se passe en dessous.

-
const mesh = new THREE.Mesh(planeGeo, planeMat);
-mesh.receiveShadow = true;
-
-

Pour le cube et la sphère, faisons en sorte qu'ils reçoivent et projettent tous deux des ombres

-
const mesh = new THREE.Mesh(cubeGeo, cubeMat);
-mesh.castShadow = true;
-mesh.receiveShadow = true;
-
-...
-
-const mesh = new THREE.Mesh(sphereGeo, sphereMat);
-mesh.castShadow = true;
-mesh.receiveShadow = true;
-
-

Et ensuite, nous l'exécutons.

-

- -

-

Que s'est-il passé ? Pourquoi des parties des ombres sont-elles manquantes ?

-

La raison est que les cartes d'ombres sont créées en rendant la scène du point -de vue de la lumière. Dans ce cas, il y a une caméra au niveau de la DirectionalLight -qui regarde sa cible. Tout comme les caméras que nous avons précédemment couvertes, -la caméra d'ombre de la lumière définit une zone à l'intérieur de laquelle -les ombres sont rendues. Dans l'exemple ci-dessus, cette zone est trop petite.

-

Afin de visualiser cette zone, nous pouvons obtenir la caméra d'ombre de la lumière et ajouter -un CameraHelper à la scène.

-
const cameraHelper = new THREE.CameraHelper(light.shadow.camera);
-scene.add(cameraHelper);
-
-

Et maintenant, vous pouvez voir la zone pour laquelle les ombres sont projetées et reçues.

-

- -

-

Ajustez la valeur x de la cible d'avant en arrière et il devrait être assez clair que seules -les ombres sont dessinées dans la zone de la caméra d'ombre de la lumière.

-

Nous pouvons ajuster la taille de cette boîte en ajustant la caméra d'ombre de la lumière.

-

Ajoutons des paramètres d'interface graphique pour ajuster la boîte de la caméra d'ombre de la lumière. Comme une -DirectionalLight représente une lumière allant dans une direction parallèle, la -DirectionalLight utilise une OrthographicCamera pour sa caméra d'ombre. -Nous avons vu comment fonctionne une OrthographicCamera dans l'article précédent sur les caméras.

-

Rappelons qu'une OrthographicCamera définit -sa boîte ou son frustum de visualisation par ses propriétés left, right, top, bottom, near, far, -et zoom.

-

À nouveau, créons une classe d'aide pour lil-gui. Nous créerons une DimensionGUIHelper -à laquelle nous passerons un objet et 2 propriétés. Elle présentera une propriété que lil-gui -peut ajuster et, en réponse, définira les deux propriétés, l'une positive et l'autre négative. -Nous pouvons l'utiliser pour définir left et right comme width, et up et down comme height.

-
class DimensionGUIHelper {
-  constructor(obj, minProp, maxProp) {
-    this.obj = obj;
-    this.minProp = minProp;
-    this.maxProp = maxProp;
-  }
-  get value() {
-    return this.obj[this.maxProp] * 2;
-  }
-  set value(v) {
-    this.obj[this.maxProp] = v /  2;
-    this.obj[this.minProp] = v / -2;
-  }
-}
-
-

Nous utiliserons également la MinMaxGUIHelper que nous avons créée dans l'article sur les caméras -pour ajuster near et far.

-
const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-gui.add(light, 'intensity', 0, 2, 0.01);
-+{
-+  const folder = gui.addFolder('Caméra d\'ombre');
-+  folder.open();
-+  folder.add(new DimensionGUIHelper(light.shadow.camera, 'left', 'right'), 'value', 1, 100)
-+    .name('largeur')
-+    .onChange(updateCamera);
-+  folder.add(new DimensionGUIHelper(light.shadow.camera, 'bottom', 'top'), 'value', 1, 100)
-+    .name('hauteur')
-+    .onChange(updateCamera);
-+  const minMaxGUIHelper = new MinMaxGUIHelper(light.shadow.camera, 'near', 'far', 0.1);
-+  folder.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('proche').onChange(updateCamera);
-+  folder.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('loin').onChange(updateCamera);
-+  folder.add(light.shadow.camera, 'zoom', 0.01, 1.5, 0.01).onChange(updateCamera);
-+}
-
-

Nous demandons à l'interface graphique d'appeler notre fonction updateCamera chaque fois que quelque chose change. -Écrivons cette fonction pour mettre à jour la lumière, l'helper pour la lumière, la -caméra d'ombre de la lumière et l'helper affichant la caméra d'ombre de la lumière.

-
function updateCamera() {
-  // mettre à jour le matrixWorld de la cible de la lumière car il est nécessaire pour l'helper
-  light.target.updateMatrixWorld();
-  helper.update();
-  // mettre à jour la matrice de projection de la caméra d'ombre de la lumière
-  light.shadow.camera.updateProjectionMatrix();
-  // et maintenant mettre à jour l'helper caméra que nous utilisons pour afficher la caméra d'ombre de la lumière
-  cameraHelper.update();
-}
-updateCamera();
-
-

Et maintenant que nous avons donné à la caméra d'ombre de la lumière une interface graphique, nous pouvons jouer avec les valeurs.

-

- -

-

Définissez la largeur et la hauteur à environ 30 et vous pourrez voir que les ombres sont correctes -et que les zones qui doivent être dans l'ombre pour cette scène sont entièrement couvertes.

-

Mais cela soulève la question : pourquoi ne pas simplement définir la largeur et la hauteur à des -nombres géants pour tout couvrir ? Définissez la largeur et la hauteur à 100 -et vous pourriez voir quelque chose comme ceci

-
- -

Qu'est-ce qui se passe avec ces ombres basse résolution ?!

-

Ce problème est un autre paramètre lié aux ombres dont il faut être conscient. -Les cartes d'ombres sont des textures dans lesquelles les ombres sont dessinées. -Ces textures ont une taille. La zone de la caméra d'ombre que nous avons définie ci-dessus est étirée -sur cette taille. Cela signifie que plus la zone que vous définissez est grande, plus vos ombres seront -pixelisées.

-

Vous pouvez définir la résolution de la texture de la carte d'ombre en définissant light.shadow.mapSize.width -et light.shadow.mapSize.height. Par défaut, elles sont de 512x512. -Plus vous les augmentez, plus elles consomment de mémoire et plus elles sont lentes à calculer, vous voulez donc -les définir aussi petites que possible tout en faisant fonctionner votre scène. Il en va de même pour la -zone de la caméra d'ombre de la lumière. Plus elle est petite, meilleures sont les ombres, alors rendez la zone aussi petite que possible et -continuez à couvrir votre scène. Sachez que la machine de chaque utilisateur a une taille de texture maximale -autorisée qui est disponible sur le renderer sous la forme de renderer.capabilities.maxTextureSize.

- -

En passant à la SpotLight, la caméra d'ombre de la lumière devient une PerspectiveCamera. Contrairement à la caméra d'ombre -de la DirectionalLight où nous pouvions définir manuellement la plupart de ses paramètres, la caméra d'ombre -de la SpotLight est contrôlée par la SpotLight elle-même. Le fov (champ de vision) pour l'ombre -de la caméra est directement lié au paramètre angle de la SpotLight. -L'aspect est défini automatiquement en fonction de la taille de la carte d'ombre.

-
-const light = new THREE.DirectionalLight(color, intensity);
-+const light = new THREE.SpotLight(color, intensity);
-
-

et nous avons rajouté les paramètres penumbra et angle -de notre article sur les lumières.

-

- -

- -

Et enfin, il y a les ombres avec une PointLight. Comme une PointLight -brille dans toutes les directions, les seuls paramètres pertinents sont near et far. -Sinon, l'ombre d'une PointLight est effectivement composée de 6 ombres de SpotLight -, chacune pointant vers une face d'un cube autour de la lumière. Cela signifie -que les ombres des PointLight sont beaucoup plus lentes car toute la scène doit être -dessinée 6 fois, une pour chaque direction.

-

Mettons une boîte autour de notre scène pour que nous puissions voir les ombres sur les murs -et le plafond. Nous définirons la propriété side du matériau sur THREE.BackSide -afin de rendre l'intérieur de la boîte au lieu de l'extérieur. Comme le sol, -nous la définirons pour qu'elle ne reçoive que les ombres. De plus, nous définirons la position de la -boîte de manière à ce que son bas soit légèrement en dessous du sol afin que le sol et le bas -de la boîte ne causent pas de z-fighting.

-
{
-  const cubeSize = 30;
-  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
-  const cubeMat = new THREE.MeshPhongMaterial({
-    color: '#CCC',
-    side: THREE.BackSide,
-  });
-  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
-  mesh.receiveShadow = true;
-  mesh.position.set(0, cubeSize / 2 - 0.1, 0);
-  scene.add(mesh);
-}
-
-

Et bien sûr, nous devons changer la lumière en PointLight.

-
-const light = new THREE.SpotLight(color, intensity);
-+const light = new THREE.PointLight(color, intensity);
-
-....
-
-// pour pouvoir facilement voir où se trouve la lumière ponctuelle
-+const helper = new THREE.PointLightHelper(light);
-+scene.add(helper);
-
-

- -

-

Utilisez les paramètres d'interface graphique position pour déplacer la lumière -et vous verrez les ombres tomber sur tous les murs. Vous pouvez -également ajuster les paramètres near et far et voir, tout comme -pour les autres ombres, que lorsque les objets sont plus proches que near, ils -ne reçoivent plus d'ombre et lorsqu'ils sont plus loin que far, ils sont toujours dans l'ombre.

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/textures.html b/manual/fr/textures.html deleted file mode 100644 index a8b2c1da837e78..00000000000000 --- a/manual/fr/textures.html +++ /dev/null @@ -1,566 +0,0 @@ - - - Textures - - - - - - - - - - - - - -
-
-

Textures

-
-
-
-

Cet article fait partie d'une série d'articles sur three.js. -Le premier article concernait les bases de three.js. -L'article précédent expliquait comment se préparer pour cet article. -Si vous ne l'avez pas encore lu, vous pourriez vouloir commencer par là.

-

Les textures sont un sujet assez vaste dans Three.js et -je ne suis pas sûr à 100% du niveau auquel les expliquer, mais je vais essayer. -Il y a de nombreux sujets et beaucoup d'entre eux sont interdépendants, il est donc difficile d'expliquer -tout en une seule fois. Voici une table des matières rapide pour cet article.

- - -

Bonjour la texture

-

Les textures sont généralement des images qui sont le plus souvent créées -dans un programme tiers comme Photoshop ou GIMP. Par exemple, mettons -cette image sur un cube.

-
- -
- -

Nous allons modifier un de nos premiers exemples. Tout ce que nous avons à faire est de créer un TextureLoader. Appelez sa -méthode load avec l'URL d'une -image et définissez la propriété map du matériau sur le résultat au lieu de définir sa color.

-
+const loader = new THREE.TextureLoader();
-+const texture = loader.load( 'resources/images/wall.jpg' );
-+texture.colorSpace = THREE.SRGBColorSpace;
-
-const material = new THREE.MeshBasicMaterial({
--  color: 0xFF8844,
-+  map: texture,
-});
-
-

Notez que nous utilisons MeshBasicMaterial, donc pas besoin de lumières.

-

- -

-

6 textures, une différente sur chaque face d'un cube

-

Que diriez-vous de 6 textures, une sur chaque face d'un cube ?

-
-
- - - -
-
- - - -
-
- -

Nous créons simplement 6 matériaux et les passons sous forme de tableau lorsque nous créons le Mesh

-
const loader = new THREE.TextureLoader();
--const texture = loader.load( 'resources/images/wall.jpg' );
--texture.colorSpace = THREE.SRGBColorSpace;
-
--const material = new THREE.MeshBasicMaterial({
--  map: texture,
--});
-+const materials = [
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-1.jpg')}),
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-2.jpg')}),
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-3.jpg')}),
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-4.jpg')}),
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-5.jpg')}),
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-6.jpg')}),
-+];
--const cube = new THREE.Mesh(geometry, material);
-+const cube = new THREE.Mesh(geometry, materials);
-
-+function loadColorTexture( path ) {
-+  const texture = loader.load( path );
-+  texture.colorSpace = THREE.SRGBColorSpace;
-+  return texture;
-+}
-
-

Ça marche !

-

- -

-

Il convient de noter cependant que tous les types de géométrie ne supportent pas plusieurs -matériaux. BoxGeometry peut utiliser 6 matériaux, un pour chaque face. -ConeGeometry peut utiliser 2 matériaux, un pour le fond et un pour le cône. -CylinderGeometry peut utiliser 3 matériaux : fond, haut et côté. -Dans d'autres cas, vous devrez construire ou charger une géométrie personnalisée et/ou modifier les coordonnées de texture.

-

Il est beaucoup plus courant dans d'autres moteurs 3D et beaucoup plus performant d'utiliser un -atlas de textures -si vous souhaitez autoriser plusieurs images sur une seule géométrie. Un atlas de textures -est un endroit où vous placez plusieurs images dans une seule texture et utilisez ensuite les coordonnées de texture -sur les sommets de votre géométrie pour sélectionner quelles parties d'une texture sont utilisées -sur chaque triangle de votre géométrie.

-

Que sont les coordonnées de texture ? Ce sont des données ajoutées à chaque sommet d'une pièce de géométrie -qui spécifient quelle partie de la texture correspond à ce sommet spécifique. -Nous les aborderons lorsque nous commencerons à construire une géométrie personnalisée.

-

Chargement des textures

-

La manière simple

-

La plupart du code sur ce site utilise la méthode la plus simple pour charger des textures. -Nous créons un TextureLoader, puis appelons sa méthode load. -Cela renvoie un objet Texture.

-
const texture = loader.load('resources/images/flower-1.jpg');
-
-

Il est important de noter qu'en utilisant cette méthode, notre texture sera transparente jusqu'à -ce que l'image soit chargée de manière asynchrone par three.js, moment auquel elle mettra à jour la texture -avec l'image téléchargée.

-

Cela présente le grand avantage de ne pas avoir à attendre le chargement de la texture et notre -page commencera à s'afficher immédiatement. C'est probablement acceptable pour un grand nombre de cas d'utilisation -mais si nous le souhaitons, nous pouvons demander à three.js de nous informer lorsque la texture a fini de se télécharger.

-

Attendre le chargement d'une texture

-

Pour attendre le chargement d'une texture, la méthode load du chargeur de textures prend un rappel -qui sera appelé lorsque la texture aura fini de se charger. En reprenant notre premier exemple, -nous pouvons attendre le chargement de la texture avant de créer notre Mesh et de l'ajouter à la scène -comme ceci

-
const loader = new THREE.TextureLoader();
-loader.load('resources/images/wall.jpg', (texture) => {
-  texture.colorSpace = THREE.SRGBColorSpace;
-  const material = new THREE.MeshBasicMaterial({
-    map: texture,
-  });
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-  cubes.push(cube);  // add to our list of cubes to rotate
-});
-
-

À moins que vous ne vidiez le cache de votre navigateur et que vous ayez une connexion lente, il est peu probable -que vous voyiez une différence, mais soyez assuré qu'elle attend le chargement de la texture.

-

- -

-

Attendre le chargement de plusieurs textures

-

Pour attendre que toutes les textures soient chargées, vous pouvez utiliser un LoadingManager. Créez-en un -et passez-le au TextureLoader, puis définissez sa propriété onLoad -sur un rappel.

-
+const loadManager = new THREE.LoadingManager();
-*const loader = new THREE.TextureLoader(loadManager);
-
-const materials = [
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-1.jpg')}),
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-2.jpg')}),
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-3.jpg')}),
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-4.jpg')}),
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-5.jpg')}),
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-6.jpg')}),
-];
-
-+loadManager.onLoad = () => {
-+  const cube = new THREE.Mesh(geometry, materials);
-+  scene.add(cube);
-+  cubes.push(cube);  // add to our list of cubes to rotate
-+};
-
-

Le LoadingManager a également une propriété onProgress -que nous pouvons définir sur un autre rappel pour afficher un indicateur de progression.

-

Nous allons d'abord ajouter une barre de progression en HTML

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="loading">
-+    <div class="progress"><div class="progressbar"></div></div>
-+  </div>
-</body>
-
-

et le CSS associé

-
#loading {
-    position: fixed;
-    top: 0;
-    left: 0;
-    width: 100%;
-    height: 100%;
-    display: flex;
-    justify-content: center;
-    align-items: center;
-}
-#loading .progress {
-    margin: 1.5em;
-    border: 1px solid white;
-    width: 50vw;
-}
-#loading .progressbar {
-    margin: 2px;
-    background: white;
-    height: 1em;
-    transform-origin: top left;
-    transform: scaleX(0);
-}
-
-

Ensuite, dans le code, nous mettrons à jour l'échelle de la progressbar dans notre rappel onProgress. Il est -appelé avec l'URL du dernier élément chargé, le nombre d'éléments chargés jusqu'à présent et le nombre total -d'éléments à charger.

-
+const loadingElem = document.querySelector('#loading');
-+const progressBarElem = loadingElem.querySelector('.progressbar');
-
-loadManager.onLoad = () => {
-+  loadingElem.style.display = 'none';
-  const cube = new THREE.Mesh(geometry, materials);
-  scene.add(cube);
-  cubes.push(cube);  // add to our list of cubes to rotate
-};
-
-+loadManager.onProgress = (urlOfLastItemLoaded, itemsLoaded, itemsTotal) => {
-+  const progress = itemsLoaded / itemsTotal;
-+  progressBarElem.style.transform = `scaleX(${progress})`;
-+};
-
-

À moins que vous ne vidiez votre cache et que vous ayez une connexion lente, il est possible que vous ne voyiez -pas la barre de chargement.

-

- -

-

Charger des textures depuis d'autres origines

-

Pour utiliser des images provenant d'autres serveurs, ces serveurs doivent envoyer les en-têtes corrects. -S'ils ne le font pas, vous ne pouvez pas utiliser les images dans three.js et vous obtiendrez une erreur. -Si vous gérez le serveur fournissant les images, assurez-vous qu'il -envoie les en-têtes corrects. -Si vous ne contrôlez pas le serveur hébergeant les images et qu'il n'envoie pas les -en-têtes d'autorisation, vous ne pouvez pas utiliser les images de ce serveur.

-

Par exemple, imgur, flickr et -github envoient tous des en-têtes vous permettant d'utiliser les images -hébergées sur leurs serveurs dans three.js. La plupart des autres sites web ne le font pas.

-

Utilisation de la mémoire

-

Les textures sont souvent la partie d'une application three.js qui utilise le plus de mémoire. Il est important de comprendre -qu'en général, les textures prennent largeur * hauteur * 4 * 1.33 octets de mémoire.

-

Notez que cela ne dit rien sur la compression. Je peux créer une image .jpg et régler sa compression très élevée. -Par exemple, disons que je créais une scène d'une maison. À l'intérieur de la maison, il y a une table -et je décide de mettre cette texture de bois sur la surface supérieure de la table

-
- -

Cette image ne fait que 157k, elle se téléchargera donc relativement rapidement, mais sa taille est en réalité -de 3024 x 3761 pixels. -En suivant l'équation ci-dessus, cela donne

-
3024 * 3761 * 4 * 1.33 = 60505764.5
-

Cette image prendra 60 MÉGAOCTETS DE MÉMOIRE ! dans three.js. -Quelques textures comme celle-là et vous serez à court de mémoire.

-

Je soulève ce point car il est important de savoir que l'utilisation des textures a un coût caché. -Pour que three.js puisse utiliser la texture, il doit la transmettre au GPU, et le -GPU en général nécessite que les données de la texture soient décompressées.

-

La morale de l'histoire est de rendre vos textures petites en dimensions, pas seulement petites -en taille de fichier. Petite taille de fichier = téléchargement rapide. Petites dimensions = prend -moins de mémoire. Quelle taille devraient-elles avoir ? -Aussi petites que possible tout en conservant l'apparence dont vous avez besoin.

-

JPG vs PNG

-

C'est à peu près la même chose qu'en HTML classique : les JPG ont une compression avec perte, -les PNG ont une compression sans perte, donc les PNG sont généralement plus lents à télécharger. -Mais les PNG supportent la transparence. Les PNG sont également probablement le format -approprié pour les données non-image comme les normal maps et d'autres types de maps non-image que nous verrons plus tard.

-

Il est important de se rappeler qu'un JPG n'utilise -pas moins de mémoire qu'un PNG dans WebGL. Voir ci-dessus.

-

Filtrage et Mips

-

Appliquons cette texture 16x16

-
- -

À un cube

-
- -

Dessinons ce cube très petit

-
- -

Hmmm, je suppose que c'est difficile à voir. Agrandissons ce tout petit cube

-
- -

Comment le GPU sait-il quelles couleurs donner à chaque pixel qu'il dessine pour le petit cube ? -Que se passerait-il si le cube était si petit qu'il ne fasse qu'un ou deux pixels ?

-

C'est à cela que sert le filtrage.

-

Si c'était Photoshop, Photoshop ferait la moyenne de presque tous les pixels pour déterminer la couleur -à donner à ces 1 ou 2 pixels. Ce serait une opération très lente. Les GPU résolvent ce problème -en utilisant les mipmaps.

-

Les mips sont des copies de la texture, chacune faisant la moitié de la largeur et la moitié de la hauteur du mip précédent, -où les pixels ont été mélangés pour créer le mip suivant plus petit. Les mips sont créés -jusqu'à ce que l'on arrive à un mip de 1x1 pixel. Pour l'image ci-dessus, tous les mips ressembleraient -à ceci

-
- -

Maintenant, lorsque le cube est dessiné si petit qu'il ne fait qu'un ou deux pixels, le GPU peut choisir -d'utiliser uniquement le mip le plus petit ou le mip juste avant le plus petit pour décider de la couleur -à donner au petit cube.

-

Dans three.js, vous pouvez choisir ce qui se passe à la fois lorsque la texture est dessinée -plus grande que sa taille d'origine et ce qui se passe lorsqu'elle est dessinée plus petite que sa -taille d'origine.

-

Pour définir le filtre lorsque la texture est dessinée plus grande que sa taille d'origine, -vous définissez la propriété texture.magFilter sur THREE.NearestFilter ou - THREE.LinearFilter. NearestFilter signifie -simplement choisir le pixel unique le plus proche de la texture d'origine. Avec une texture -basse résolution, cela donne un aspect très pixélisé comme dans Minecraft.

-

LinearFilter signifie choisir les 4 pixels de la texture qui sont les plus proches -de l'endroit où nous devrions choisir une couleur et les mélanger dans les -proportions appropriées par rapport à la distance entre le point réel et -chacun des 4 pixels.

-
-
-
-
Plus proche
-
-
-
-
Linéaire
-
-
- -

Pour définir le filtre lorsque la texture est dessinée plus petite que sa taille d'origine, -vous définissez la propriété texture.minFilter sur l'une des 6 valeurs suivantes.

-
    -
  • THREE.NearestFilter

    -

    identique à ci-dessus, choisir le pixel le plus proche dans la texture

    -
  • -
  • THREE.LinearFilter

    -

    identique à ci-dessus, choisir 4 pixels de la texture et les mélanger

    -
  • -
  • THREE.NearestMipmapNearestFilter

    -

    choisir le mip approprié puis choisir un pixel

    -
  • -
  • THREE.NearestMipmapLinearFilter

    -

    choisir 2 mips, choisir un pixel de chaque, mélanger les 2 pixels

    -
  • -
  • THREE.LinearMipmapNearestFilter

    -

    choisir le mip approprié puis choisir 4 pixels et les mélanger

    -
  • -
  • THREE.LinearMipmapLinearFilter

    -

    choisir 2 mips, choisir 4 pixels de chaque et mélanger les 8 en 1 pixel

    -
  • -
-

Voici un exemple montrant les 6 paramètres

-
-
-
-
cliquer pour
changer la
texture
-
-
plus proche
-
linéaire
-
plus proche
mipmap
plus proche
-
plus proche
mipmap
linéaire
-
linéaire
mipmap
plus proche
-
linéaire
mipmap
linéaire
-
-
- -

Une chose à remarquer est que le coin supérieur gauche et le milieu supérieur utilisant NearestFilter et LinearFilter -n'utilisent pas les mips. De ce fait, ils scintillent au loin car le GPU sélectionne -des pixels de la texture d'origine. À gauche, un seul pixel est choisi et -au milieu, 4 sont choisis et mélangés, mais ce n'est pas suffisant pour obtenir une bonne -couleur représentative. Les 4 autres bandes s'en sortent mieux, -celle en bas à droite, LinearMipmapLinearFilter, étant la meilleure.

-

Si vous cliquez sur l'image ci-dessus, elle basculera entre la texture que nous avons utilisée ci-dessus -et une texture où chaque niveau de mip est d'une couleur différente.

-
-
-
- -

Cela rend plus clair -ce qui se passe. Vous pouvez voir en haut à gauche et au milieu supérieur que le premier mip est utilisé jusqu'au loin. -En haut à droite et au milieu inférieur, vous pouvez clairement voir où un mip différent -est utilisé.

-

En revenant à la texture d'origine, vous pouvez voir que celle en bas à droite est la plus lisse, -de la plus haute qualité. Vous pourriez vous demander pourquoi ne pas toujours utiliser ce mode. La raison -la plus évidente est que parfois vous voulez que les choses soient pixélisées pour un look rétro ou pour une autre raison. -La raison suivante la plus courante est que lire 8 pixels et les mélanger est plus lent -que de lire 1 pixel et de le mélanger. Bien qu'il soit peu probable qu'une seule texture -fasse la différence entre rapide et lent, à mesure que nous progresserons dans ces articles, -nous aurons finalement des matériaux qui utilisent 4 ou 5 textures en même temps. -4 textures * 8 pixels par texture, c'est rechercher 32 pixels pour chaque pixel rendu. -Cela peut être particulièrement important à considérer sur les appareils mobiles.

-

Répétition, décalage, rotation, habillage d'une texture

-

Les textures ont des paramètres pour la répétition, le décalage et la rotation d'une texture.

-

Par défaut, les textures dans three.js ne se répètent pas. Pour définir si une -texture se répète ou non, il existe 2 propriétés : wrapS pour l'habillage horizontal -et wrapT pour l'habillage vertical.

-

Ils peuvent être définis sur l'une des valeurs suivantes :

-
    -
  • THREE.ClampToEdgeWrapping

    -

    le dernier pixel sur chaque bord est répété indéfiniment

    -
  • -
  • THREE.RepeatWrapping

    -

    la texture est répétée

    -
  • -
  • THREE.MirroredRepeatWrapping

    -

    la texture est mise en miroir et répétée

    -
  • -
-

Par exemple, pour activer l'habillage dans les deux directions :

-
someTexture.wrapS = THREE.RepeatWrapping;
-someTexture.wrapT = THREE.RepeatWrapping;
-
-

La répétition est définie avec la propriété [repeat] repeat.

-
const timesToRepeatHorizontally = 4;
-const timesToRepeatVertically = 2;
-someTexture.repeat.set(timesToRepeatHorizontally, timesToRepeatVertically);
-
-

Le décalage de la texture peut être effectué en définissant la propriété offset. Les textures -sont décalées avec des unités où 1 unité = 1 taille de texture. Autrement dit, 0 = pas de décalage -et 1 = décalage d'une quantité de texture complète.

-
const xOffset = .5;   // offset by half the texture
-const yOffset = .25;  // offset by 1/4 the texture
-someTexture.offset.set(xOffset, yOffset);
-
-

La rotation de la texture peut être définie en définissant la propriété rotation en radians -ainsi que la propriété center pour choisir le centre de rotation. -Elle est par défaut à 0,0, ce qui correspond à une rotation depuis le coin inférieur gauche. Comme pour le décalage, -ces unités sont en taille de texture, donc les définir à .5, .5 effectuerait une rotation -autour du centre de la texture.

-
someTexture.center.set(.5, .5);
-someTexture.rotation = THREE.MathUtils.degToRad(45);
-
-

Modifions l'exemple du haut ci-dessus pour jouer avec ces valeurs

-

Tout d'abord, nous allons conserver une référence à la texture afin de pouvoir la manipuler

-
+const texture = loader.load('resources/images/wall.jpg');
-const material = new THREE.MeshBasicMaterial({
--  map: loader.load('resources/images/wall.jpg');
-+  map: texture,
-});
-
-

Ensuite, nous utiliserons à nouveau lil-gui pour fournir une interface simple.

-
import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
-
-

Comme nous l'avons fait dans les exemples précédents avec lil-gui, nous utiliserons une classe simple pour -donner à lil-gui un objet qu'il peut manipuler en degrés -mais qui définira une propriété en radians.

-
class DegRadHelper {
-  constructor(obj, prop) {
-    this.obj = obj;
-    this.prop = prop;
-  }
-  get value() {
-    return THREE.MathUtils.radToDeg(this.obj[this.prop]);
-  }
-  set value(v) {
-    this.obj[this.prop] = THREE.MathUtils.degToRad(v);
-  }
-}
-
-

Nous avons également besoin d'une classe qui convertira une chaîne de caractères comme "123" en un -nombre comme 123, car three.js nécessite des nombres pour les paramètres d'énumération -comme wrapS et wrapT, mais lil-gui n'utilise que des chaînes de caractères pour les énumérations.

-
class StringToNumberHelper {
-  constructor(obj, prop) {
-    this.obj = obj;
-    this.prop = prop;
-  }
-  get value() {
-    return this.obj[this.prop];
-  }
-  set value(v) {
-    this.obj[this.prop] = parseFloat(v);
-  }
-}
-
-

En utilisant ces classes, nous pouvons configurer une interface graphique simple pour les paramètres ci-dessus

-
const wrapModes = {
-  'ClampToEdgeWrapping': THREE.ClampToEdgeWrapping,
-  'RepeatWrapping': THREE.RepeatWrapping,
-  'MirroredRepeatWrapping': THREE.MirroredRepeatWrapping,
-};
-
-function updateTexture() {
-  texture.needsUpdate = true;
-}
-
-const gui = new GUI();
-gui.add(new StringToNumberHelper(texture, 'wrapS'), 'value', wrapModes)
-  .name('texture.wrapS')
-  .onChange(updateTexture);
-gui.add(new StringToNumberHelper(texture, 'wrapT'), 'value', wrapModes)
-  .name('texture.wrapT')
-  .onChange(updateTexture);
-gui.add(texture.repeat, 'x', 0, 5, .01).name('texture.repeat.x');
-gui.add(texture.repeat, 'y', 0, 5, .01).name('texture.repeat.y');
-gui.add(texture.offset, 'x', -2, 2, .01).name('texture.offset.x');
-gui.add(texture.offset, 'y', -2, 2, .01).name('texture.offset.y');
-gui.add(texture.center, 'x', -.5, 1.5, .01).name('texture.center.x');
-gui.add(texture.center, 'y', -.5, 1.5, .01).name('texture.center.y');
-gui.add(new DegRadHelper(texture, 'rotation'), 'value', -360, 360)
-  .name('texture.rotation');
-
-

La dernière chose à noter à propos de l'exemple est que si vous changez wrapS ou -wrapT sur la texture, vous devez également définir texture.needsUpdate -afin que three.js sache qu'il doit appliquer ces paramètres. Les autres paramètres sont appliqués automatiquement.

-

- -

-

Ce n'est qu'une étape dans le sujet des textures. À un moment donné, nous aborderons -les coordonnées de texture ainsi que 9 autres types de textures qui peuvent être appliqués -aux matériaux.

-

Pour l'instant, passons aux lumières.

- -

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/tips.html b/manual/fr/tips.html deleted file mode 100644 index 5f025d7f6cca1c..00000000000000 --- a/manual/fr/tips.html +++ /dev/null @@ -1,344 +0,0 @@ - - - Conseils - - - - - - - - - - - - - -
-
-

Conseils

-
-
-
-

Cet article est une collection de petits problèmes que vous pourriez rencontrer -en utilisant three.js, qui semblaient trop mineurs pour avoir leur propre article.

-
-

-

Faire une capture d'écran du Canvas

-

Dans le navigateur, il y a effectivement 2 fonctions qui permettent de prendre une capture d'écran. -L'ancienne -canvas.toDataURL -et la nouvelle, meilleure, -canvas.toBlob

-

On pourrait donc penser qu'il serait facile de prendre une capture d'écran en ajoutant simplement du code comme

-
<canvas id="c"></canvas>
-+<button id="screenshot" type="button">Enregistrer...</button>
-
-
const elem = document.querySelector('#screenshot');
-elem.addEventListener('click', () => {
-  canvas.toBlob((blob) => {
-    saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`);
-  });
-});
-
-const saveBlob = (function() {
-  const a = document.createElement('a');
-  document.body.appendChild(a);
-  a.style.display = 'none';
-  return function saveData(blob, fileName) {
-     const url = window.URL.createObjectURL(blob);
-     a.href = url;
-     a.download = fileName;
-     a.click();
-  };
-}());
-
-

Voici l'exemple de l'article sur la réactivité -avec le code ci-dessus ajouté et un peu de CSS pour positionner le bouton

-

- -

-

Lorsque je l'ai essayé, j'ai obtenu cette capture d'écran

-
- -

Oui, c'est juste une image noire.

-

Il est possible que cela ait fonctionné pour vous selon votre navigateur/OS mais en général, -il est peu probable que cela fonctionne.

-

Le problème est que, pour des raisons de performance et de compatibilité, par défaut, le navigateur -efface le buffer de dessin d'un canvas WebGL après y avoir dessiné.

-

La solution est d'appeler votre code de rendu juste avant la capture.

-

Dans notre code, nous devons ajuster quelques éléments. D'abord, séparons -le code de rendu.

-
+const state = {
-+  time: 0,
-+};
-
--function render(time) {
--  time *= 0.001;
-+function render() {
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-  cubes.forEach((cube, ndx) => {
-    const speed = 1 + ndx * .1;
--    const rot = time * speed;
-+    const rot = state.time * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
-  renderer.render(scene, camera);
-
--  requestAnimationFrame(render);
-}
-
-+function animate(time) {
-+  state.time = time * 0.001;
-+
-+  render();
-+
-+  requestAnimationFrame(animate);
-+}
-+requestAnimationFrame(animate);
-
-

Maintenant que render ne s'occupe que du rendu effectif, -nous pouvons l'appeler juste avant de capturer le canvas.

-
const elem = document.querySelector('#screenshot');
-elem.addEventListener('click', () => {
-+  render();
-  canvas.toBlob((blob) => {
-    saveBlob(blob, `screencapture-${canvas.width}x-${canvas.height}.png`);
-  });
-});
-
-

Et maintenant, ça devrait marcher.

-

- -

-

Pour une solution différente, voir l'élément suivant.

-
-

-

Empêcher l'effacement du canvas

-

Supposons que vous vouliez permettre à l'utilisateur de peindre avec un objet -animé. Vous devez passer preserveDrawingBuffer: true lorsque -vous créez le WebGLRenderer. Cela empêche le navigateur d'effacer -le canvas. Vous devez également dire à three.js de ne pas effacer -le canvas.

-
const canvas = document.querySelector('#c');
--const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+const renderer = new THREE.WebGLRenderer({
-+  canvas,
-+  preserveDrawingBuffer: true,
-+  alpha: true,
-+});
-+renderer.autoClearColor = false;
-
-

- -

-

Notez que si vous étiez sérieux au sujet de la création d'un programme de dessin, ce ne serait pas une -solution car le navigateur effacera toujours le canvas chaque fois que sa -résolution change. Nous modifions sa résolution en fonction de sa taille d'affichage. Sa taille d'affichage -change lorsque la fenêtre change de taille. Cela inclut lorsque l'utilisateur télécharge -un fichier, même dans un autre onglet, et que le navigateur ajoute une barre d'état. Cela inclut également lorsque -l'utilisateur tourne son téléphone et que le navigateur passe du mode portrait au mode paysage.

-

Si vous vouliez vraiment créer un programme de dessin, vous devriez -faire le rendu sur une texture en utilisant une cible de rendu.

-
-

-

Obtenir la saisie clavier

-

Tout au long de ces tutoriels, nous avons souvent attaché des écouteurs d'événements au canvas. -Bien que de nombreux événements fonctionnent, un qui ne fonctionne pas par défaut est l'événement -clavier.

-

Pour obtenir les événements clavier, définissez le tabindex -du canvas à 0 ou plus. Par exemple :

-
<canvas tabindex="0"></canvas>
-
-

Cela finit cependant par causer un nouveau problème. Tout élément ayant un tabindex défini -sera mis en évidence lorsqu'il aura le focus. Pour résoudre ce problème, définissez son contour de focus CSS -à none (aucun)

-
canvas:focus {
-  outline:none;
-}
-
-

Pour démontrer, voici 3 canvas

-
<canvas id="c1"></canvas>
-<canvas id="c2" tabindex="0"></canvas>
-<canvas id="c3" tabindex="1"></canvas>
-
-

et un peu de css juste pour le dernier canvas

-
#c3:focus {
-    outline: none;
-}
-
-

Notez que vous ne pouvez pas faire en sorte que le premier canvas accepte les saisies clavier. -Le deuxième canvas le peut, mais il est mis en évidence. Le 3ème -canvas a les deux solutions appliquées.

-

- -

-
-

-

Rendre le Canvas Transparent

-

Par défaut, THREE.js rend le canvas opaque. Si vous voulez que le -canvas soit transparent, passez alpha:true lorsque vous créez -le WebGLRenderer

-
const canvas = document.querySelector('#c');
--const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+const renderer = new THREE.WebGLRenderer({
-+  canvas,
-+  alpha: true,
-+});
-
-

Vous voudrez probablement aussi lui dire que vos résultats n'utilisent pas l'alpha prémultiplié

-
const canvas = document.querySelector('#c');
-const renderer = new THREE.WebGLRenderer({
-  canvas,
-  alpha: true,
-+  premultipliedAlpha: false,
-});
-
-

Three.js utilise par défaut premultipliedAlpha: true pour le canvas, -mais par défaut, les matériaux génèrent premultipliedAlpha: false.

-

Si vous souhaitez mieux comprendre quand utiliser ou non l'alpha prémultiplié, -voici un bon article à ce sujet.

-

En tout cas, configurons un exemple simple avec un canvas transparent.

-

Nous avons appliqué les paramètres ci-dessus à l'exemple de l'article sur la réactivité. -Rendons également les matériaux plus transparents.

-
function makeInstance(geometry, color, x) {
--  const material = new THREE.MeshPhongMaterial({color});
-+  const material = new THREE.MeshPhongMaterial({
-+    color,
-+    opacity: 0.5,
-+  });
-
-...
-
-

Et ajoutons un peu de contenu HTML

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="content">
-+    <div>
-+      <h1>Cubes-R-Us !</h1>
-+      <p>Nous fabriquons les meilleurs cubes !</p>
-+    </div>
-+  </div>
-</body>
-
-

ainsi qu'un peu de CSS pour placer le canvas devant

-
body {
-    margin: 0;
-}
-#c {
-    width: 100%;
-    height: 100%;
-    display: block;
-+    position: fixed;
-+    left: 0;
-+    top: 0;
-+    z-index: 2;
-+    pointer-events: none;
-}
-+#content {
-+  font-size: 7vw;
-+  font-family: sans-serif;
-+  text-align: center;
-+  width: 100%;
-+  height: 100%;
-+  display: flex;
-+  justify-content: center;
-+  align-items: center;
-+}
-
-

Notez que pointer-events: none rend le canvas invisible aux événements de souris -et tactiles afin que vous puissiez sélectionner le texte en dessous.

-

- -

-
-

-

Faire de votre arrière-plan une animation three.js

-

Une question courante est de savoir comment faire en sorte qu'une animation three.js serve d'arrière-plan à -une page web.

-

Il y a 2 façons évidentes.

-
    -
  • Définir la propriété CSS position du canvas sur fixed comme dans
  • -
-
#c {
- position: fixed;
- left: 0;
- top: 0;
- ...
-}
-
-

Vous pouvez voir cette solution exacte dans l'exemple précédent. Il suffit de définir z-index à -1 -et les cubes apparaîtront derrière le texte.

-

Un petit inconvénient de cette solution est que votre JavaScript doit s'intégrer à la page -et si vous avez une page complexe, vous devez vous assurer qu'aucun des scripts JavaScript de votre -visualisation three.js n'entre en conflit avec le JavaScript effectuant d'autres tâches dans la page.

-
    -
  • Utiliser une iframe
  • -
-

C'est la solution utilisée sur la page d'accueil de ce site.

-

Dans votre page web, insérez simplement une iframe, par exemple :

-
<iframe id="background" src="responsive.html">
-<div>
-  Votre contenu ici.
-</div>
-
-

Ensuite, stylisez l'iframe pour qu'elle remplisse la fenêtre et soit en arrière-plan, -ce qui est essentiellement le même code que nous avons utilisé ci-dessus pour le canvas, -sauf que nous devons également définir border à none car les iframes ont -une bordure par défaut.

-
#background {
-    position: fixed;
-    width: 100%;
-    height: 100%;
-    left: 0;
-    top: 0;
-    z-index: -1;
-    border: none;
-    pointer-events: none;
-}
-

- -

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/transparency.html b/manual/fr/transparency.html deleted file mode 100644 index ff4420a9f94ae0..00000000000000 --- a/manual/fr/transparency.html +++ /dev/null @@ -1,401 +0,0 @@ - - - Transparence - - - - - - - - - - - - - -
-
-

Transparence

-
-
-
-

La transparence dans three.js est à la fois facile et difficile.

-

Nous allons d'abord aborder la partie facile. Créons une -scène avec 8 cubes placés sur une grille 2x2x2.

-

Nous allons commencer par l'exemple de -l'article sur le rendu à la demande -qui contenait 3 cubes et le modifier pour en avoir 8. Modifions d'abord notre -fonction makeInstance pour qu'elle prenne -x, y et z

-
-function makeInstance(geometry, color) {
-+function makeInstance(geometry, color, x, y, z) {
-  const material = new THREE.MeshPhongMaterial({color});
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
--  cube.position.x = x;
-+  cube.position.set(x, y, z);
-
-  return cube;
-}
-
-

Ensuite, nous pouvons créer 8 cubes.

-
+function hsl(h, s, l) {
-+  return (new THREE.Color()).setHSL(h, s, l);
-+}
-
--makeInstance(geometry, 0x44aa88,  0);
--makeInstance(geometry, 0x8844aa, -2);
--makeInstance(geometry, 0xaa8844,  2);
-
-+{
-+  const d = 0.8;
-+  makeInstance(geometry, hsl(0 / 8, 1, .5), -d, -d, -d);
-+  makeInstance(geometry, hsl(1 / 8, 1, .5),  d, -d, -d);
-+  makeInstance(geometry, hsl(2 / 8, 1, .5), -d,  d, -d);
-+  makeInstance(geometry, hsl(3 / 8, 1, .5),  d,  d, -d);
-+  makeInstance(geometry, hsl(4 / 8, 1, .5), -d, -d,  d);
-+  makeInstance(geometry, hsl(5 / 8, 1, .5),  d, -d,  d);
-+  makeInstance(geometry, hsl(6 / 8, 1, .5), -d,  d,  d);
-+  makeInstance(geometry, hsl(7 / 8, 1, .5),  d,  d,  d);
-+}
-
-

J'ai aussi ajusté la caméra.

-
const fov = 75;
-const aspect = 2;  // the canvas default
-const near = 0.1;
--const far = 5;
-+const far = 25;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
--camera.position.z = 4;
-+camera.position.z = 2;
-
-

Définissez le fond en blanc.

-
const scene = new THREE.Scene();
-+scene.background = new THREE.Color('white');
-
-

Et ajouté une deuxième lumière pour que toutes les faces des cubes reçoivent de l'éclairage.

-
-{
-+function addLight(...pos) {
-  const color = 0xFFFFFF;
-  const intensity = 1;
-  const light = new THREE.DirectionalLight(color, intensity);
--  light.position.set(-1, 2, 4);
-+  light.position.set(...pos);
-  scene.add(light);
-}
-+addLight(-1, 2, 4);
-+addLight( 1, -1, -2);
-
-

Pour rendre les cubes transparents, il suffit de définir le -drapeau transparent et de définir un -niveau d'opacity, 1 étant complètement opaque -et 0 étant complètement transparent.

-
function makeInstance(geometry, color, x, y, z) {
--  const material = new THREE.MeshPhongMaterial({color});
-+  const material = new THREE.MeshPhongMaterial({
-+    color,
-+    opacity: 0.5,
-+    transparent: true,
-+  });
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  cube.position.set(x, y, z);
-
-  return cube;
-}
-
-

et avec cela, nous obtenons 8 cubes transparents.

-

- -

-

Faites glisser sur l'exemple pour faire pivoter la vue.

-

Cela semble donc facile mais... regardez de plus près. Les cubes n'ont pas -de faces arrière.

-
-
pas de faces arrière
- -

Nous avons découvert la propriété de matériau side dans -l'article sur les matériaux. -Définissons-la donc sur THREE.DoubleSide pour que les deux faces de chaque cube soient dessinées.

-
const material = new THREE.MeshPhongMaterial({
-  color,
-  map: loader.load(url),
-  opacity: 0.5,
-  transparent: true,
-+  side: THREE.DoubleSide,
-});
-
-

Et nous obtenons

-

- -

-

Faites-le tourner. Cela semble fonctionner car nous pouvons voir les faces arrière, -sauf qu'en y regardant de plus près, parfois ce n'est pas le cas.

-
-
la face arrière gauche de chaque cube est manquante
- -

Cela se produit en raison de la manière dont les objets 3D sont généralement dessinés. Pour chaque géométrie, -chaque triangle est dessiné un par un. Lorsque chaque pixel du triangle est dessiné, -2 choses sont enregistrées. Premièrement, la couleur de ce pixel, et deuxièmement, la profondeur de ce pixel. -Lorsque le triangle suivant est dessiné, pour chaque pixel, si la profondeur est plus importante que la -profondeur précédemment enregistrée, aucun pixel n'est dessiné.

-

Cela fonctionne très bien pour les objets opaques, mais échoue pour les objets transparents.

-

La solution consiste à trier les objets transparents et à dessiner ceux situés à l'arrière avant -de dessiner ceux à l'avant. THREE.js le fait pour les objets comme les Mesh, -sinon le tout premier exemple aurait échoué entre les cubes, certains cubes bloquant les autres. -Malheureusement, pour les triangles individuels, le tri serait extrêmement lent.

-

Le cube a 12 triangles, 2 pour chaque face, et l'ordre dans lequel ils sont dessinés est -le même que celui dans lequel ils sont construits dans la géométrie. -Ainsi, selon la direction dans laquelle nous regardons, les triangles les plus proches de la caméra -peuvent être dessinés en premier. Dans ce cas, les triangles à l'arrière ne sont pas dessinés. -C'est pourquoi parfois nous ne voyons pas les faces arrière.

-

Pour un objet convexe comme une sphère ou un cube, une sorte de solution consiste à ajouter -chaque cube à la scène deux fois. Une fois avec un matériau qui dessine -uniquement les triangles orientés vers l'arrière, et une autre fois avec un matériau qui dessine uniquement -les triangles orientés vers l'avant.

-
function makeInstance(geometry, color, x, y, z) {
-+  [THREE.BackSide, THREE.FrontSide].forEach((side) => {
-    const material = new THREE.MeshPhongMaterial({
-      color,
-      opacity: 0.5,
-      transparent: true,
-+      side,
-    });
-
-    const cube = new THREE.Mesh(geometry, material);
-    scene.add(cube);
-
-    cube.position.set(x, y, z);
-+  });
-}
-
-

Et avec cela, cela semble fonctionner.

-

- -

-

Cela suppose que le tri de three.js est stable. C'est-à-dire que parce que nous -avons ajouté le mesh side: THREE.BackSide en premier et parce qu'il est exactement à la même -position, il sera dessiné avant le mesh side: THREE.FrontSide.

-

Créons 2 plans qui se croisent (après avoir supprimé tout le code relatif aux cubes). -Nous allons ajouter une texture à chaque plan.

-
const planeWidth = 1;
-const planeHeight = 1;
-const geometry = new THREE.PlaneGeometry(planeWidth, planeHeight);
-
-const loader = new THREE.TextureLoader();
-
-function makeInstance(geometry, color, rotY, url) {
-  const texture = loader.load(url, render);
-  const material = new THREE.MeshPhongMaterial({
-    color,
-    map: texture,
-    opacity: 0.5,
-    transparent: true,
-    side: THREE.DoubleSide,
-  });
-
-  const mesh = new THREE.Mesh(geometry, material);
-  scene.add(mesh);
-
-  mesh.rotation.y = rotY;
-}
-
-makeInstance(geometry, 'pink',       0,             'resources/images/happyface.png');
-makeInstance(geometry, 'lightblue',  Math.PI * 0.5, 'resources/images/hmmmface.png');
-
-

Cette fois, nous pouvons utiliser side: THREE.DoubleSide car nous ne pouvons jamais voir qu'une -seule face d'un plan à la fois. Notez également que nous passons notre fonction render à la fonction de chargement de texture -afin que lorsque la texture a fini de charger, nous rendions à nouveau la scène. -C'est parce que cet exemple effectue un rendu à la demande -au lieu d'un rendu continu.

-

- -

-

Et encore une fois, nous voyons un problème similaire.

-
-
la moitié d'une face est manquante
- -

La solution ici est de diviser manuellement chaque plan en 2 plans -afin qu'il n'y ait réellement aucune intersection.

-
function makeInstance(geometry, color, rotY, url) {
-+  const base = new THREE.Object3D();
-+  scene.add(base);
-+  base.rotation.y = rotY;
-
-+  [-1, 1].forEach((x) => {
-    const texture = loader.load(url, render);
-+    texture.offset.x = x < 0 ? 0 : 0.5;
-+    texture.repeat.x = .5;
-    const material = new THREE.MeshPhongMaterial({
-      color,
-      map: texture,
-      opacity: 0.5,
-      transparent: true,
-      side: THREE.DoubleSide,
-    });
-
-    const mesh = new THREE.Mesh(geometry, material);
--    scene.add(mesh);
-+    base.add(mesh);
-
--    mesh.rotation.y = rotY;
-+    mesh.position.x = x * .25;
-  });
-}
-
-

La manière d'y parvenir dépend de vous. Si j'utilisais un logiciel de modélisation comme -Blender, je le ferais probablement manuellement en ajustant -les coordonnées de texture. Ici cependant, nous utilisons PlaneGeometry qui, par défaut, -étire la texture sur tout le plan. Comme nous l'avons vu précédemment, -en définissant texture.repeat -et texture.offset, nous pouvons mettre à l'échelle et déplacer la texture pour obtenir -la bonne moitié de la texture de face sur chaque plan.

-

Le code ci-dessus crée également un Object3D et lui attache les 2 plans en tant qu'enfants. -Il semblait plus facile de faire pivoter un Object3D parent que de faire les calculs nécessaires -pour le faire sans.

-

- -

-

Cette solution ne fonctionne vraiment que pour des choses simples comme 2 plans dont -la position d'intersection ne change pas.

-

Pour les objets texturés, une autre solution consiste à définir un test alpha.

-

Un test alpha est un niveau d'alpha en dessous duquel three.js ne dessinera pas -le pixel. Si nous ne dessinons pas du tout un pixel, alors les problèmes de profondeur -mentionnés ci-dessus disparaissent. Pour les textures aux bords relativement nets, -cela fonctionne assez bien. Les exemples incluent les textures de feuilles sur une plante ou un arbre, -ou souvent une parcelle d'herbe.

-

Essayons sur les 2 plans. Utilisons d'abord des textures différentes. -Les textures ci-dessus étaient 100% opaques. Ces 2 utilisent la transparence.

-
-
-
-
- -

Retournons aux 2 plans qui se croisent (avant de les diviser) et utilisons -ces textures et définissons un alphaTest.

-
function makeInstance(geometry, color, rotY, url) {
-  const texture = loader.load(url, render);
-  const material = new THREE.MeshPhongMaterial({
-    color,
-    map: texture,
--    opacity: 0.5,
-    transparent: true,
-+    alphaTest: 0.5,
-    side: THREE.DoubleSide,
-  });
-
-  const mesh = new THREE.Mesh(geometry, material);
-  scene.add(mesh);
-
-  mesh.rotation.y = rotY;
-}
-
--makeInstance(geometry, 'pink',       0,             'resources/images/happyface.png');
--makeInstance(geometry, 'lightblue',  Math.PI * 0.5, 'resources/images/hmmmface.png');
-+makeInstance(geometry, 'white', 0,             'resources/images/tree-01.png');
-+makeInstance(geometry, 'white', Math.PI * 0.5, 'resources/images/tree-02.png');
-
-

Avant d'exécuter cela, ajoutons une petite interface utilisateur pour pouvoir jouer plus facilement avec les paramètres alphaTest -et transparent. Nous utiliserons lil-gui comme nous l'avons présenté -dans l'article sur le graphe de scène de three.js.

-

Nous allons d'abord créer une aide pour lil-gui qui définit une valeur pour chaque matériau de la scène.

-
class AllMaterialPropertyGUIHelper {
-  constructor(prop, scene) {
-    this.prop = prop;
-    this.scene = scene;
-  }
-  get value() {
-    const {scene, prop} = this;
-    let v;
-    scene.traverse((obj) => {
-      if (obj.material && obj.material[prop] !== undefined) {
-        v = obj.material[prop];
-      }
-    });
-    return v;
-  }
-  set value(v) {
-    const {scene, prop} = this;
-    scene.traverse((obj) => {
-      if (obj.material && obj.material[prop] !== undefined) {
-        obj.material[prop] = v;
-        obj.material.needsUpdate = true;
-      }
-    });
-  }
-}
-
-

Ensuite, nous allons ajouter l'interface graphique.

-
const gui = new GUI();
-gui.add(new AllMaterialPropertyGUIHelper('alphaTest', scene), 'value', 0, 1)
-    .name('alphaTest')
-    .onChange(requestRenderIfNotRequested);
-gui.add(new AllMaterialPropertyGUIHelper('transparent', scene), 'value')
-    .name('transparent')
-    .onChange(requestRenderIfNotRequested);
-
-

et bien sûr, nous devons inclure lil-gui.

-
import * as THREE from 'three';
-import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-+import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
-
-

et voici les résultats.

-

- -

-

Vous pouvez voir que cela fonctionne, mais zoomez et vous verrez qu'un plan a des lignes blanches.

-
- -

C'est le même problème de profondeur qu'auparavant. Ce plan a été dessiné en premier, -donc le plan situé derrière n'est pas dessiné. Il n'y a pas de solution parfaite. -Ajustez l'alphaTest et/ou désactivez transparent pour trouver une solution -qui correspond à votre cas d'utilisation.

-

La conclusion de cet article est que la transparence parfaite est difficile. -Il y a des problèmes, des compromis et des solutions de contournement.

-

Par exemple, disons que vous avez une voiture. -Les voitures ont généralement des pare-brise sur les 4 côtés. Si vous voulez éviter les problèmes de tri -ci-dessus, vous devrez faire de chaque fenêtre son propre objet afin que three.js puisse -trier les fenêtres et les dessiner dans le bon ordre.

-

Si vous créez des plantes ou de l'herbe, la solution du test alpha est courante.

-

La solution que vous choisissez dépend de vos besoins.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/uniform-types.html b/manual/fr/uniform-types.html deleted file mode 100644 index 1f511b7abb58c9..00000000000000 --- a/manual/fr/uniform-types.html +++ /dev/null @@ -1,254 +0,0 @@ - - - Types d'uniformes - - - - - - - - - - - - - -
-
-

Types d'uniformes

-
-
-
- -

- Chaque uniforme doit avoir une propriété `value`. Le type de la valeur doit - correspondre au type de la variable uniforme dans le code GLSL tel que - spécifié pour les types GLSL primitifs dans le tableau ci-dessous. Les structures et - tableaux d'uniformes sont également pris en charge. Les tableaux GLSL de type primitif - doivent être spécifiés soit comme un tableau des objets THREE correspondants, soit - comme un tableau plat contenant les données de tous les objets. En d'autres termes, - les primitives GLSL dans les tableaux ne doivent pas être représentées par des tableaux. Cette règle - ne s'applique pas de manière transitive. Un tableau de tableaux `vec2`, chacun d'une longueur - de cinq vecteurs, doit être un tableau de tableaux, soit de cinq objets `Vector2`, - soit de dix `number`s. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
GLSL typeJavaScript type
intNumber
uintNumber
floatNumber
boolBoolean
boolNumber
vec2Vector2
vec2Float32Array (*)
vec2Array (*)
vec3Vector3
vec3Color
vec3Float32Array (*)
vec3Array (*)
vec4Vector4
vec4Quaternion
vec4Float32Array (*)
vec4Array (*)
mat2Float32Array (*)
mat2Array (*)
mat3Matrix3
mat3Float32Array (*)
mat3Array (*)
mat4Matrix4
mat4Float32Array (*)
mat4Array (*)
ivec2, bvec2Float32Array (*)
ivec2, bvec2Array (*)
ivec3, bvec3Int32Array (*)
ivec3, bvec3Array (*)
ivec4, bvec4Int32Array (*)
ivec4, bvec4Array (*)
sampler2DTexture
samplerCubeCubeTexture
- -

- (*) De même pour un tableau (le plus interne) (dimension) du même type GLSL, - contenant les composants de tous les vecteurs ou matrices du tableau. -

- -

Uniforms structurés

- -

- Parfois, vous voulez organiser les uniformes en tant que `structs` dans votre code de shader. - Le style suivant doit être utilisé pour que `three.js` puisse traiter - les données d'uniformes structurées. -

-
-uniforms = {
-  data: { 
-    value: {
-      position: new Vector3(), 
-      direction: new Vector3( 0, 0, 1 ) 
-    } 
-  } 
-};
-
- Cette définition peut être mappée sur le code GLSL suivant : -
-struct Data { 
-  vec3 position;
-  vec3 direction;
-};
-uniform Data data;
-
- -

Uniforms structurés avec tableaux

- -

- Il est également possible de gérer des `structs` dans des tableaux. La syntaxe pour ce cas d'utilisation - est la suivante : -

-
-const entry1 = {
-  position: new Vector3(),
-  direction: new Vector3( 0, 0, 1 )
-};
-const entry2 = {
-  position: new Vector3( 1, 1, 1 ),
-  direction: new Vector3( 0, 1, 0 )
-};
-
-uniforms = {
-  data: {
-    value: [ entry1, entry2 ]
-  }
-};
-
- Cette définition peut être mappée sur le code GLSL suivant : -
-struct Data { 
-  vec3 position; 
-  vec3 direction; 
-};
-uniform Data data[ 2 ];
-
- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/useful-links.html b/manual/fr/useful-links.html deleted file mode 100644 index 61ea23177180f2..00000000000000 --- a/manual/fr/useful-links.html +++ /dev/null @@ -1,193 +0,0 @@ - - - Liens utiles - - - - - - - - - - - - - -
-
-

Liens utiles

-
-
-
- -

- Voici une collection de liens qui pourraient vous être utiles pour apprendre three.js.
- Si vous trouvez quelque chose que vous aimeriez ajouter ici, ou si vous pensez que l'un des liens ci-dessous n'est plus pertinent ou ne fonctionne plus, n'hésitez pas à cliquer sur le bouton 'edit' en bas à droite et à apporter des modifications !

- - Notez également qu'étant donné que three.js est en développement rapide, de nombreux liens contiendront des informations obsolètes. Si quelque chose ne fonctionne pas comme prévu ou comme l'indique l'un de ces liens, vérifiez la console du navigateur pour les avertissements ou les erreurs. Consultez également les pages de documentation pertinentes. -

- -

Forums d'aide

-

- Three.js utilise officiellement le [link:https://discourse.threejs.org/ forum] et [link:http://stackoverflow.com/tags/three.js/info Stack Overflow] pour les demandes d'aide. - Si vous avez besoin d'aide pour quelque chose, c'est l'endroit où aller. NE PAS ouvrir de problème sur Github pour les demandes d'aide. -

- -

Tutoriels et cours

- -

Pour commencer avec three.js

-
    -
  • - [link:https://threejs.org/manual/#en/fundamentals Leçon d'introduction aux Fondamentaux de Three.js] -
  • -
  • - [link:https://codepen.io/rachsmith/post/beginning-with-3d-webgl-pt-1-the-scene Pour commencer avec la 3D WebGL] par [link:https://codepen.io/rachsmith/ Rachel Smith]. -
  • -
  • - [link:https://www.august.com.au/blog/animating-scenes-with-webgl-three-js/ Animer des scènes avec WebGL et three.js] -
  • -
- -

Articles et cours plus approfondis / avancés

-
    -
  • - [link:https://threejs-journey.com/ Three Journey] Cours par [link:https://bruno-simon.com/ Bruno Simon] - Apprend aux débutants à utiliser Three.js étape par étape -
  • -
  • - [link:https://discoverthreejs.com/ Découvrir three.js] -
  • -
  • - [link:http://blog.cjgammon.com/ Collection de tutoriels] par [link:http://www.cjgammon.com/ CJ Gammon]. -
  • -
  • - [link:https://medium.com/soffritti.pierfrancesco/glossy-spheres-in-three-js-bfd2785d4857 Sphères brillantes dans three.js]. -
  • -
  • - [link:https://www.udacity.com/course/interactive-3d-graphics--cs291 Graphismes 3D Interactifs] - un cours gratuit sur Udacity qui enseigne les fondamentaux des graphismes 3D et utilise three.js comme outil de codage. -
  • -
  • - [Link:https://aerotwist.com/tutorials/ Aerotwist] tutoriels par [link:https://github.com/paullewis/ Paul Lewis]. -
  • -
  • - [link:https://discourse.threejs.org/t/three-js-bookshelf/2468 Étagère à livres Three.js] - Vous cherchez plus de ressources sur three.js ou les graphismes par ordinateur en général ? Consultez la sélection de littérature recommandée par la communauté. -
  • -
- -

Nouvelles et mises à jour

-
    -
  • - [link:https://twitter.com/hashtag/threejs Three.js sur Twitter] -
  • -
  • - [link:http://www.reddit.com/r/threejs/ Three.js sur reddit] -
  • -
  • - [link:http://www.reddit.com/r/webgl/ WebGL sur reddit] -
  • -
- -

Exemples

-
    -
  • - [link:https://github.com/edwinwebb/three-seed/ three-seed] - projet de démarrage three.js avec ES6 et Webpack -
  • -
  • - [link:http://stemkoski.github.io/Three.js/index.html Exemples du Professeur Stemkoski] - une collection d'exemples adaptés aux débutants construits à l'aide de three.js r60. -
  • -
  • - [link:https://threejs.org/examples/ Exemples officiels de three.js] - ces exemples sont maintenus dans le cadre du dépôt three.js et utilisent toujours la dernière version de three.js. -
  • -
  • - [link:https://raw.githack.com/mrdoob/three.js/dev/examples/ Exemples officiels de la branche de développement three.js] - Identiques aux exemples ci-dessus, sauf qu'ils utilisent la branche de développement de three.js et sont utilisés pour vérifier que tout fonctionne correctement pendant le développement de three.js. -
  • -
- -

Outils

-
    -
  • - [link:https://github.com/tbensky/physgl physgl.org] - Interface front-end JavaScript avec des wrappers pour three.js, pour apporter les graphismes WebGL aux étudiants apprenant la physique et les mathématiques. -
  • -
  • - [link:https://whsjs.readme.io/ Whitestorm.js] – Framework three.js modulaire avec plugin physique AmmoNext. -
  • -
  • - [link:http://zz85.github.io/zz85-bookmarklets/threelabs.html Inspecteur Three.js] -
  • -
  • - [link:http://idflood.github.io/ThreeNodes.js/ ThreeNodes.js]. -
  • -
  • - [link:https://marketplace.visualstudio.com/items?itemName=slevesque.shader vscode shader] - Colorateur syntaxique pour le langage de shader. -
    - [link:https://marketplace.visualstudio.com/items?itemName=bierner.comment-tagged-templates vscode comment-tagged-templates] - Coloration syntaxique pour les chaînes de gabarit marquées utilisant des commentaires pour le langage de shader, comme : glsl.js. -
  • -
  • - [link:https://github.com/MozillaReality/WebXR-emulator-extension WebXR-emulator-extension] -
  • -
- -

Références WebGL

-
    -
  • - [link:https://www.khronos.org/files/webgl/webgl-reference-card-1_0.pdf webgl-reference-card.pdf] - Référence de tous les mots-clés, terminologie, syntaxe et définitions de WebGL et GLSL. -
  • -
- -

Anciens liens

-

- Ces liens sont conservés à des fins historiques - vous pouvez toujours les trouver utiles, mais sachez qu'ils peuvent contenir des informations relatives à de très anciennes versions de three.js. -

- -
    -
  • - [link:https://www.youtube.com/watch?v=Dir4KO9RdhM AlterQualia at WebGL Camp 3] -
  • -
  • - [link:http://yomotsu.github.io/threejs-examples/ Yomotsus Examples] - une collection d'exemples utilisant three.js r45. -
  • -
  • - [link:http://fhtr.org/BasicsOfThreeJS/#1 Introduction à Three.js] par [link:http://github.com/kig/ Ilmari Heikkinen] (diaporama). -
  • -
  • - [link:http://www.slideshare.net/yomotsu/webgl-and-threejs WebGL and Three.js] par [link:http://github.com/yomotsu Akihiro Oyamada] (diaporama). -
  • -
  • - [link:https://www.youtube.com/watch?v=VdQnOaolrPA Trigger Rally] par [link:https://github.com/jareiko jareiko] (vidéo). -
  • -
  • - [link:http://blackjk3.github.io/threefab/ ThreeFab] - éditeur de scène, maintenu jusqu'à environ three.js r50. -
  • -
  • - [link:http://bkcore.com/blog/3d/webgl-three-js-workflow-tips.html Max to Three.js workflow tips and tricks] par [link:https://github.com/BKcore BKcore] -
  • -
  • - [link:http://12devsofxmas.co.uk/2012/01/webgl-and-three-js/ Un aperçu rapide de Three.js] - par [link:http://github.com/nrocy Paul King] -
  • -
  • - [link:http://bkcore.com/blog/3d/webgl-three-js-animated-selective-glow.html Lueur sélective animée dans Three.js] - par [link:https://github.com/BKcore BKcore] -
  • -
  • - [link:http://www.natural-science.or.jp/article/20120220155529.php Building A Physics Simulation Environment] - tutoriel three.js en japonais -
  • -
- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/voxel-geometry.html b/manual/fr/voxel-geometry.html deleted file mode 100644 index 15dee6fdea8e02..00000000000000 --- a/manual/fr/voxel-geometry.html +++ /dev/null @@ -1,1116 +0,0 @@ - - - Géométrie Voxel (type Minecraft) - - - - - - - - - - - - - -
-
-

Géométrie Voxel (type Minecraft)

-
-
-
-

J'ai vu ce sujet revenir plus d'une fois à divers endroits. -C'est fondamentalement, "Comment faire un affichage de voxels comme Minecraft".

-

La plupart des gens essaient d'abord en créant une géométrie de cube, puis -en faisant un maillage à chaque position de voxel. Juste pour le plaisir, j'ai essayé -cela. J'ai créé un Uint8Array de 16777216 éléments pour représenter -un cube de voxels de 256x256x256.

-
const cellSize = 256;
-const cell = new Uint8Array(cellSize * cellSize * cellSize);
-
-

J'ai ensuite fait une seule couche avec une sorte de collines de -vagues sinusoïdales comme ceci

-
for (let y = 0; y < cellSize; ++y) {
-  for (let z = 0; z < cellSize; ++z) {
-    for (let x = 0; x < cellSize; ++x) {
-      const height = (Math.sin(x / cellSize * Math.PI * 4) + Math.sin(z / cellSize * Math.PI * 6)) * 20 + cellSize / 2;
-      if (height > y && height < y + 1) {
-        const offset = y * cellSize * cellSize +
-                       z * cellSize +
-                       x;
-        cell[offset] = 1;
-      }
-    }
-  }
-}
-
-

J'ai ensuite parcouru toutes les cellules et si elles n'étaient pas -à 0, j'ai créé un maillage avec un cube.

-
const geometry = new THREE.BoxGeometry(1, 1, 1);
-const material = new THREE.MeshPhongMaterial({color: 'green'});
-
-for (let y = 0; y < cellSize; ++y) {
-  for (let z = 0; z < cellSize; ++z) {
-    for (let x = 0; x < cellSize; ++x) {
-      const offset = y * cellSize * cellSize +
-                     z * cellSize +
-                     x;
-      const block = cell[offset];
-      const mesh = new THREE.Mesh(geometry, material);
-      mesh.position.set(x, y, z);
-      scene.add(mesh);
-    }
-  }
-}
-
-

Le reste du code est basé sur l'exemple de -l'article sur le rendu à la demande.

-

- -

-

Cela prend un certain temps pour démarrer et si vous essayez de bouger la caméra -c'est probablement trop lent. Comme dans l'article sur l'optimisation de nombreux objets -le problème est qu'il y a juste beaucoup trop d'objets. 256x256 -fait 65536 boîtes !

-

L'utilisation de la technique de fusion de la géométrie -résoudra le problème pour cet exemple, mais que se passerait-il si, au lieu de faire une simple couche, nous remplissions tout ce qui se trouve sous le sol avec des voxels ? -En d'autres termes, changez la boucle qui remplit les voxels comme ceci :

-
for (let y = 0; y < cellSize; ++y) {
-  for (let z = 0; z < cellSize; ++z) {
-    for (let x = 0; x < cellSize; ++x) {
-      const height = (Math.sin(x / cellSize * Math.PI * 4) + Math.sin(z / cellSize * Math.PI * 6)) * 20 + cellSize / 2;
--      if (height > y && height < y + 1) {
-+      if (height < y + 1) {
-        const offset = y * cellSize * cellSize +
-                       z * cellSize +
-                       x;
-        cell[offset] = 1;
-      }
-    }
-  }
-}
-
-

J'ai essayé une fois juste pour voir les résultats. Ça a mouliné pendant -environ une minute, puis ça a planté avec un message manque de mémoire 😅

-

Il y a plusieurs problèmes, mais le plus important est -que nous créons toutes ces faces à l'intérieur des cubes que -nous ne pouvons en fait jamais voir.

-

En d'autres termes, disons que nous avons une boîte de voxels -3x2x2. En fusionnant les cubes, nous obtenons ceci :

-
-
-
- -

mais nous voulons vraiment ceci

-
-
-
- -

Dans la boîte du haut, il y a des faces entre les voxels. Des faces -qui sont un gâchis car elles ne peuvent pas être vues. Ce n'est pas seulement -une face entre chaque voxel, il y a 2 faces, une pour -chaque voxel faisant face à son voisin qui sont un gâchis. Toutes ces faces supplémentaires, -surtout pour un grand volume de voxels, tueront les performances.

-

Il devrait être clair que nous ne pouvons pas simplement fusionner la géométrie. -Nous devons la construire nous-mêmes, en tenant compte du fait -que si un voxel a un voisin adjacent, il n'a pas besoin de la -face qui fait face à ce voisin.

-

Le problème suivant est que 256x256x256 est tout simplement trop grand. 16 Mo représentent beaucoup de mémoire et -si rien d'autre n'y est, une grande partie de l'espace est vide, ce qui représente beaucoup de mémoire gaspillée. C'est aussi un nombre énorme de voxels, 16 millions ! C'est trop à -considérer d'un coup.

-

Une solution consiste à diviser la zone en zones plus petites. -Toute zone qui ne contient rien n'a pas besoin de stockage. Utilisons -des zones de 32x32x32 (soit 32k) et ne créons une zone que si elle contient quelque chose. -Nous appellerons l'une de ces zones plus grandes de 32x32x32 une "cellule".

-

Découpons cela en morceaux. Tout d'abord, créons une classe pour gérer les données de voxel.

-
class VoxelWorld {
-  constructor(cellSize) {
-    this.cellSize = cellSize;
-  }
-}
-
-

Créons la fonction qui génère la géométrie pour une cellule. -Supposons que vous passiez une position de cellule. -En d'autres termes, si vous voulez la géométrie pour la cellule qui couvre les voxels (0-31x, 0-31y, 0-31z) -alors vous passerez 0,0,0. Pour la cellule qui couvre les voxels (32-63x, 0-31y, 0-31z), vous passerez -1,0,0.

-

Nous devons pouvoir vérifier les voxels voisins, alors supposons que notre classe -dispose d'une fonction getVoxel qui, étant donné une position de voxel, renvoie la valeur -du voxel à cet endroit. En d'autres termes, si vous lui passez 35,0,0 et que la cellSize est de 32, -elle regardera la cellule 1,0,0 et dans cette cellule, elle regardera le voxel 3,0,0. -En utilisant cette fonction, nous pouvons regarder les voxels voisins d'un voxel, même s'ils -se trouvent dans des cellules voisines.

-
class VoxelWorld {
-  constructor(cellSize) {
-    this.cellSize = cellSize;
-  }
-+  generateGeometryDataForCell(cellX, cellY, cellZ) {
-+    const {cellSize} = this;
-+    const startX = cellX * cellSize;
-+    const startY = cellY * cellSize;
-+    const startZ = cellZ * cellSize;
-+
-+    for (let y = 0; y < cellSize; ++y) {
-+      const voxelY = startY + y;
-+      for (let z = 0; z < cellSize; ++z) {
-+        const voxelZ = startZ + z;
-+        for (let x = 0; x < cellSize; ++x) {
-+          const voxelX = startX + x;
-+          const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
-+          if (voxel) {
-+            for (const {dir} of VoxelWorld.faces) {
-+              const neighbor = this.getVoxel(
-+                  voxelX + dir[0],
-+                  voxelY + dir[1],
-+                  voxelZ + dir[2]);
-+              if (!neighbor) {
-+                // ce voxel n'a pas de voisin dans cette direction, nous avons donc besoin d'une face ici.
-+                // here.
-+              }
-+            }
-+          }
-+        }
-+      }
-+    }
-+  }
-}
-
-+VoxelWorld.faces = [
-+  { // gauche
-+    dir: [ -1,  0,  0, ],
-+  },
-+  { // droite
-+    dir: [  1,  0,  0, ],
-+  },
-+  { // bas
-+    dir: [  0, -1,  0, ],
-+  },
-+  { // haut
-+    dir: [  0,  1,  0, ],
-+  },
-+  { // arrière
-+    dir: [  0,  0, -1, ],
-+  },
-+  { // avant
-+    dir: [  0,  0,  1, ],
-+  },
-+];
-
-

Donc, en utilisant le code ci-dessus, nous savons quand nous avons besoin d'une face. Générons les faces.

-
class VoxelWorld {
-  constructor(cellSize) {
-    this.cellSize = cellSize;
-  }
-  generateGeometryDataForCell(cellX, cellY, cellZ) {
-    const {cellSize} = this;
-+    const positions = [];
-+    const normals = [];
-+    const indices = [];
-    const startX = cellX * cellSize;
-    const startY = cellY * cellSize;
-    const startZ = cellZ * cellSize;
-
-    for (let y = 0; y < cellSize; ++y) {
-      const voxelY = startY + y;
-      for (let z = 0; z < cellSize; ++z) {
-        const voxelZ = startZ + z;
-        for (let x = 0; x < cellSize; ++x) {
-          const voxelX = startX + x;
-          const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
-          if (voxel) {
--            for (const {dir} of VoxelWorld.faces) {
-+            for (const {dir, corners} of VoxelWorld.faces) {
-              const neighbor = this.getVoxel(
-                  voxelX + dir[0],
-                  voxelY + dir[1],
-                  voxelZ + dir[2]);
-              if (!neighbor) {
-                // ce voxel n'a pas de voisin dans cette direction, nous avons donc besoin d'une face.
-+                const ndx = positions.length / 3;
-+                for (const pos of corners) {
-+                  positions.push(pos[0] + x, pos[1] + y, pos[2] + z);
-+                  normals.push(...dir);
-+                }
-+                indices.push(
-+                  ndx, ndx + 1, ndx + 2,
-+                  ndx + 2, ndx + 1, ndx + 3,
-+                );
-              }
-            }
-          }
-        }
-      }
-    }
-+    return {
-+      positions,
-+      normals,
-+      indices,
-    };
-  }
-}
-
-VoxelWorld.faces = [
-  { // gauche
-    dir: [ -1,  0,  0, ],
-+    corners: [
-+      [ 0, 1, 0 ],
-+      [ 0, 0, 0 ],
-+      [ 0, 1, 1 ],
-+      [ 0, 0, 1 ],
-+    ],
-  },
-  { // droite
-    dir: [  1,  0,  0, ],
-+    corners: [
-+      [ 1, 1, 1 ],
-+      [ 1, 0, 1 ],
-+      [ 1, 1, 0 ],
-+      [ 1, 0, 0 ],
-+    ],
-  },
-  { // bas
-    dir: [  0, -1,  0, ],
-+    corners: [
-+      [ 1, 0, 1 ],
-+      [ 0, 0, 1 ],
-+      [ 1, 0, 0 ],
-+      [ 0, 0, 0 ],
-+    ],
-  },
-  { // haut
-    dir: [  0,  1,  0, ],
-+    corners: [
-+      [ 0, 1, 1 ],
-+      [ 1, 1, 1 ],
-+      [ 0, 1, 0 ],
-+      [ 1, 1, 0 ],
-+    ],
-  },
-  { // arrière
-    dir: [  0,  0, -1, ],
-+    corners: [
-+      [ 1, 0, 0 ],
-+      [ 0, 0, 0 ],
-+      [ 1, 1, 0 ],
-+      [ 0, 1, 0 ],
-+    ],
-  },
-  { // avant
-    dir: [  0,  0,  1, ],
-+    corners: [
-+      [ 0, 0, 1 ],
-+      [ 1, 0, 1 ],
-+      [ 0, 1, 1 ],
-+      [ 1, 1, 1 ],
-+    ],
-  },
-];
-
-

Le code ci-dessus générerait des données de géométrie de base pour nous. Il suffit de fournir -la fonction getVoxel. Commençons par une seule cellule codée en dur.

-
class VoxelWorld {
-  constructor(cellSize) {
-    this.cellSize = cellSize;
-+    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
-  }
-+  getCellForVoxel(x, y, z) {
-+    const {cellSize} = this;
-+    const cellX = Math.floor(x / cellSize);
-+    const cellY = Math.floor(y / cellSize);
-+    const cellZ = Math.floor(z / cellSize);
-+    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
-+      return null
-+    }
-+    return this.cell;
-+  }
-+  getVoxel(x, y, z) {
-+    const cell = this.getCellForVoxel(x, y, z);
-+    if (!cell) {
-+      return 0;
-+    }
-+    const {cellSize} = this;
-+    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
-+    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
-+    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
-+    const voxelOffset = voxelY * cellSize * cellSize +
-+                        voxelZ * cellSize +
-+                        voxelX;
-+    return cell[voxelOffset];
-+  }
-  generateGeometryDataForCell(cellX, cellY, cellZ) {
-
-  ...
-}
-
-

Cela semble fonctionner. Créons une fonction setVoxel -pour pouvoir définir des données.

-
class VoxelWorld {
-  constructor(cellSize) {
-    this.cellSize = cellSize;
-    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
-  }
-  getCellForVoxel(x, y, z) {
-    const {cellSize} = this;
-    const cellX = Math.floor(x / cellSize);
-    const cellY = Math.floor(y / cellSize);
-    const cellZ = Math.floor(z / cellSize);
-    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
-      return null
-    }
-    return this.cell;
-  }
-+  setVoxel(x, y, z, v) {
-+    let cell = this.getCellForVoxel(x, y, z);
-+    if (!cell) {
-+      return;  // TODO : ajouter une nouvelle cellule ?
-+    }
-+    const {cellSize} = this;
-+    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
-+    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
-+    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
-+    const voxelOffset = voxelY * cellSize * cellSize +
-+                        voxelZ * cellSize +
-+                        voxelX;
-+    cell[voxelOffset] = v;
-+  }
-  getVoxel(x, y, z) {
-    const cell = this.getCellForVoxel(x, y, z);
-    if (!cell) {
-      return 0;
-    }
-    const {cellSize} = this;
-    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
-    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
-    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
-    const voxelOffset = voxelY * cellSize * cellSize +
-                        voxelZ * cellSize +
-                        voxelX;
-    return cell[voxelOffset];
-  }
-  generateGeometryDataForCell(cellX, cellY, cellZ) {
-
-  ...
-}
-
-

Hmmm, je vois beaucoup de code répété. Arrangeons ça

-
class VoxelWorld {
-  constructor(cellSize) {
-    this.cellSize = cellSize;
-+    this.cellSliceSize = cellSize * cellSize;
-    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
-  }
-  getCellForVoxel(x, y, z) {
-    const {cellSize} = this;
-    const cellX = Math.floor(x / cellSize);
-    const cellY = Math.floor(y / cellSize);
-    const cellZ = Math.floor(z / cellSize);
-    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
-      return null;
-    }
-    return this.cell;
-  }
-+  computeVoxelOffset(x, y, z) {
-+    const {cellSize, cellSliceSize} = this;
-+    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
-+    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
-+    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
-+    return voxelY * cellSliceSize +
-+           voxelZ * cellSize +
-+           voxelX;
-+  }
-  setVoxel(x, y, z, v) {
-    const cell = this.getCellForVoxel(x, y, z);
-    if (!cell) {
-      return;  // TODO : ajouter une nouvelle cellule ?
-    }
--    const {cellSize} = this;
--    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
--    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
--    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
--    const voxelOffset = voxelY * cellSize * cellSize +
--                        voxelZ * cellSize +
--                        voxelX;
-+    const voxelOffset = this.computeVoxelOffset(x, y, z);
-    cell[voxelOffset] = v;
-  }
-  getVoxel(x, y, z) {
-    const cell = this.getCellForVoxel(x, y, z);
-    if (!cell) {
-      return 0;
-    }
--    const {cellSize} = this;
--    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
--    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
--    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
--    const voxelOffset = voxelY * cellSize * cellSize +
--                        voxelZ * cellSize +
--                        voxelX;
-+    const voxelOffset = this.computeVoxelOffset(x, y, z);
-    return cell[voxelOffset];
-  }
-  generateGeometryDataForCell(cellX, cellY, cellZ) {
-
-  ...
-}
-
-

Maintenant, créons du code pour remplir la première cellule avec des voxels.

-
const cellSize = 32;
-
-const world = new VoxelWorld(cellSize);
-
-for (let y = 0; y < cellSize; ++y) {
-  for (let z = 0; z < cellSize; ++z) {
-    for (let x = 0; x < cellSize; ++x) {
-      const height = (Math.sin(x / cellSize * Math.PI * 2) + Math.sin(z / cellSize * Math.PI * 3)) * (cellSize / 6) + (cellSize / 2);
-      if (y < height) {
-        world.setVoxel(x, y, z, 1);
-      }
-    }
-  }
-}
-
-

et du code pour effectivement générer la géométrie comme nous l'avons vu dans -l'article sur BufferGeometry personnalisé.

-
const {positions, normals, indices} = world.generateGeometryDataForCell(0, 0, 0);
-const geometry = new THREE.BufferGeometry();
-const material = new THREE.MeshLambertMaterial({color: 'green'});
-
-const positionNumComponents = 3;
-const normalNumComponents = 3;
-geometry.setAttribute(
-    'position',
-    new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
-geometry.setAttribute(
-    'normal',
-    new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
-geometry.setIndex(indices);
-const mesh = new THREE.Mesh(geometry, material);
-scene.add(mesh);
-
-

essayons

-

- -

-

Cela semble fonctionner ! D'accord, ajoutons des textures.

-

En cherchant sur le net, j'ai trouvé cet ensemble -de textures minecraft sous licence CC-BY-NC-SA par Joshtimus. -J'en ai choisi quelques-unes au hasard et j'ai construit cette texture atlas.

-
- -

Pour simplifier les choses, elles sont arrangées un type de voxel par colonne, -où la rangée supérieure est le côté d'un voxel. La 2ème rangée est -le dessus du voxel, et la 3ème rangée est le dessous du voxel.

-

Sachant cela, nous pouvons ajouter des informations à nos données VoxelWorld.faces -pour spécifier pour chaque face quelle rangée utiliser et les UVs à utiliser -pour cette face.

-
VoxelWorld.faces = [
-  { // gauche
-+    uvRow: 0,
-    dir: [ -1,  0,  0, ],
-    corners: [
--      [ 0, 1, 0 ],
--      [ 0, 0, 0 ],
--      [ 0, 1, 1 ],
--      [ 0, 0, 1 ],
-+      { pos: [ 0, 1, 0 ], uv: [ 0, 1 ], },
-+      { pos: [ 0, 0, 0 ], uv: [ 0, 0 ], },
-+      { pos: [ 0, 1, 1 ], uv: [ 1, 1 ], },
-+      { pos: [ 0, 0, 1 ], uv: [ 1, 0 ], },
-    ],
-  },
-  { // droite
-+    uvRow: 0,
-    dir: [  1,  0,  0, ],
-    corners: [
--      [ 1, 1, 1 ],
--      [ 1, 0, 1 ],
--      [ 1, 1, 0 ],
--      [ 1, 0, 0 ],
-+      { pos: [ 1, 1, 1 ], uv: [ 0, 1 ], },
-+      { pos: [ 1, 0, 1 ], uv: [ 0, 0 ], },
-+      { pos: [ 1, 1, 0 ], uv: [ 1, 1 ], },
-+      { pos: [ 1, 0, 0 ], uv: [ 1, 0 ], },
-+    ],
-  },
-  { // bas
-+    uvRow: 1,
-    dir: [  0, -1,  0, ],
-    corners: [
--      [ 1, 0, 1 ],
--      [ 0, 0, 1 ],
--      [ 1, 0, 0 ],
--      [ 0, 0, 0 ],
-+      { pos: [ 1, 0, 1 ], uv: [ 1, 0 ], },
-+      { pos: [ 0, 0, 1 ], uv: [ 0, 0 ], },
-+      { pos: [ 1, 0, 0 ], uv: [ 1, 1 ], },
-+      { pos: [ 0, 0, 0 ], uv: [ 0, 1 ], },
-+    ],
-  },
-  { // haut
-+    uvRow: 2,
-    dir: [  0,  1,  0, ],
-    corners: [
--      [ 0, 1, 1 ],
--      [ 1, 1, 1 ],
--      [ 0, 1, 0 ],
--      [ 1, 1, 0 ],
-+      { pos: [ 0, 1, 1 ], uv: [ 1, 1 ], },
-+      { pos: [ 1, 1, 1 ], uv: [ 0, 1 ], },
-+      { pos: [ 0, 1, 0 ], uv: [ 1, 0 ], },
-+      { pos: [ 1, 1, 0 ], uv: [ 0, 0 ], },
-+    ],
-  },
-  { // arrière
-+    uvRow: 0,
-    dir: [  0,  0, -1, ],
-    corners: [
--      [ 1, 0, 0 ],
--      [ 0, 0, 0 ],
--      [ 1, 1, 0 ],
--      [ 0, 1, 0 ],
-+      { pos: [ 1, 0, 0 ], uv: [ 0, 0 ], },
-+      { pos: [ 0, 0, 0 ], uv: [ 1, 0 ], },
-+      { pos: [ 1, 1, 0 ], uv: [ 0, 1 ], },
-+      { pos: [ 0, 1, 0 ], uv: [ 1, 1 ], },
-+    ],
-  },
-  { // avant
-+    uvRow: 0,
-    dir: [  0,  0,  1, ],
-    corners: [
--      [ 0, 0, 1 ],
--      [ 1, 0, 1 ],
--      [ 0, 1, 1 ],
--      [ 1, 1, 1 ],
-+      { pos: [ 0, 0, 1 ], uv: [ 0, 0 ], },
-+      { pos: [ 1, 0, 1 ], uv: [ 1, 0 ], },
-+      { pos: [ 0, 1, 1 ], uv: [ 0, 1 ], },
-+      { pos: [ 1, 1, 1 ], uv: [ 1, 1 ], },
-+    ],
-  },
-];
-
-

Et nous pouvons mettre à jour le code pour utiliser ces données. Nous devons -connaître la taille d'une tuile dans la texture atlas et les dimensions -de la texture.

-
class VoxelWorld {
--  constructor(cellSize) {
--    this.cellSize = cellSize;
-+  constructor(options) {
-+    this.cellSize = options.cellSize;
-+    this.tileSize = options.tileSize;
-+    this.tileTextureWidth = options.tileTextureWidth;
-+    this.tileTextureHeight = options.tileTextureHeight;
-+    const {cellSize} = this;
-+    this.cellSliceSize = cellSize * cellSize;
-+    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
-+  }
-
-  ...
-
-  generateGeometryDataForCell(cellX, cellY, cellZ) {
--    const {cellSize} = this;
-+    const {cellSize, tileSize, tileTextureWidth, tileTextureHeight} = this;
-    const positions = [];
-    const normals = [];
-+    const uvs = [];
-    const indices = [];
-    const startX = cellX * cellSize;
-    const startY = cellY * cellSize;
-    const startZ = cellZ * cellSize;
-
-    for (let y = 0; y < cellSize; ++y) {
-      const voxelY = startY + y;
-      for (let z = 0; z < cellSize; ++z) {
-        const voxelZ = startZ + z;
-        for (let x = 0; x < cellSize; ++x) {
-          const voxelX = startX + x;
-          const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
-          if (voxel) {
-            const uvVoxel = voxel - 1;  // le voxel 0 est le ciel, donc pour les UVs nous commençons à 0
-            // There is a voxel here but do we need faces for it?
--            for (const {dir, corners} of VoxelWorld.faces) {
-+            for (const {dir, corners, uvRow} of VoxelWorld.faces) {
-              const neighbor = this.getVoxel(
-                  voxelX + dir[0],
-                  voxelY + dir[1],
-                  voxelZ + dir[2]);
-              if (!neighbor) {
-                // ce voxel n'a pas de voisin dans cette direction, nous avons donc besoin d'une face.
-                const ndx = positions.length / 3;
--                for (const pos of corners) {
-+                for (const {pos, uv} of corners) {
-                  positions.push(pos[0] + x, pos[1] + y, pos[2] + z);
-                  normals.push(...dir);
-+                  uvs.push(
-+                        (uvVoxel +   uv[0]) * tileSize / tileTextureWidth,
-+                    1 - (uvRow + 1 - uv[1]) * tileSize / tileTextureHeight);
-                }
-                indices.push(
-                  ndx, ndx + 1, ndx + 2,
-                  ndx + 2, ndx + 1, ndx + 3,
-                );
-              }
-            }
-          }
-        }
-      }
-    }
-
-    return {
-      positions,
-      normals,
-      uvs,
-      indices,
-    };
-  }
-}
-
-

Nous devons ensuite charger la texture

-
const loader = new THREE.TextureLoader();
-const texture = loader.load('resources/images/minecraft/flourish-cc-by-nc-sa.png', render);
-texture.magFilter = THREE.NearestFilter;
-texture.minFilter = THREE.NearestFilter;
-texture.colorSpace = THREE.SRGBColorSpace;
-
-

et passer les paramètres à la classe VoxelWorld

-
+const tileSize = 16;
-+const tileTextureWidth = 256;
-+const tileTextureHeight = 64;
--const world = new VoxelWorld(cellSize);
-+const world = new VoxelWorld({
-+  cellSize,
-+  tileSize,
-+  tileTextureWidth,
-+  tileTextureHeight,
-+});
-
-

Utilisons réellement les UVs lors de la création de la géométrie -et la texture lorsque nous fabriquons le matériau

-
-const {positions, normals, indices} = world.generateGeometryDataForCell(0, 0, 0);
-+const {positions, normals, uvs, indices} = world.generateGeometryDataForCell(0, 0, 0);
-const geometry = new THREE.BufferGeometry();
--const material = new THREE.MeshLambertMaterial({color: 'green'});
-+const material = new THREE.MeshLambertMaterial({
-+  map: texture,
-+  side: THREE.DoubleSide,
-+  alphaTest: 0.1,
-+  transparent: true,
-+});
-
-const positionNumComponents = 3;
-const normalNumComponents = 3;
-+const uvNumComponents = 2;
-geometry.setAttribute(
-    'position',
-    new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
-geometry.setAttribute(
-    'normal',
-    new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
-+geometry.setAttribute(
-+    'uv',
-+    new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
-geometry.setIndex(indices);
-const mesh = new THREE.Mesh(geometry, material);
-scene.add(mesh);
-
-

Une dernière chose, nous devons réellement définir certains voxels -pour utiliser différentes textures.

-
for (let y = 0; y < cellSize; ++y) {
-  for (let z = 0; z < cellSize; ++z) {
-    for (let x = 0; x < cellSize; ++x) {
-      const height = (Math.sin(x / cellSize * Math.PI * 2) + Math.sin(z / cellSize * Math.PI * 3)) * (cellSize / 6) + (cellSize / 2);
-      if (y < height) {
--        world.setVoxel(x, y, z, 1);
-+        world.setVoxel(x, y, z, randInt(1, 17));
-+      }
-+    }
-+  }
-+}
-+
-+function randInt(min, max) {
-+  return Math.floor(Math.random() * (max - min) + min);
-+}
-
-

et avec cela, nous obtenons des textures !

-

- -

-

Supportons maintenant plus d'une cellule.

-

Pour ce faire, stockons les cellules dans un objet en utilisant des cell ids. -Un cell id sera simplement les coordonnées d'une cellule séparées par -une virgule. En d'autres termes, si nous demandons le voxel 35,0,0, -qui est dans la cellule 1,0,0, son id est donc "1,0,0".

-
class VoxelWorld {
-  constructor(options) {
-    this.cellSize = options.cellSize;
-    this.tileSize = options.tileSize;
-    this.tileTextureWidth = options.tileTextureWidth;
-    this.tileTextureHeight = options.tileTextureHeight;
-    const {cellSize} = this;
-    this.cellSliceSize = cellSize * cellSize;
--    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
-+    this.cells = {};
-  }
-+  computeCellId(x, y, z) {
-+    const {cellSize} = this;
-+    const cellX = Math.floor(x / cellSize);
-+    const cellY = Math.floor(y / cellSize);
-+    const cellZ = Math.floor(z / cellSize);
-+    return `${cellX},${cellY},${cellZ}`;
-+  }
-+  getCellForVoxel(x, y, z) {
--    const cellX = Math.floor(x / cellSize);
--    const cellY = Math.floor(y / cellSize);
--    const cellZ = Math.floor(z / cellSize);
--    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
--      return null;
--    }
--    return this.cell;
-+    return this.cells[this.computeCellId(x, y, z)];
-  }
-
-   ...
-}
-
-

et maintenant nous pouvons faire en sorte que setVoxel ajoute de nouvelles cellules si -nous essayons de définir un voxel dans une cellule qui n'existe pas encore

-
  setVoxel(x, y, z, v) {
--    const cell = this.getCellForVoxel(x, y, z);
-+    let cell = this.getCellForVoxel(x, y, z);
-    if (!cell) {
--      return 0;
-+      cell = this.addCellForVoxel(x, y, z);
-    }
-    const voxelOffset = this.computeVoxelOffset(x, y, z);
-    cell[voxelOffset] = v;
-  }
-+  addCellForVoxel(x, y, z) {
-+    const cellId = this.computeCellId(x, y, z);
-+    let cell = this.cells[cellId];
-+    if (!cell) {
-+      const {cellSize} = this;
-+      cell = new Uint8Array(cellSize * cellSize * cellSize);
-+      this.cells[cellId] = cell;
-+    }
-+    return cell;
-+  }
-
-

Rendons cela modifiable.

-

Tout d'abord, nous ajouterons une UI. En utilisant des boutons radio, nous pouvons créer un tableau de tuiles 8x2

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="ui">
-+    <div class="tiles">
-+      <input type="radio" name="voxel" id="voxel1" value="1"><label for="voxel1" style="background-position:   -0% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel2" value="2"><label for="voxel2" style="background-position: -100% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel3" value="3"><label for="voxel3" style="background-position: -200% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel4" value="4"><label for="voxel4" style="background-position: -300% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel5" value="5"><label for="voxel5" style="background-position: -400% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel6" value="6"><label for="voxel6" style="background-position: -500% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel7" value="7"><label for="voxel7" style="background-position: -600% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel8" value="8"><label for="voxel8" style="background-position: -700% -0%"></label>
-+    </div>
-+    <div class="tiles">
-+      <input type="radio" name="voxel" id="voxel9"  value="9" ><label for="voxel9"  style="background-position:  -800% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel10" value="10"><label for="voxel10" style="background-position:  -900% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel11" value="11"><label for="voxel11" style="background-position: -1000% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel12" value="12"><label for="voxel12" style="background-position: -1100% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel13" value="13"><label for="voxel13" style="background-position: -1200% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel14" value="14"><label for="voxel14" style="background-position: -1300% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel15" value="15"><label for="voxel15" style="background-position: -1400% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel16" value="16"><label for="voxel16" style="background-position: -1500% -0%"></label>
-+    </div>
-+  </div>
-</body>
-
-

Et ajouter du CSS pour le styliser, afficher les tuiles et mettre en évidence -la sélection actuelle

-
body {
-    margin: 0;
-}
-#c {
-    width: 100%;
-    height: 100%;
-    display: block;
-}
-+#ui {
-+    position: absolute;
-+    left: 10px;
-+    top: 10px;
-+    background: rgba(0, 0, 0, 0.8);
-+    padding: 5px;
-+}
-+#ui input[type=radio] {
-+  width: 0;
-+  height: 0;
-+  display: none;
-+}
-+#ui input[type=radio] + label {
-+  background-image: url('resources/images/minecraft/flourish-cc-by-nc-sa.png');
-+  background-size: 1600% 400%;
-+  image-rendering: pixelated;
-+  width: 64px;
-+  height: 64px;
-+  display: inline-block;
-+}
-+#ui input[type=radio]:checked + label {
-+  outline: 3px solid red;
-+}
-+@media (max-width: 600px), (max-height: 600px) {
-+  #ui input[type=radio] + label {
-+    width: 32px;
-+    height: 32px;
-+  }
-+}
-
-

L'expérience utilisateur (UX) sera la suivante. Si aucune tuile n'est sélectionnée et que vous cliquez sur un voxel, ce voxel sera effacé, ou si vous cliquez sur un voxel et que vous maintenez la touche Maj enfoncée, il sera effacé. Sinon, si une tuile est sélectionnée, elle sera ajoutée. Vous pouvez désélectionner le type de tuile sélectionné en cliquant à nouveau dessus.

-

Ce code permettra à l'utilisateur de désélectionner le -bouton radio surligné.

-
let currentVoxel = 0;
-let currentId;
-
-document.querySelectorAll('#ui .tiles input[type=radio][name=voxel]').forEach((elem) => {
-  elem.addEventListener('click', allowUncheck);
-});
-
-function allowUncheck() {
-  if (this.id === currentId) {
-    this.checked = false;
-    currentId = undefined;
-    currentVoxel = 0;
-  } else {
-    currentId = this.id;
-    currentVoxel = parseInt(this.value);
-  }
-}
-
-

Et le code ci-dessous nous permettra de définir un voxel en fonction de l'endroit -où l'utilisateur clique. Il utilise un code similaire à celui que nous avons -fait dans l'article sur la sélection -mais il n'utilise pas le RayCaster intégré. Au lieu de cela, -il utilise VoxelWorld.intersectRay qui renvoie -la position d'intersection et la normale de la face -touchée.

-
function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function placeVoxel(event) {
-  const pos = getCanvasRelativePosition(event);
-  const x = (pos.x / canvas.width ) *  2 - 1;
-  const y = (pos.y / canvas.height) * -2 + 1;  // notez que nous inversons Y
-
-  const start = new THREE.Vector3();
-  const end = new THREE.Vector3();
-  start.setFromMatrixPosition(camera.matrixWorld);
-  end.set(x, y, 1).unproject(camera);
-
-  const intersection = world.intersectRay(start, end);
-  if (intersection) {
-    const voxelId = event.shiftKey ? 0 : currentVoxel;
-    // le point d'intersection est sur la face. Cela signifie
-    // que l'imprécision mathématique pourrait nous placer de chaque côté de la face.
-    // alors allons à la moitié de la normale DANS le voxel si nous supprimons (currentVoxel = 0)
-    // ou HORS du voxel si nous ajoutons (currentVoxel > 0)
-    const pos = intersection.position.map((v, ndx) => {
-      return v + intersection.normal[ndx] * (voxelId > 0 ? 0.5 : -0.5);
-    });
-    world.setVoxel(...pos, voxelId);
-    updateVoxelGeometry(...pos);
-    requestRenderIfNotRequested();
-  }
-}
-
-const mouse = {
-  x: 0,
-  y: 0,
-};
-
-function recordStartPosition(event) {
-  mouse.x = event.clientX;
-  mouse.y = event.clientY;
-  mouse.moveX = 0;
-  mouse.moveY = 0;
-}
-function recordMovement(event) {
-  mouse.moveX += Math.abs(mouse.x - event.clientX);
-  mouse.moveY += Math.abs(mouse.y - event.clientY);
-}
-function placeVoxelIfNoMovement(event) {
-  if (mouse.moveX < 5 && mouse.moveY < 5) {
-    placeVoxel(event);
-  }
-  window.removeEventListener('pointermove', recordMovement);
-  window.removeEventListener('pointerup', placeVoxelIfNoMovement);
-}
-canvas.addEventListener('pointerdown', (event) => {
-  event.preventDefault();
-  recordStartPosition(event);
-  window.addEventListener('pointermove', recordMovement);
-  window.addEventListener('pointerup', placeVoxelIfNoMovement);
-}, {passive: false});
-canvas.addEventListener('touchstart', (event) => {
-  // arrêter le défilement
-  event.preventDefault();
-}, {passive: false});
-
-

Il se passe beaucoup de choses dans le code ci-dessus. En gros, la souris a une double fonction. L'une est de déplacer la caméra. L'autre est d'éditer le monde. Placer/Effacer un voxel se produit lorsque vous relâchez la souris, mais uniquement si vous n'avez pas bougé la souris depuis que vous avez appuyé pour la première fois. C'est juste une supposition que si vous avez bougé la souris, vous essayiez de déplacer la caméra, pas de placer un bloc. moveX et moveY sont en mouvement absolu, donc si vous vous déplacez de 10 vers la gauche puis de 10 vers la droite, vous aurez parcouru 20 unités. Dans ce cas, l'utilisateur était probablement juste en train de faire pivoter le modèle d'avant en arrière et ne voulait pas placer de bloc. Je n'ai pas fait de tests pour voir si 5 est une bonne valeur ou non.

-

Dans le code, nous appelons world.setVoxel pour définir un voxel et -ensuite updateVoxelGeometry pour mettre à jour la géométrie three.js -en fonction de ce qui a changé.

-

Faisons cela maintenant. Si l'utilisateur clique sur un -voxel au bord d'une cellule, la géométrie du voxel -dans la cellule adjacente pourrait avoir besoin d'une nouvelle géométrie. Cela signifie -que nous devons vérifier la cellule du voxel que nous venons d'éditer -ainsi que dans les 6 directions à partir de cette cellule.

-
const neighborOffsets = [
-  [ 0,  0,  0], // soi-même
-  [-1,  0,  0], // gauche
-  [ 1,  0,  0], // droite
-  [ 0, -1,  0], // bas
-  [ 0,  1,  0], // haut
-  [ 0,  0, -1], // arrière
-  [ 0,  0,  1], // avant
-];
-function updateVoxelGeometry(x, y, z) {
-  const updatedCellIds = {};
-  for (const offset of neighborOffsets) {
-    const ox = x + offset[0];
-    const oy = y + offset[1];
-    const oz = z + offset[2];
-    const cellId = world.computeCellId(ox, oy, oz);
-    if (!updatedCellIds[cellId]) {
-      updatedCellIds[cellId] = true;
-      updateCellGeometry(ox, oy, oz);
-    }
-  }
-}
-
-

J'ai pensé à vérifier les cellules adjacentes comme

-
const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
-if (voxelX === 0) {
-  // mettre à jour la cellule à gauche
-} else if (voxelX === cellSize - 1) {
-  // mettre à jour la cellule à droite
-}
-
-

et il y aurait 4 vérifications supplémentaires pour les 4 autres directions, -mais il m'est apparu que le code serait beaucoup plus simple avec -juste un tableau d'offsets et en sauvegardant les cell ids des -cellules que nous avons déjà mises à jour. Si le voxel mis à jour n'est pas -au bord d'une cellule, le test rejettera rapidement la mise à jour -de la même cellule.

-

Pour updateCellGeometry, nous allons simplement prendre le code que nous -avions auparavant et qui générait la géométrie pour une cellule -et le faire gérer plusieurs cellules.

-
const cellIdToMesh = {};
-function updateCellGeometry(x, y, z) {
-  const cellX = Math.floor(x / cellSize);
-  const cellY = Math.floor(y / cellSize);
-  const cellZ = Math.floor(z / cellSize);
-  const cellId = world.computeCellId(x, y, z);
-  let mesh = cellIdToMesh[cellId];
-  const geometry = mesh ? mesh.geometry : new THREE.BufferGeometry();
-
-  const {positions, normals, uvs, indices} = world.generateGeometryDataForCell(cellX, cellY, cellZ);
-  const positionNumComponents = 3;
-  geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
-  const normalNumComponents = 3;
-  geometry.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
-  const uvNumComponents = 2;
-  geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
-  geometry.setIndex(indices);
-  geometry.computeBoundingSphere();
-
-  if (!mesh) {
-    mesh = new THREE.Mesh(geometry, material);
-    mesh.name = cellId;
-    cellIdToMesh[cellId] = mesh;
-    scene.add(mesh);
-    mesh.position.set(cellX * cellSize, cellY * cellSize, cellZ * cellSize);
-  }
-}
-
-

Le code ci-dessus vérifie une map de cell ids vers les maillages. Si -nous demandons une cellule qui n'existe pas, un nouveau Mesh est créé -et ajouté au bon endroit dans l'espace monde. -À la fin, nous mettons à jour les attributes et les indices avec les nouvelles données.

-

- -

-

Quelques notes :

-

Le RayCaster aurait peut-être fonctionné très bien. Je n'ai pas essayé. -Au lieu de cela, j'ai trouvé un raycaster spécifique aux voxels. -qui est optimisé pour les voxels.

-

J'ai fait de intersectRay une partie de VoxelWorld car il semblait -que si cela devenait trop lent, nous pourrions lancer des rayons contre les cellules -avant de le faire sur les voxels comme une simple accélération si cela devenait -trop lent.

-

Vous pourriez vouloir changer la longueur du raycast -car actuellement, elle va jusqu'au Z-far. Je suppose que si l' -utilisateur clique sur quelque chose de trop éloigné, il ne veut pas vraiment -placer des blocs de l'autre côté du monde qui font 1 ou 2 pixels.

-

Appeler geometry.computeBoundingSphere pourrait être lent. -Nous pourrions simplement définir manuellement la bounding sphere pour qu'elle s'adapte -à la cellule entière.

-

Voulons-nous supprimer les cellules si tous les voxels de cette cellule sont à 0 ? -Ce serait probablement un changement raisonnable si nous voulions livrer ceci.

-

En réfléchissant à la manière dont cela fonctionne, il est clair que le -pire des cas absolu est un damier de voxels activés et désactivés. Je ne -sais pas d'emblée quelles autres stratégies utiliser -si les choses deviennent trop lentes. Peut-être que devenir trop lent -encouragerait simplement l'utilisateur à ne pas créer d'énormes zones en damier.

-

Pour simplifier, la texture atlas n'a qu'une seule colonne -par type de voxel. Il serait préférable de faire quelque chose de plus -flexible où nous aurions un tableau de types de voxels et chaque -type pourrait spécifier où se trouvent les textures de ses faces dans l'atlas. -Tel quel, beaucoup d'espace est gaspillé.

-

En regardant le vrai minecraft, il y a des tuiles qui ne sont pas -des voxels, pas des cubes. Comme une tuile de clôture ou des fleurs. Pour faire cela, -nous aurions à nouveau besoin d'un tableau de types de voxels et pour chaque -voxel, s'il s'agit d'un cube ou d'une autre géométrie. S'il ne s'agit pas d'un cube, -la vérification des voisins lors de la génération de la géométrie -devrait également changer. Un voxel de fleur à côté d'un autre -voxel ne devrait pas supprimer les faces entre eux.

-

Si vous voulez créer quelque chose de similaire à minecraft en utilisant three.js, -j'espère que cela vous a donné quelques idées pour commencer et comment -générer une géométrie quelque peu efficace.

-

- - - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/webgl-compatibility-check.html b/manual/fr/webgl-compatibility-check.html deleted file mode 100644 index fcf1ea33abe92d..00000000000000 --- a/manual/fr/webgl-compatibility-check.html +++ /dev/null @@ -1,62 +0,0 @@ - - - Vérification de la compatibilité WebGL - - - - - - - - - - - - - -
-
-

Vérification de la compatibilité WebGL

-
-
-
- -

- Bien que cela devienne de moins en moins un problème, certains appareils ou navigateurs peuvent encore ne pas prendre en charge WebGL 2. - La méthode suivante vous permet de vérifier s'il est pris en charge et d'afficher un message à l'utilisateur si ce n'est pas le cas. - Importez le module de détection de la prise en charge WebGL et exécutez le code suivant avant de tenter de rendre quoi que ce soit. -

- -
-import WebGL from 'three/addons/capabilities/WebGL.js';
-
-if ( WebGL.isWebGL2Available() ) {
-
-  // Initialisez la fonction ou d'autres initialisations ici
-  animate();
-
-} else {
-
-  const warning = WebGL.getWebGL2ErrorMessage();
-  document.getElementById( 'container' ).appendChild( warning );
-
-}
-
- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/webxr-basics.html b/manual/fr/webxr-basics.html deleted file mode 100644 index ab8d3d186253f1..00000000000000 --- a/manual/fr/webxr-basics.html +++ /dev/null @@ -1,376 +0,0 @@ - - - RV - - - - - - - - - - - - - -
-
-

RV

-
-
-
-

Créer une application RV dans three.js est assez simple. Il suffit essentiellement de dire à -three.js que vous souhaitez utiliser WebXR. Si vous y réfléchissez, quelques points concernant WebXR -devraient être clairs. La direction vers laquelle la caméra pointe est fournie par le système RV -lui-même, puisque l'utilisateur tourne la tête pour choisir une direction à regarder. De même, -le champ de vision et l'aspect seront fournis par le système RV, car chaque système a un champ -de vision et un aspect d'affichage différents.

-

Prenons un exemple de l'article sur la création d'une page web responsive -et rendons-le compatible avec la RV.

-

Avant de commencer, vous aurez besoin d'un dispositif compatible RV comme un smartphone Android, -Google Daydream, Oculus Go, Oculus Rift, Vive, Samsung Gear VR, un iPhone avec un -navigateur WebXR.

-

Ensuite, si vous exécutez localement, vous devez exécuter un simple serveur web, comme -expliqué dans l'article sur la configuration.

-

Si l'appareil que vous utilisez pour visualiser la RV n'est pas le même ordinateur sur lequel vous -exécutez, vous devez servir votre page web via https, sinon le navigateur ne permettra pas d'utiliser -l'API WebXR. Le serveur mentionné dans l'article sur la configuration -appelé Servez a une option pour utiliser https. -Cochez-le et démarrez le serveur.

-
- -

Notez les URL. Vous avez besoin de celle qui correspond à l'adresse IP locale de votre ordinateur. -Elle commencera généralement par 192, 172 ou 10. Saisissez cette adresse complète, y compris la partie https:// -dans le navigateur de votre appareil RV. Note : Votre ordinateur et votre appareil RV doivent être sur le même réseau local -ou WiFi, et vous devez probablement être sur un réseau domestique. note : De nombreux cafés sont configurés pour interdire ce type -de connexion machine à machine.

-

Vous serez accueilli par une erreur ressemblant à celle ci-dessous. Cliquez sur "avancé" puis cliquez sur -continuer.

-
- -

Vous pouvez maintenant exécuter vos exemples.

-

Si vous vous lancez vraiment dans le développement WebXR, une autre chose que vous devriez apprendre est -le débogage à distance -afin de pouvoir voir les avertissements, les erreurs de console, et bien sûr, réellement -déboguer votre code.

-

Si vous voulez juste voir le code fonctionner ci-dessous, vous pouvez simplement exécuter le code depuis -ce site.

-

La première chose à faire est d'inclure le support RV après -avoir inclus three.js

-
import * as THREE from 'three';
-+import {VRButton} from 'three/addons/webxr/VRButton.js';
-
-

Ensuite, nous devons activer le support WebXR de three.js et ajouter son -bouton RV à notre page

-
function main() {
-  const canvas = document.querySelector('#c');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+  renderer.xr.enabled = true;
-+  document.body.appendChild(VRButton.createButton(renderer));
-
-

Nous devons laisser three.js exécuter notre boucle de rendu. Jusqu'à présent, nous avons utilisé une -boucle requestAnimationFrame, mais pour supporter la RV, nous devons laisser three.js gérer -notre boucle de rendu pour nous. Nous pouvons le faire en appelant -WebGLRenderer.setAnimationLoop et en passant une fonction à appeler pour la boucle.

-
function render(time) {
-  time *= 0.001;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-  cubes.forEach((cube, ndx) => {
-    const speed = 1 + ndx * .1;
-    const rot = time * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
-  renderer.render(scene, camera);
-
--  requestAnimationFrame(render);
-}
-
--requestAnimationFrame(render);
-+renderer.setAnimationLoop(render);
-
-

Il y a un détail de plus. Nous devrions probablement définir une hauteur de caméra -qui soit à peu près moyenne pour un utilisateur debout.

-
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-+camera.position.set(0, 1.6, 0);
-
-

et déplacer les cubes pour qu'ils soient devant la caméra

-
const cube = new THREE.Mesh(geometry, material);
-scene.add(cube);
-
-cube.position.x = x;
-+cube.position.y = 1.6;
-+cube.position.z = -2;
-
-

Nous les avons définis à z = -2 car la caméra sera maintenant à z = 0 et -la caméra par défaut regarde vers l'axe -z.

-

Cela soulève un point extrêmement important. Les unités en RV sont des mètres. -En d'autres termes, Une Unité = Un Mètre. Cela signifie que la caméra est à 1,6 mètres au-dessus de 0. -Les centres des cubes sont à 2 mètres devant la caméra. Chaque cube -a une taille de 1x1x1 mètre. C'est important car la RV doit ajuster les choses à l'utilisateur -dans le monde réel. Cela signifie que les unités utilisées dans three.js doivent correspondre -aux mouvements de l'utilisateur lui-même.

-

Et avec cela, nous devrions obtenir 3 cubes tournant devant -la caméra avec un bouton pour entrer en RV.

-

- -

-

Je trouve que la RV fonctionne mieux si nous avons quelque chose entourant la caméra, comme -une pièce pour référence, alors ajoutons une simple cubemap en grille comme nous l'avons vu dans -l'article sur les arrière-plans. Nous utiliserons simplement la même texture de grille -pour chaque côté du cube, ce qui donnera une salle en grille.

-
const scene = new THREE.Scene();
-+{
-+  const loader = new THREE.CubeTextureLoader();
-+  const texture = loader.load([
-+    'resources/images/grid-1024.png',
-+    'resources/images/grid-1024.png',
-+    'resources/images/grid-1024.png',
-+    'resources/images/grid-1024.png',
-+    'resources/images/grid-1024.png',
-+    'resources/images/grid-1024.png',
-+  ]);
-+  scene.background = texture;
-+}
-
-

C'est mieux.

-

- -

-

Note : Pour voir réellement la RV, vous aurez besoin d'un appareil compatible WebXR. -Je crois que la plupart des téléphones Android peuvent supporter WebXR en utilisant Chrome ou Firefox. -Pour iOS, vous pourriez pouvoir utiliser cette application WebXR, -bien qu'en général, le support WebXR sur iOS ne soit pas supporté en mai 2019.

-

Pour utiliser WebXR sur Android ou iPhone, vous aurez besoin d'un Casque RV -pour téléphones. Vous pouvez en trouver entre 5$ pour un fait de carton -et 100$. Malheureusement, je ne sais pas lesquels recommander. J'en ai acheté -6 au fil des ans et ils sont tous de qualité variable. Je n'ai -jamais payé plus d'environ 25$.

-

Juste pour mentionner quelques-uns des problèmes

-
    -
  1. Sont-ils compatibles avec votre téléphone

    -

    Les téléphones existent en différentes tailles et les casques RV doivent donc correspondre. -De nombreux casques prétendent correspondre à une grande variété de tailles. Mon expérience -est que plus ils correspondent à de tailles, moins ils sont performants, car au lieu -d'être conçus pour une taille spécifique, ils doivent faire des compromis -pour correspondre à plus de tailles. Malheureusement, les casques multi-tailles sont le type le plus courant.

    -
  2. -
  3. Peuvent-ils faire la mise au point pour votre visage

    -

    Certains appareils ont plus d'ajustements que d'autres. Généralement, il y a -au maximum 2 ajustements. La distance entre les lentilles et vos yeux -et la distance entre les lentilles.

    -
  4. -
  5. Sont-ils trop réfléchissants

    -

    De nombreux casques ont un cône en plastique entre votre œil et le téléphone. -Si ce plastique est brillant ou réfléchissant, il agira comme -un miroir reflétant l'écran et sera très distrayant.

    -

    Peu de critiques, voire aucune, ne semblent couvrir ce problème.

    -
  6. -
  7. Sont-ils confortables sur votre visage.

    -

    La plupart des appareils reposent sur votre nez comme une paire de lunettes. -Cela peut faire mal après quelques minutes. Certains ont des sangles qui passent -autour de votre tête. D'autres ont une 3ème sangle qui passe par-dessus votre tête. Cela -peut aider ou non à maintenir l'appareil au bon endroit.

    -

    Il s'avère que pour la plupart (tous ?) des appareils, vos yeux doivent être centrés -avec les lentilles. Si les lentilles sont légèrement au-dessus ou en dessous de vos -yeux, l'image devient floue. Cela peut être très frustrant -car les choses peuvent commencer nettes, mais 45 à 60 secondes plus tard, l'appareil -s'est déplacé de 1 millimètre vers le haut ou vers le bas et vous réalisez soudain que vous avez -lutté pour faire la mise au point sur une image floue.

    -
  8. -
  9. Sont-ils compatibles avec vos lunettes.

    -

    Si vous portez des lunettes, vous devrez lire les critiques pour voir -si un casque particulier fonctionne bien avec les lunettes.

    -
  10. -
-

Je ne peux vraiment pas faire de recommandations malheureusement. Google propose quelques recommandations -bon marché faites en carton, -certains à partir de 5$, alors peut-être commencer par là et si vous aimez, -envisagez de passer à la vitesse supérieure. 5$ c'est le prix d'un café, alors sérieusement, essayez !

-

Il existe également 3 types de dispositifs de base.

-
    -
  1. 3 degrés de liberté (3dof), pas de dispositif d'entrée

    -

    C'est généralement le style téléphone, bien que parfois vous puissiez -acheter un dispositif d'entrée tiers. Les 3 degrés de liberté -signifient que vous pouvez regarder vers le haut/bas (1), gauche/droite (2) et que vous pouvez incliner -la tête gauche et droite (3).

    -
  2. -
  3. 3 degrés de liberté (3dof) avec 1 dispositif d'entrée (3dof)

    -

    C'est fondamentalement Google Daydream et Oculus GO

    -

    Ceux-ci permettent également 3 degrés de liberté et incluent un petit -contrôleur qui agit comme un pointeur laser dans la RV. -Le pointeur laser n'a également que 3 degrés de liberté. Le -système peut dire dans quelle direction le dispositif d'entrée pointe, mais -il ne peut pas dire où se trouve le dispositif.

    -
  4. -
  5. 6 degrés de liberté (6dof) avec dispositifs d'entrée (6dof)

    -

    Ceux-ci sont le vrai truc haha. 6 degrés de liberté -signifie que non seulement ces appareils savent dans quelle direction vous regardez, -mais ils savent aussi où se trouve réellement votre tête. Cela signifie que -si vous vous déplacez de gauche à droite ou d'avant en arrière ou si vous vous levez / vous asseyez, -les appareils peuvent enregistrer cela et tout dans la RV se déplace en conséquence. -C'est incroyablement et étonnamment réaliste. Avec une bonne démo, -vous serez époustouflé, ou du moins je l'ai été et je le suis toujours.

    -

    De plus, ces appareils incluent généralement 2 contrôleurs, un -pour chaque main, et le système peut dire exactement où se trouvent vos -mains et dans quelle orientation elles sont, de sorte que vous pouvez -manipuler des choses en RV en tendant simplement la main, touchant, -poussant, tournant, etc...

    -

    Les appareils à 6 degrés de liberté incluent le Vive et Vive Pro, -l'Oculus Rift et Quest, et je crois tous les appareils Windows MR.

    -
  6. -
-

Avec tout cela couvert, je ne sais pas avec certitude quels appareils fonctionneront avec WebXR. -Je suis sûr à 99% que la plupart des téléphones Android fonctionneront avec Chrome. Vous pourriez -avoir besoin d'activer le support WebXR dans about:flags. Je sais aussi que Google -Daydream fonctionnera également et de même, vous devez activer le support WebXR dans -about:flags. Oculus Rift, Vive et Vive Pro fonctionneront via -Chrome ou Firefox. Je suis moins sûr pour Oculus Go et Oculus Quest car les deux -utilisent des systèmes d'exploitation personnalisés, mais selon Internet, ils semblent tous deux fonctionner.

-

Bien, après cette longue digression sur les dispositifs RV et WebXR, il y a certaines choses à couvrir

-
    -
  • Prise en charge de la RV et de la non-RV

    -

    Pour autant que je sache, du moins depuis la version r112, il n'y a pas de moyen simple de prendre en charge -les modes RV et non-RV avec three.js. Idéalement, -si vous n'êtes pas en mode RV, vous devriez pouvoir contrôler la caméra en utilisant -les moyens que vous souhaitez, par exemple les OrbitControls, -et vous devriez obtenir un événement lors du passage en mode RV et -de la sortie du mode RV afin que vous puissiez activer/désactiver les contrôles.

    -
  • -
-

Si three.js ajoute un support pour faire les deux, j'essaierai de mettre à jour -cet article. En attendant, vous pourriez avoir besoin de 2 versions de votre -site OU de passer un drapeau dans l'URL, quelque chose comme

-
https://mysite.com/mycooldemo?allowvr=true
-

Alors nous pourrions ajouter des liens pour changer de mode

-
<body>
-  <canvas id="c"></canvas>
-+  <div class="mode">
-+    <a href="?allowvr=true" id="vr">Autoriser la RV</a>
-+    <a href="?" id="nonvr">Utiliser le mode non-RV</a>
-+  </div>
-</body>
-
-

et du CSS pour les positionner

-
body {
-    margin: 0;
-}
-#c {
-    width: 100%;
-    height: 100%;
-    display: block;
-}
-+.mode {
-+  position: absolute;
-+  right: 1em;
-+  top: 1em;
-+}
-
-

dans votre code, vous pourriez utiliser ce paramètre comme ceci

-
function main() {
-  const canvas = document.querySelector('#c');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
--  renderer.xr.enabled = true;
--  document.body.appendChild(VRButton.createButton(renderer));
-
-  const fov = 75;
-  const aspect = 2;  // the canvas default
-  const near = 0.1;
-  const far = 5;
-  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-  camera.position.set(0, 1.6, 0);
-
-+  const params = (new URL(document.location)).searchParams;
-+  const allowvr = params.get('allowvr') === 'true';
-+  if (allowvr) {
-+    renderer.xr.enabled = true;
-+    document.body.appendChild(VRButton.createButton(renderer));
-+    document.querySelector('#vr').style.display = 'none';
-+  } else {
-+    // no VR, add some controls
-+    const controls = new OrbitControls(camera, canvas);
-+    controls.target.set(0, 1.6, -2);
-+    controls.update();
-+    document.querySelector('#nonvr').style.display = 'none';
-+  }
-
-

Que ce soit bon ou mauvais, je ne sais pas. J'ai l'impression que les différences -entre ce qui est nécessaire pour la RV et ce qui est nécessaire pour la non-RV sont souvent -très différentes, donc pour tout sauf les choses les plus simples, peut-être que 2 pages séparées -sont meilleures ? Vous devrez décider.

-

Note : pour diverses raisons, cela ne fonctionnera pas dans l'éditeur en direct -sur ce site, donc si vous voulez le vérifier, -cliquez ici. -Il devrait démarrer en mode non-RV et vous pouvez utiliser la souris ou les doigts pour déplacer -la caméra. Cliquer sur "Autoriser la RV" devrait basculer pour permettre le mode RV et vous devriez -pouvoir cliquer sur "Entrer en RV" si vous êtes sur un dispositif RV.

-
    -
  • Décider du niveau de support RV

    -

    Ci-dessus, nous avons couvert 3 types de dispositifs RV.

    -
      -
    • 3DOF sans entrée
    • -
    • 3DOF + entrée 3DOF
    • -
    • 6DOF + entrée 6DOF
    • -
    -

    Vous devez décider combien d'efforts vous êtes prêt à investir -pour supporter chaque type de dispositif.

    -

    Par exemple, le dispositif le plus simple n'a pas d'entrée. Le mieux que vous puissiez -généralement faire est de faire en sorte qu'il y ait des boutons ou des objets dans la vue de l'utilisateur -et si l'utilisateur aligne un marqueur au centre de l'affichage -sur ces objets pendant une demi-seconde environ, alors ce bouton est cliqué. -Une UX courante consiste à afficher un petit minuteur qui apparaîtra au-dessus de l'objet indiquant -que si vous maintenez le marqueur à cet endroit pendant un moment, l'objet/bouton sera sélectionné.

    -

    Puisqu'il n'y a pas d'autre entrée, c'est à peu près le mieux que vous puissiez faire

    -

    Au niveau supérieur, vous avez un dispositif d'entrée 3DOF. Généralement, il -peut pointer vers des choses et l'utilisateur dispose d'au moins 2 boutons. Le Daydream -possède également un pavé tactile qui fournit des entrées tactiles normales.

    -

    Dans tous les cas, si un utilisateur dispose de ce type d'appareil, il est beaucoup plus -confortable pour l'utilisateur de pouvoir pointer les choses avec -son contrôleur que de devoir le faire avec sa tête en regardant les choses.

    -

    Un niveau similaire pourrait être un appareil 3DOF ou 6DOF avec un -contrôleur de console de jeu. Vous devrez décider quoi faire ici. -Je soupçonne que la chose la plus courante est que l'utilisateur doit toujours regarder -pour pointer et le contrôleur est juste utilisé pour les boutons.

    -

    Le dernier niveau est un utilisateur avec un casque 6DOF et 2 contrôleurs 6DOF. -Ces utilisateurs trouveront souvent une expérience qui n'est que 3DOF -frustrante. De même, ils s'attendent généralement à pouvoir -manipuler virtuellement des choses avec leurs mains en RV, donc vous devrez -décider si vous voulez supporter cela ou non.

    -
  • -
-

Comme vous pouvez le voir, commencer en RV est assez facile, mais réaliser quelque chose de livrable en RV -nécessitera beaucoup de décisions et de conception.

-

Ceci était une brève introduction à la RV avec three.js. Nous aborderons -certaines méthodes d'entrée dans des articles futurs.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/webxr-look-to-select.html b/manual/fr/webxr-look-to-select.html deleted file mode 100644 index bf60125decee3f..00000000000000 --- a/manual/fr/webxr-look-to-select.html +++ /dev/null @@ -1,388 +0,0 @@ - - - VR - Sélection par le regard - - - - - - - - - - - - - -
-
-

VR - Sélection par le regard

-
-
-
-

NOTE : Les exemples de cette page nécessitent un appareil compatible VR. Sans cela, ils ne fonctionneront pas. Voir l'article précédent pour comprendre pourquoi.

-

Dans l'article précédent, nous avons abordé un exemple VR très simple utilisant three.js et nous avons discuté des différents types de systèmes VR.

-

Le plus simple et probablement le plus courant est le style VR Google Cardboard qui consiste essentiellement en un téléphone placé dans un masque facial coûtant entre 5 et 50 dollars. Ce type de VR n'a pas de contrôleur, les gens doivent donc trouver des solutions créatives pour permettre l'entrée utilisateur.

-

La solution la plus courante est la "sélection par le regard" où si l'utilisateur pointe sa tête vers quelque chose pendant un moment, cela est sélectionné.

-

Implémentons la "sélection par le regard" ! Nous allons commencer par un exemple de l'article précédent et pour ce faire, nous ajouterons le PickHelper que nous avons créé dans l'article sur le picking. Le voici.

-
class PickHelper {
-  constructor() {
-    this.raycaster = new THREE.Raycaster();
-    this.pickedObject = null;
-    this.pickedObjectSavedColor = 0;
-  }
-  pick(normalizedPosition, scene, camera, time) {
-    // restaurer la couleur s'il y a un objet sélectionné
-    if (this.pickedObject) {
-      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
-      this.pickedObject = undefined;
-    }
-
-    // lancer un rayon à travers le frustum
-    this.raycaster.setFromCamera(normalizedPosition, camera);
-    // obtenir la liste des objets intersectés par le rayon
-    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
-    if (intersectedObjects.length) {
-      // sélectionner le premier objet. C'est le plus proche
-      this.pickedObject = intersectedObjects[0].object;
-      // sauvegarder sa couleur
-      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
-      // définir sa couleur d'émission sur rouge/jaune clignotant
-      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
-    }
-  }
-}
-
-

Pour une explication de ce code, voir l'article sur le picking.

-

Pour l'utiliser, il suffit de créer une instance et de l'appeler dans notre boucle de rendu.

-
+const pickHelper = new PickHelper();
-
-...
-function render(time) {
-  time *= 0.001;
-
-  ...
-
-+  // 0, 0 est le centre de la vue en coordonnées normalisées.
-+  pickHelper.pick({x: 0, y: 0}, scene, camera, time);
-
-

Dans l'exemple de picking original, nous avons converti les coordonnées de la souris des pixels CSS en coordonnées normalisées qui vont de -1 à +1 sur le canevas.

-

Dans ce cas, cependant, nous sélectionnerons toujours l'endroit où la caméra est dirigée, c'est-à-dire le centre de l'écran, nous passons donc 0 pour x et y, ce qui correspond au centre en coordonnées normalisées.

-

Et avec cela, les objets clignoteront lorsque nous les regarderons.

-

- -

-

Généralement, nous ne voulons pas que la sélection soit immédiate. Au lieu de cela, nous demandons à l'utilisateur de maintenir la caméra sur l'objet qu'il souhaite sélectionner pendant quelques instants afin de lui donner une chance de ne pas sélectionner quelque chose par accident.

-

Pour ce faire, nous avons besoin d'une sorte de compteur ou de jauge ou d'un moyen quelconque pour indiquer que l'utilisateur doit continuer à regarder et pendant combien de temps.

-

Une façon simple de procéder est de créer une texture à 2 couleurs et d'utiliser un décalage de texture pour faire glisser la texture sur un modèle.

-

Faisons cela séparément pour voir comment cela fonctionne avant de l'ajouter à l'exemple VR.

-

Tout d'abord, nous créons une OrthographicCamera.

-
const left = -2;    // Utiliser les valeurs pour gauche
-const right = 2;    // droite, haut et bas
-const top = 1;      // qui correspondent à la taille
-const bottom = -1;  // par défaut du canevas.
-const near = -1;
-const far = 1;
-const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
-
-

Et bien sûr, la mettre à jour si la taille du canevas change.

-
function render(time) {
-  time *= 0.001;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    const aspect = canvas.clientWidth / canvas.clientHeight;
-+    camera.left = -aspect;
-+    camera.right = aspect;
-    camera.updateProjectionMatrix();
-  }
-  ...
-
-

Nous avons maintenant une caméra qui montre 2 unités au-dessus et en dessous du centre et des unités d'aspect à gauche et à droite.

-

Ensuite, créons une texture à 2 couleurs. Nous utiliserons une DataTexture que nous avons utilisée à quelques autres endroits.

-
function makeDataTexture(data, width, height) {
-  const texture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat);
-  texture.minFilter = THREE.NearestFilter;
-  texture.magFilter = THREE.NearestFilter;
-  texture.needsUpdate = true;
-  return texture;
-}
-
-const cursorColors = new Uint8Array([
-  64, 64, 64, 64,       // gris foncé
-  255, 255, 255, 255,   // blanc
-]);
-const cursorTexture = makeDataTexture(cursorColors, 2, 1);
-
-

Nous utiliserons ensuite cette texture sur une TorusGeometry.

-
const ringRadius = 0.4;
-const tubeRadius = 0.1;
-const tubeSegments = 4;
-const ringSegments = 64;
-const cursorGeometry = new THREE.TorusGeometry(
-    ringRadius, tubeRadius, tubeSegments, ringSegments);
-
-const cursorMaterial = new THREE.MeshBasicMaterial({
-  color: 'white',
-  map: cursorTexture,
-  transparent: true,
-  blending: THREE.CustomBlending,
-  blendSrc: THREE.OneMinusDstColorFactor,
-  blendDst: THREE.OneMinusSrcColorFactor,
-});
-const cursor = new THREE.Mesh(cursorGeometry, cursorMaterial);
-scene.add(cursor);
-
-

et ensuite dans render, ajustons le décalage de la texture.

-
function render(time) {
-  time *= 0.001;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    const aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.left = -aspect;
-    camera.right = aspect;
-    camera.updateProjectionMatrix();
-  }
-
-+  const fromStart = 0;
-+  const fromEnd = 2;
-+  const toStart = -0.5;
-+  const toEnd = 0.5;
-+  cursorTexture.offset.x = THREE.MathUtils.mapLinear(
-+      time % 2,
-+      fromStart, fromEnd,
-+      toStart, toEnd);
-
-  renderer.render(scene, camera);
-}
-
-

THREE.MathUtils.mapLinear prend une valeur qui se situe entre fromStart et fromEnd et la mappe à une valeur entre toStart et toEnd. Dans le cas ci-dessus, nous prenons time % 2, ce qui signifie une valeur qui va de 0 à 2 et la mappons à une valeur qui va de -0.5 à 0.5.

-

Les textures sont mappées à la géométrie en utilisant des coordonnées de texture normalisées qui vont de 0 à 1. Cela signifie que notre image de 2x1 pixels, définie sur le mode de répétition par défaut de THREE.ClampToEdge, si nous ajustons les coordonnées de texture de -0.5, alors toute la maille sera de la première couleur et si nous ajustons les coordonnées de texture de +0.5, toute la maille sera de la deuxième couleur. Entre les deux, avec le filtrage défini sur THREE.NearestFilter, nous pourrons déplacer la transition entre les 2 couleurs à travers la géométrie.

-

Ajoutons une texture d'arrière-plan tant que nous y sommes, comme nous l'avons vu dans l'article sur les arrière-plans. Nous utiliserons simplement un ensemble de couleurs 2x2, mais définirons les paramètres de répétition de la texture pour nous donner une grille 8x8. Cela donnera à notre curseur quelque chose sur lequel être rendu afin que nous puissions le vérifier par rapport à différentes couleurs.

-
+const backgroundColors = new Uint8Array([
-+    0,   0,   0, 255,  // noir
-+   90,  38,  38, 255,  // rouge foncé
-+  100, 175, 103, 255,  // vert moyen
-+  255, 239, 151, 255,  // jaune clair
-+]);
-+const backgroundTexture = makeDataTexture(backgroundColors, 2, 2);
-+backgroundTexture.wrapS = THREE.RepeatWrapping;
-+backgroundTexture.wrapT = THREE.RepeatWrapping;
-+backgroundTexture.repeat.set(4, 4);
-
-const scene = new THREE.Scene();
-+scene.background = backgroundTexture;
-
-

Maintenant, si nous exécutons cela, vous verrez que nous obtenons une jauge en forme de cercle et que nous pouvons définir où se trouve la jauge.

-

- -

-

Quelques points à noter et à essayer.

-
    -
  • Nous avons défini les propriétés blending, blendSrc et blendDst du cursorMaterial comme suit :

    -
      blending: THREE.CustomBlending,
    -  blendSrc: THREE.OneMinusDstColorFactor,
    -  blendDst: THREE.OneMinusSrcColorFactor,
    -

    Cela donne un effet de type inverse. Commentez ces 3 lignes et vous verrez la différence. Je suppose simplement que l'effet inverse est le meilleur ici, car de cette façon, nous pouvons, espérons-le, voir le curseur quelles que soient les couleurs sur lesquelles il se trouve.

    -
  • -
  • Nous utilisons une TorusGeometry et non une RingGeometry.

    -

    Pour une raison quelconque, la RingGeometry utilise un schéma de mappage UV plat. De ce fait, si nous utilisons une RingGeometry, la texture glisse horizontalement sur l'anneau au lieu de l'entourer comme c'est le cas ci-dessus.

    -

    Essayez, changez la TorusGeometry en une RingGeometry (elle est simplement commentée dans l'exemple ci-dessus) et vous verrez ce que je veux dire.

    -

    La chose la plus correcte à faire (selon une certaine définition de correct) serait soit d'utiliser la RingGeometry mais de corriger les coordonnées de texture pour qu'elles fassent le tour de l'anneau. Ou bien, générer notre propre géométrie d'anneau. Mais, le tore fonctionne très bien. Placé directement devant la caméra avec un MeshBasicMaterial, il ressemblera exactement à un anneau et les coordonnées de texture font le tour de l'anneau, donc cela fonctionne pour nos besoins.

    -
  • -
-

Intégrons-le avec notre code VR ci-dessus.

-
class PickHelper {
--  constructor() {
-+  constructor(camera) {
-    this.raycaster = new THREE.Raycaster();
-    this.pickedObject = null;
--    this.pickedObjectSavedColor = 0;
-
-+    const cursorColors = new Uint8Array([
-+      64, 64, 64, 64,       // gris foncé
-+      255, 255, 255, 255,   // blanc
-+    ]);
-+    this.cursorTexture = makeDataTexture(cursorColors, 2, 1);
-+
-+    const ringRadius = 0.4;
-+    const tubeRadius = 0.1;
-+    const tubeSegments = 4;
-+    const ringSegments = 64;
-+    const cursorGeometry = new THREE.TorusGeometry(
-+        ringRadius, tubeRadius, tubeSegments, ringSegments);
-+
-+    const cursorMaterial = new THREE.MeshBasicMaterial({
-+      color: 'white',
-+      map: this.cursorTexture,
-+      transparent: true,
-+      blending: THREE.CustomBlending,
-+      blendSrc: THREE.OneMinusDstColorFactor,
-+      blendDst: THREE.OneMinusSrcColorFactor,
-+    });
-+    const cursor = new THREE.Mesh(cursorGeometry, cursorMaterial);
-+    // ajouter le curseur comme enfant de la caméra
-+    camera.add(cursor);
-+    // et le déplacer devant la caméra
-+    cursor.position.z = -1;
-+    const scale = 0.05;
-+    cursor.scale.set(scale, scale, scale);
-+    this.cursor = cursor;
-+
-+    this.selectTimer = 0;
-+    this.selectDuration = 2;
-+    this.lastTime = 0;
-  }
-  pick(normalizedPosition, scene, camera, time) {
-+    const elapsedTime = time - this.lastTime;
-+    this.lastTime = time;
-
--    // restore the color if there is a picked object
--    if (this.pickedObject) {
--      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
--      this.pickedObject = undefined;
--    }
-
-+    const lastPickedObject = this.pickedObject;
-+    this.pickedObject = undefined;
-
-    // lancer un rayon à travers le frustum
-    this.raycaster.setFromCamera(normalizedPosition, camera);
-    // obtenir la liste des objets intersectés par le rayon
-    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
-    if (intersectedObjects.length) {
-      // sélectionner le premier objet. C'est le plus proche
-      this.pickedObject = intersectedObjects[0].object;
--      // save its color
--      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
--      // set its emissive color to flashing red/yellow
--      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
-    }
-
-+    // afficher le curseur uniquement s'il touche quelque chose
-+    this.cursor.visible = this.pickedObject ? true : false;
-+
-+    let selected = false;
-+
-+    // si nous regardons le même objet qu'avant
-+    // incrémenter le minuteur de sélection
-+    if (this.pickedObject && lastPickedObject === this.pickedObject) {
-+      this.selectTimer += elapsedTime;
-+      if (this.selectTimer >= this.selectDuration) {
-+        this.selectTimer = 0;
-+        selected = true;
-+      }
-+    } else {
-+      this.selectTimer = 0;
-+    }
-+
-+    // définir le matériau du curseur pour afficher l'état du minuteur
-+    const fromStart = 0;
-+    const fromEnd = this.selectDuration;
-+    const toStart = -0.5;
-+    const toEnd = 0.5;
-+    this.cursorTexture.offset.x = THREE.MathUtils.mapLinear(
-+        this.selectTimer,
-+        fromStart, fromEnd,
-+        toStart, toEnd);
-+
-+    return selected ? this.pickedObject : undefined;
-  }
-}
-
-

Vous pouvez voir dans le code ci-dessus que nous avons ajouté tout le code pour créer la géométrie, la texture et le matériau du curseur, et nous l'avons ajouté comme enfant de la caméra afin qu'il soit toujours devant la caméra. Notez que nous devons ajouter la caméra à la scène, sinon le curseur ne sera pas rendu.

-
+scene.add(camera);
-
-

Nous vérifions ensuite si l'objet que nous sélectionnons cette fois est le même que la dernière fois. Si c'est le cas, nous ajoutons le temps écoulé à un minuteur et si le minuteur atteint sa limite, nous retournons l'élément sélectionné.

-

Maintenant, utilisons cela pour sélectionner les cubes. Comme simple exemple, nous allons ajouter également 3 sphères. Lorsqu'un cube est sélectionné, nous cachons le cube et révélons la sphère correspondante.

-

Donc, d'abord, nous allons créer une géométrie de sphère.

-
const boxWidth = 1;
-const boxHeight = 1;
-const boxDepth = 1;
--const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-+const boxGeometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-+
-+const sphereRadius = 0.5;
-+const sphereGeometry = new THREE.SphereGeometry(sphereRadius);
-
-

Ensuite, créons 3 paires de maillages (meshes) boîte et sphère. Nous utiliserons une Map afin de pouvoir associer chaque Mesh à son partenaire.

-
-const cubes = [
--  makeInstance(geometry, 0x44aa88,  0),
--  makeInstance(geometry, 0x8844aa, -2),
--  makeInstance(geometry, 0xaa8844,  2),
--];
-+const meshToMeshMap = new Map();
-+[
-+  { x:  0, boxColor: 0x44aa88, sphereColor: 0xFF4444, },
-+  { x:  2, boxColor: 0x8844aa, sphereColor: 0x44FF44, },
-+  { x: -2, boxColor: 0xaa8844, sphereColor: 0x4444FF, },
-+].forEach((info) => {
-+  const {x, boxColor, sphereColor} = info;
-+  const sphere = makeInstance(sphereGeometry, sphereColor, x);
-+  const box = makeInstance(boxGeometry, boxColor, x);
-+  // cacher la sphère
-+  sphere.visible = false;
-+  // mapper la sphère à la boîte
-+  meshToMeshMap.set(box, sphere);
-+  // mapper la boîte à la sphère
-+  meshToMeshMap.set(sphere, box);
-+});
-
-

Dans render, où nous faisons tourner les cubes, nous devons itérer sur meshToMeshMap au lieu de cubes.

-
-cubes.forEach((cube, ndx) => {
-+let ndx = 0;
-+for (const mesh of meshToMeshMap.keys()) {
-  const speed = 1 + ndx * .1;
-  const rot = time * speed;
--  cube.rotation.x = rot;
--  cube.rotation.y = rot;
--});
-+  mesh.rotation.x = rot;
-+  mesh.rotation.y = rot;
-+  ++ndx;
-+}
-
-

Et maintenant, nous pouvons utiliser notre nouvelle implémentation de PickHelper pour sélectionner l'un des objets. Lorsqu'il est sélectionné, nous cachons cet objet et révélons son partenaire.

-
// 0, 0 est le centre de la vue en coordonnées normalisées.
--pickHelper.pick({x: 0, y: 0}, scene, camera, time);
-+const selectedObject = pickHelper.pick({x: 0, y: 0}, scene, camera, time);
-+if (selectedObject) {
-+  selectedObject.visible = false;
-+  const partnerObject = meshToMeshMap.get(selectedObject);
-+  partnerObject.visible = true;
-+}
-
-

Et avec cela, nous devrions avoir une implémentation assez correcte de la sélection par le regard.

-

- -

-

J'espère que cet exemple vous a donné quelques idées sur la façon d'implémenter une interface utilisateur de type "sélection par le regard" au niveau de Google Cardboard. Faire glisser des textures en utilisant les décalages des coordonnées de texture est également une technique couramment utile.

-

Ensuite, permettons à l'utilisateur disposant d'un contrôleur VR de pointer et de déplacer des objets.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/fr/webxr-point-to-select.html b/manual/fr/webxr-point-to-select.html deleted file mode 100644 index 87dd35db187cb9..00000000000000 --- a/manual/fr/webxr-point-to-select.html +++ /dev/null @@ -1,347 +0,0 @@ - - - VR - Sélection par Pointage 3DOF - - - - - - - - - - - - - -
-
-

VR - Sélection par Pointage 3DOF

-
-
-
-

NOTE : Les exemples sur cette page nécessitent un appareil compatible VR avec un dispositif de pointage. Sans cela, ils ne fonctionneront pas. Voir cet article pour comprendre pourquoi.

-

Dans l'article précédent, nous avons examiné un exemple VR très simple où l'utilisateur pouvait choisir des éléments en pointant via le regard. Dans cet article, nous irons un peu plus loin et laisserons l'utilisateur choisir avec un dispositif de pointage.

-

Three.js rend les choses relativement faciles en fournissant 2 objets contrôleurs en VR et essaie de gérer les deux cas : un seul contrôleur 3DOF et deux contrôleurs 6DOF. Chacun des contrôleurs est un objet Object3D qui donne l'orientation et la position de ce contrôleur. Ils fournissent également les événements selectstart, select et selectend lorsque l'utilisateur commence à appuyer, appuie, et cesse d'appuyer (termine) sur le bouton "principal" du contrôleur.

-

En partant du dernier exemple de l'article précédent, changeons le PickHelper en un ControllerPickHelper.

-

Notre nouvelle implémentation émettra un événement select qui nous donnera l'objet qui a été sélectionné, donc pour l'utiliser, nous aurons juste besoin de faire ceci.

-
const pickHelper = new ControllerPickHelper(scene);
-pickHelper.addEventListener('select', (event) => {
-  event.selectedObject.visible = false;
-  const partnerObject = meshToMeshMap.get(event.selectedObject);
-  partnerObject.visible = true;
-});
-
-

Rappelez-vous de notre code précédent : meshToMeshMap mappe nos boîtes et sphères les unes aux autres, donc si nous en avons une, nous pouvons trouver son partenaire via meshToMeshMap. Ici, nous cachons simplement l'objet sélectionné et rendons son partenaire visible.

-

Quant à l'implémentation réelle de ControllerPickHelper, nous devons d'abord ajouter les objets contrôleurs VR à la scène et y ajouter des lignes 3D que nous pouvons utiliser pour afficher où l'utilisateur pointe. Nous sauvegardons à la fois les contrôleurs et leurs lignes.

-
class ControllerPickHelper {
-  constructor(scene) {
-    const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
-      new THREE.Vector3(0, 0, 0),
-      new THREE.Vector3(0, 0, -1),
-    ]);
-
-    this.controllers = [];
-    for (let i = 0; i < 2; ++i) {
-      const controller = renderer.xr.getController(i);
-      scene.add(controller);
-
-      const line = new THREE.Line(pointerGeometry);
-      line.scale.z = 5;
-      controller.add(line);
-      this.controllers.push({controller, line});
-    }
-  }
-}
-
-

Sans rien faire d'autre, cela seul nous donnerait 1 ou 2 lignes dans la scène montrant où se trouvent les dispositifs de pointage de l'utilisateur et dans quelle direction ils pointent.

-

Cependant, nous avons un problème : nous ne voulons pas que notre RayCaster sélectionne la ligne elle-même. Une solution facile est de séparer les objets que nous voulions pouvoir sélectionner des objets que nous ne voulons pas en les plaçant sous un autre Object3D.

-
const scene = new THREE.Scene();
-+// objet pour placer les objets sélectionnables afin de pouvoir les
-+// séparer facilement des objets non sélectionnables
-+const pickRoot = new THREE.Object3D();
-+scene.add(pickRoot);
-
-...
-
-function makeInstance(geometry, color, x) {
-  const material = new THREE.MeshPhongMaterial({color});
-
-  const cube = new THREE.Mesh(geometry, material);
--  scene.add(cube);
-+  pickRoot.add(cube);
-
-...
-
-

Ajoutons ensuite du code pour sélectionner à partir des contrôleurs. C'est la première fois que nous sélectionnons avec autre chose que la caméra. Dans notre article sur la sélection, l'utilisateur utilise la souris ou le doigt pour sélectionner, ce qui signifie que la sélection provient de la caméra vers l'écran. Dans l'article précédent, nous sélectionnions en fonction de la direction dans laquelle l'utilisateur regardait, donc cela venait aussi de la caméra. Cette fois, cependant, nous sélectionnons à partir de la position des contrôleurs, donc nous n'utilisons pas la caméra.

-
class ControllerPickHelper {
-  constructor(scene) {
-+    this.raycaster = new THREE.Raycaster();
-+    this.objectToColorMap = new Map();
-+    this.controllerToObjectMap = new Map();
-+    this.tempMatrix = new THREE.Matrix4();
-
-    const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
-      new THREE.Vector3(0, 0, 0),
-      new THREE.Vector3(0, 0, -1),
-    ]);
-
-    this.controllers = [];
-    for (let i = 0; i < 2; ++i) {
-      const controller = renderer.xr.getController(i);
-      scene.add(controller);
-
-      const line = new THREE.Line(pointerGeometry);
-      line.scale.z = 5;
-      controller.add(line);
-      this.controllers.push({controller, line});
-    }
-+  update(pickablesParent, time) {
-+    this.reset();
-+    for (const {controller, line} of this.controllers) {
-+      // lancer un rayon depuis le contrôleur
-+      this.tempMatrix.identity().extractRotation(controller.matrixWorld);
-+      this.raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
-+      this.raycaster.ray.direction.set(0, 0, -1).applyMatrix4(this.tempMatrix);
-+      // obtenir la liste des objets intersectés par le rayon
-+      const intersections = this.raycaster.intersectObjects(pickablesParent.children);
-+      if (intersections.length) {
-+        const intersection = intersections[0];
-+        // faire en sorte que la ligne touche l'objet
-+        line.scale.z = intersection.distance;
-+        // sélectionner le premier objet. C'est le plus proche
-+        const pickedObject = intersection.object;
-+        // sauvegarder quel objet ce contrôleur a sélectionné
-+        this.controllerToObjectMap.set(controller, pickedObject);
-+        // mettre en évidence l'objet si ce n'est pas déjà fait
-+        if (this.objectToColorMap.get(pickedObject) === undefined) {
-+          // sauvegarder sa couleur
-+          this.objectToColorMap.set(pickedObject, pickedObject.material.emissive.getHex());
-+          // définir sa couleur émissive en rouge/jaune clignotant
-+          pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFF2000 : 0xFF0000);
-+        }
-+      } else {
-+        line.scale.z = 5;
-+      }
-+    }
-+  }
-}
-
-

Comme précédemment, nous utilisons un Raycaster, mais cette fois, nous prenons le rayon depuis le contrôleur. Dans notre précédent PickHelper, il n'y avait qu'une seule chose pour la sélection, mais ici nous avons jusqu'à 2 contrôleurs, un pour chaque main. Nous sauvegardons l'objet que chaque contrôleur regarde dans controllerToObjectMap. Nous sauvegardons également la couleur émissive d'origine dans objectToColorMap et nous faisons en sorte que la ligne soit assez longue pour toucher ce vers quoi elle pointe.

-

Nous devons ajouter du code pour réinitialiser ces paramètres à chaque image.

-
class ControllerPickHelper {
-
-  ...
-
-+  _reset() {
-+    // restaurer les couleurs
-+    this.objectToColorMap.forEach((color, object) => {
-+      object.material.emissive.setHex(color);
-+    });
-+    this.objectToColorMap.clear();
-+    this.controllerToObjectMap.clear();
-+  }
-  update(pickablesParent, time) {
-+    this._reset();
-
-    ...
-
-}
-
-

Ensuite, nous voulons émettre un événement select lorsque l'utilisateur clique sur le contrôleur. Pour ce faire, nous pouvons étendre l'EventDispatcher de three.js, puis nous vérifierons quand nous recevons un événement select du contrôleur. Si ce contrôleur pointe vers quelque chose, nous émettrons ce vers quoi le contrôleur pointe comme notre propre événement select.

-
-class ControllerPickHelper {
-+class ControllerPickHelper extends THREE.EventDispatcher {
-  constructor(scene) {
-+    super();
-    this.raycaster = new THREE.Raycaster();
--    this.objectToColorMap = new Map();  // object to save color and picked object
-+    this.objectToColorMap = new Map();  // objet pour sauvegarder la couleur et l'objet sélectionné
-    this.controllerToObjectMap = new Map();
-    this.tempMatrix = new THREE.Matrix4();
-
-    const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
-      new THREE.Vector3(0, 0, 0),
-      new THREE.Vector3(0, 0, -1),
-    ]);
-
-    this.controllers = [];
-    for (let i = 0; i < 2; ++i) {
-      const controller = renderer.xr.getController(i);
-+      controller.addEventListener('select', (event) => {
-+        const controller = event.target;
-+        const selectedObject = this.controllerToObjectMap.get(controller);
-+        if (selectedObject) {
-+          this.dispatchEvent({type: 'select', controller, selectedObject});
-+        }
-+      });
-      scene.add(controller);
-
-      const line = new THREE.Line(pointerGeometry);
-      line.scale.z = 5;
-      controller.add(line);
-      this.controllers.push({controller, line});
-    }
-  }
-}
-
-

Il ne reste plus qu'à appeler update dans notre boucle de rendu.

-
function render(time) {
-
-  ...
-
-+  pickHelper.update(pickablesParent, time);
-
-  renderer.render(scene, camera);
-}
-
-

et en supposant que vous ayez un appareil VR avec un contrôleur, vous devriez pouvoir utiliser les contrôleurs pour sélectionner des éléments.

-

- -

-

Et si nous voulions pouvoir déplacer les objets ?

-

C'est relativement facile. Déplaçons notre code d'écouteur 'select' du contrôleur dans une fonction afin de pouvoir l'utiliser pour plus d'une chose.

-
class ControllerPickHelper extends THREE.EventDispatcher {
-  constructor(scene) {
-    super();
-
-    ...
-
-    this.controllers = [];
-
-+    const selectListener = (event) => {
-+      const controller = event.target;
-+      const selectedObject = this.controllerToObjectMap.get(event.target);
-+      if (selectedObject) {
-+        this.dispatchEvent({type: 'select', controller, selectedObject});
-+      }
-+    };
-
-    for (let i = 0; i < 2; ++i) {
-      const controller = renderer.xr.getController(i);
--      controller.addEventListener('select', (event) => {
--        const controller = event.target;
--        const selectedObject = this.controllerToObjectMap.get(event.target);
--        if (selectedObject) {
--          this.dispatchEvent({type: 'select', controller, selectedObject});
--        }
--      });
-+      controller.addEventListener('select', selectListener);
-
-       ...
-
-

Utilisons-le ensuite pour selectstart et select.

-
class ControllerPickHelper extends THREE.EventDispatcher {
-  constructor(scene) {
-    super();
-
-    ...
-
-    this.controllers = [];
-
-    const selectListener = (event) => {
-      const controller = event.target;
-      const selectedObject = this.controllerToObjectMap.get(event.target);
-      if (selectedObject) {
--        this.dispatchEvent({type: 'select', controller, selectedObject});
-+        this.dispatchEvent({type: event.type, controller, selectedObject});
-      }
-    };
-
-    for (let i = 0; i < 2; ++i) {
-      const controller = renderer.xr.getController(i);
-      controller.addEventListener('select', selectListener);
-      controller.addEventListener('selectstart', selectListener);
-
-       ...
-
-

et transmettons également l'événement selectend que three.js envoie lorsque l'utilisateur relâche le bouton du contrôleur.

-
class ControllerPickHelper extends THREE.EventDispatcher {
-  constructor(scene) {
-    super();
-
-    ...
-
-    this.controllers = [];
-
-    const selectListener = (event) => {
-      const controller = event.target;
-      const selectedObject = this.controllerToObjectMap.get(event.target);
-      if (selectedObject) {
-        this.dispatchEvent({type: event.type, controller, selectedObject});
-      }
-    };
-
-+    const endListener = (event) => {
-+      const controller = event.target;
-+      this.dispatchEvent({type: event.type, controller});
-+    };
-
-    for (let i = 0; i < 2; ++i) {
-      const controller = renderer.xr.getController(i);
-      controller.addEventListener('select', selectListener);
-      controller.addEventListener('selectstart', selectListener);
-+      controller.addEventListener('selectend', endListener);
-
-       ...
-
-

Maintenant, modifions le code de manière à ce que, lorsque nous recevons un événement selectstart, nous retirions l'objet sélectionné de la scène et en fassions un enfant du contrôleur. Cela signifie qu'il se déplacera avec le contrôleur. Lorsque nous recevrons un événement selectend, nous le remettrons dans la scène.

-
const pickHelper = new ControllerPickHelper(scene);
--pickHelper.addEventListener('select', (event) => {
--  event.selectedObject.visible = false;
--  const partnerObject = meshToMeshMap.get(event.selectedObject);
--  partnerObject.visible = true;
--});
-
-+const controllerToSelection = new Map();
-+pickHelper.addEventListener('selectstart', (event) => {
-+  const {controller, selectedObject} = event;
-+  const existingSelection = controllerToSelection.get(controller);
-+  if (!existingSelection) {
-+    controllerToSelection.set(controller, {
-+      object: selectedObject,
-+      parent: selectedObject.parent,
-+    });
-+    controller.attach(selectedObject);
-+  }
-+});
-+
-+pickHelper.addEventListener('selectend', (event) => {
-+  const {controller} = event;
-+  const selection = controllerToSelection.get(controller);
-+  if (selection) {
-+    controllerToSelection.delete(controller);
-+    selection.parent.attach(selection.object);
-+  }
-+});
-
-

Lorsqu'un objet est sélectionné, nous sauvegardons cet objet et son parent d'origine. Lorsque l'utilisateur a terminé, nous pouvons remettre l'objet en place.

-

Nous utilisons Object3D.attach pour changer le parent des objets sélectionnés. Ces fonctions nous permettent de modifier le parent d'un objet sans modifier son orientation et sa position dans la scène.

-

Et avec cela, nous devrions pouvoir déplacer les objets avec un contrôleur 6DOF ou au moins changer leur orientation avec un contrôleur 3DOF.

-

- -

-

Pour être honnête, je ne suis pas sûr à 100 % que ce ControllerPickHelper soit la meilleure façon d'organiser le code, mais il est utile pour démontrer les différentes parties nécessaires pour faire fonctionner quelque chose de simple en VR avec three.js.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/index.html b/manual/index.html index e3091f1d7acfdb..e535f5decfbb33 100644 --- a/manual/index.html +++ b/manual/index.html @@ -33,14 +33,6 @@

three.js

-

@@ -72,37 +64,7 @@

three.js

const list = await ( await fetch( 'list.json' ) ).json(); - const hash = window.location.hash.substring( 1 ); - - // Localization - - let language = 'en'; - - const hashLanguage = /^(.*?)\//.exec( hash ); - - if ( hashLanguage ) { - - language = hashLanguage[ 1 ]; - - } - - const languageSelect = document.querySelector( '#language' ); - languageSelect.value = language; - languageSelect.addEventListener( 'change', function () { - - setLanguage( this.value ); - - } ); - - function setLanguage( value ) { - - language = value; - - createNavigation( list, language ); - updateFilter(); - autoChangeUrlLanguage( language ); - - } + redirectLegacyLanguage(); // Functionality for hamburger button (on small devices) @@ -157,14 +119,28 @@

three.js

window.onpopstate = function () { + redirectLegacyLanguage(); updateNavigation(); createNewIframe(); }; + // Redirect legacy localized deep links entered directly in the address bar + + window.addEventListener( 'hashchange', function () { + + if ( redirectLegacyLanguage() ) { + + updateNavigation(); + createNewIframe(); + + } + + } ); + // Create the navigation panel and configure the iframe - createNavigation( list, language ); + createNavigation( list ); createNewIframe(); // Handle search query @@ -186,7 +162,7 @@

three.js

function createLink( pageName, pageURL ) { const link = document.createElement( 'a' ); - const url = new URL( pageURL, window.location.href ); + const url = new URL( 'pages/' + pageURL, window.location.href ); url.pathname += '.html'; link.href = url.href; link.textContent = pageName; @@ -206,7 +182,7 @@

three.js

} - function createNavigation( list, language ) { + function createNavigation( list ) { if ( navigation !== undefined ) { @@ -219,13 +195,7 @@

three.js

navigation = document.createElement( 'div' ); content.appendChild( navigation ); - if ( language === 'ar' ) { - - navigation.style.direction = 'rtl'; - - } - - const categories = list[ language ]; + const categories = list; const selectedPage = window.location.hash.substring( 1 ).replace( /\.html$/, '' ); for ( const category in categories ) { @@ -302,7 +272,7 @@

three.js

content.querySelectorAll( 'a' ).forEach( function ( item ) { - if ( item.href.includes( selectedPage ) ) { + if ( selectedPage !== '' && item.href.includes( selectedPage ) ) { item.classList.add( 'selected' ); @@ -316,17 +286,6 @@

three.js

} - // Auto change language url. If a reader open a document in English, when they click "zh", the document they read will auto change into Chinese version - - function autoChangeUrlLanguage( language ) { - - const hash = location.hash; - if ( hash === '' ) return; - const docLink = hash.slice( hash.indexOf( '/' ) ); - location.href = '#' + language + docLink; - - } - // Filtering function extractQuery() { @@ -445,7 +404,7 @@

three.js

const [ , base, path ] = m; if ( base.includes( 'manual' ) ) { - const newHash = `#${ path.replace( '.html', '' ) }`; + const newHash = `#${ path.replace( 'pages/', '' ).replace( '.html', '' ) }`; // Only create new iframe if we're actually changing pages. // We could just be going to an anchor on the same page. const newPrefix = newHash.split( '#' )[ 1 ]; @@ -481,6 +440,25 @@

three.js

} + function redirectLegacyLanguage() { + + // Legacy language-qualified deep links (e.g. #en/installation, #fr/installation) + // now resolve to the language-neutral URL (e.g. #installation). + + const legacyLanguage = /^#(en|fr|ja|ko|ru|zh)(\/|$)/; + + if ( legacyLanguage.test( window.location.hash ) ) { + + window.history.replaceState( null, '', window.location.hash.replace( legacyLanguage, '#' ) ); + + return true; + + } + + return false; + + } + function createNewIframe() { // Change the content displayed in the iframe @@ -503,18 +481,18 @@

three.js

// We can have 2 hashes. One for the main page, one for the page it's referencing // In other words - // #en/somePage#someSectionOfPage + // #somePage#someSectionOfPage const subHash = splitHash[ 0 ].indexOf( '#' ); let src; if ( subHash >= 0 ) { const beforeSubHash = splitHash[ 0 ].slice( 0, subHash ); const afterSubHash = splitHash[ 0 ].slice( subHash ); - src = `${beforeSubHash}.html${afterSubHash}${splitHash[ 1 ]}`; + src = `pages/${beforeSubHash}.html${afterSubHash}${splitHash[ 1 ]}`; } else { - src = splitHash[ 0 ] + '.html' + splitHash[ 1 ]; + src = 'pages/' + splitHash[ 0 ] + '.html' + splitHash[ 1 ]; } diff --git a/manual/ja/align-html-elements-to-3d.html b/manual/ja/align-html-elements-to-3d.html deleted file mode 100644 index f918af1c8f22ae..00000000000000 --- a/manual/ja/align-html-elements-to-3d.html +++ /dev/null @@ -1,701 +0,0 @@ - - - でHTML要素を3Dに揃える - - - - - - - - - - - - - -
-
-

でHTML要素を3Dに揃える

-
-
-
-

この記事はThree.jsの連載記事の1つです。 -最初の記事はThree.jsの基礎知識です。 -まだ読んでいない場合、そこから始めると良いかもしれません。

-

3Dシーンにテキスト表示させたい場合があると思います。 -メリットとデメリットを持つ多くの方法があります。

-
    -
  • 3Dテキストを使用する

    -

    プリミティブの記事を見ると TextGeometry があり、3Dテキストが作れます。 -ロゴを飛ばすには便利ですが、統計や情報、ラベル付けなどにはあまり便利でないかもしれません。

    -
  • -
  • テキストが描かれたテクスチャを使用する

    -

    この記事ではキャンバスをテクスチャとして使いました。 -キャンバスにテキストを描画してビルボードとして表示できます。 -この方法のメリットは、3Dシーンにテキストが組み込まれている事かもしれません。 -3Dシーンの中でPC端末のようなものを描画するには最適かもしれません。

    -
  • -
  • HTML要素を3D空間に合わせて配置する

    -

    この方法のメリットは全てのHTMLを使えます。 -HTMLは複数の要素を持てます。 -また、CSSでスタイルを整えられます。 -実際のテキストなのでユーザーが選択する事もできます。

    -
  • -
-

この記事では、HTML要素を3D空間に合わせて配置を取り上げます。

-

まずは簡単に始めてみましょう。 -いくつかのプリミティブで3Dシーンを作り、それぞれのプリミティブにラベルを付けます。 -レスポンシブの記事の例を使います。

-

ライティングの記事のように OrbitControls を追加します。

-
import * as THREE from 'three';
-+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-
-
const controls = new OrbitControls(camera, canvas);
-controls.target.set(0, 0, 0);
-controls.update();
-
-

ラベル要素を含むHTML要素を追加します。

-
<body>
--  <canvas id="c"></canvas>
-+  <div id="container">
-+    <canvas id="c"></canvas>
-+    <div id="labels"></div>
-+  </div>
-</body>
-
-

キャンバスと <div id="labels"> の両方を親コンテナ配下に入れて、CSSで重なるように設定できます。

-
#c {
--    width: 100%;
--    height: 100%;
-+    width: 100%;  /* let our container decide our size */
-+    height: 100%;
-    display: block;
-}
-+#container {
-+  position: relative;  /* makes this the origin of its children */
-+  width: 100%;
-+  height: 100%;
-+  overflow: hidden;
-+}
-+#labels {
-+  position: absolute;  /* let us position ourself inside the container */
-+  left: 0;             /* make our position the top left of the container */
-+  top: 0;
-+  color: white;
-+}
-
-

ラベル自体にもCSSを追加しましょう。

-
#labels>div {
-  position: absolute;  /* let us position them inside the container */
-  left: 0;             /* make their default position the top left of the container */
-  top: 0;
-  cursor: pointer;     /* change the cursor to a hand when over us */
-  font-size: large;
-  user-select: none;   /* don't let the text get selected */
-  text-shadow:         /* create a black outline */
-    -1px -1px 0 #000,
-     0   -1px 0 #000,
-     1px -1px 0 #000,
-     1px  0   0 #000,
-     1px  1px 0 #000,
-     0    1px 0 #000,
-    -1px  1px 0 #000,
-    -1px  0   0 #000;
-}
-#labels>div:hover {
-  color: red;
-}
-
-

HTMLに多くのラベル要素を追加する必要はありません。 -立方体を生成する関数 makeInstance があります。 -この関数にラベル要素も追加してみましょう。

-
+const labelContainerElem = document.querySelector('#labels');
-
--function makeInstance(geometry, color, x) {
-+function makeInstance(geometry, color, x, name) {
-  const material = new THREE.MeshPhongMaterial({color});
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  cube.position.x = x;
-
-+  const elem = document.createElement('div');
-+  elem.textContent = name;
-+  labelContainerElem.appendChild(elem);
-
--  return cube;
-+  return {cube, elem};
-}
-
-

立方体ごとに1つずつコンテナに <div> を追加しています。 -ラベルのために cubeelem の両方を持つオブジェクトを返します。

-

この関数を呼び出すには、ラベル名も指定する必要があります。

-
const cubes = [
--  makeInstance(geometry, 0x44aa88,  0),
--  makeInstance(geometry, 0x8844aa, -2),
--  makeInstance(geometry, 0xaa8844,  2),
-+  makeInstance(geometry, 0x44aa88,  0, 'Aqua'),
-+  makeInstance(geometry, 0x8844aa, -2, 'Purple'),
-+  makeInstance(geometry, 0xaa8844,  2, 'Gold'),
-];
-
-

あとはレンダリング時にラベル要素を配置します。

-
const tempV = new THREE.Vector3();
-
-...
-
--cubes.forEach((cube, ndx) => {
-+cubes.forEach((cubeInfo, ndx) => {
-+  const {cube, elem} = cubeInfo;
-  const speed = 1 + ndx * .1;
-  const rot = time * speed;
-  cube.rotation.x = rot;
-  cube.rotation.y = rot;
-
-+  // get the position of the center of the cube
-+  cube.updateWorldMatrix(true, false);
-+  cube.getWorldPosition(tempV);
-+
-+  // get the normalized screen coordinate of that position
-+  // x and y will be in the -1 to +1 range with x = -1 being
-+  // on the left and y = -1 being on the bottom
-+  tempV.project(camera);
-+
-+  // convert the normalized position to CSS coordinates
-+  const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
-+  const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
-+
-+  // move the elem to that position
-+  elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
-});
-
-

そして、ラベルを対応するオブジェクトに合わせて位置を指定します。

-

- -

-

動かしてみると対処したい問題がいくつか出ると思います。

-

1つの問題は、オブジェクトを回転させると全てのラベルが重なってしまいます。

-
- -

もう1つの問題は、オブジェクトが錐台の外に出るようにズームアウトしても、ラベルが消えずに表示されたままです。

-

オブジェクトが重なる問題の解決策はピッキング記事のピッキングコードを使います。 -画面上のオブジェクトの位置を入力し、RayCaster にどのオブジェクトが交差していたか教えてもらいます。 -オブジェクトが最初のものでなければ前面に表示されません。

-
const tempV = new THREE.Vector3();
-+const raycaster = new THREE.Raycaster();
-
-...
-
-cubes.forEach((cubeInfo, ndx) => {
-  const {cube, elem} = cubeInfo;
-  const speed = 1 + ndx * .1;
-  const rot = time * speed;
-  cube.rotation.x = rot;
-  cube.rotation.y = rot;
-
-  // get the position of the center of the cube
-  cube.updateWorldMatrix(true, false);
-  cube.getWorldPosition(tempV);
-
-  // get the normalized screen coordinate of that position
-  // x and y will be in the -1 to +1 range with x = -1 being
-  // on the left and y = -1 being on the bottom
-  tempV.project(camera);
-
-+  // ask the raycaster for all the objects that intersect
-+  // from the eye toward this object's position
-+  raycaster.setFromCamera(tempV, camera);
-+  const intersectedObjects = raycaster.intersectObjects(scene.children);
-+  // We're visible if the first intersection is this object.
-+  const show = intersectedObjects.length && cube === intersectedObjects[0].object;
-+
-+  if (!show) {
-+    // hide the label
-+    elem.style.display = 'none';
-+  } else {
-+    // un-hide the label
-+    elem.style.display = '';
-
-    // convert the normalized position to CSS coordinates
-    const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
-    const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
-
-    // move the elem to that position
-    elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
-+  }
-});
-
-

これで重なり問題に対応しました。

-

以下は tempV.z をチェックし、オブジェクトの原点が錐台の外にあるかをチェックします。

-
-  if (!show) {
-+  if (!show || Math.abs(tempV.z) > 1) {
-    // hide the label
-    elem.style.display = 'none';
-
-

正規化された座標には計算した z の値が含まれており、カメラの錐台の near は-1、far は+1の値になります。

-

- -

-

錐台のチェックではオブジェクトの原点をチェックしているだけなので、上記の解決策では失敗します。特に大きなオブジェクトの場合などです。 -オブジェクトの原点は錐台の外にあるかもしれませんが、オブジェクトの半分は錐台の中にあるかもしれません。

-

正しい解決策は、オブジェクト自体が錐台に入っているか確認する事でしょう。 -ただし、残念ながらチェックの動作が遅いです。 -3つの立方体の場合は問題ないですが、オブジェクトがたくさんある場合は問題になるかもしれません。

-

Three.jsには、球体オブジェクトが錐台内にあるかチェックする関数がいくつか用意されています。

-
// at init time
-const frustum = new THREE.Frustum();
-const viewProjection = new THREE.Matrix4();
-
-...
-
-// before checking
-camera.updateMatrix();
-camera.updateMatrixWorld();
-camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
-
-...
-
-// then for each mesh
-someMesh.updateMatrix();
-someMesh.updateMatrixWorld();
-
-viewProjection.multiplyMatrices(
-    camera.projectionMatrix, camera.matrixWorldInverse);
-frustum.setFromProjectionMatrix(viewProjection);
-const inFrustum = frustum.contains(someMesh));
-
-

現在の重なりの解決策にも同様の問題があります。 -ピッキングが遅いです。 -ピッキングの記事で取り上げたように、GPUベースのピッキングを使う事もできますがコストがかからない訳ではありません。 -どの解決策を選択するかはニーズによります。

-

もう1つの問題はラベルの表示順序です。 -コードを変更してラベルを長くなると

-
const cubes = [
--  makeInstance(geometry, 0x44aa88,  0, 'Aqua'),
--  makeInstance(geometry, 0x8844aa, -2, 'Purple'),
--  makeInstance(geometry, 0xaa8844,  2, 'Gold'),
-+  makeInstance(geometry, 0x44aa88,  0, 'Aqua Colored Box'),
-+  makeInstance(geometry, 0x8844aa, -2, 'Purple Colored Box'),
-+  makeInstance(geometry, 0xaa8844,  2, 'Gold Colored Box'),
-];
-
-

折り返さないようにCSSで設定します。

-
#labels>div {
-+  white-space: nowrap;
-
-

そうすると次のような問題が発生します。

-
- -

上記では紫のボックスは奥にありますが、紫のボックスのラベルはアクアのボックスの前にあります。

-

これは各要素の zIndex を設定して修正できます。 -投影された位置の z は-1(手前)〜+1(奥)の値を持ちます。 -zIndex は整数である必要があります。 -逆方向の意味で zIndex の値が大きい方が手前にある事を意味します。 -以下のコードで動作するはずです。

-
// convert the normalized position to CSS coordinates
-const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
-const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
-
-// move the elem to that position
-elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
-
-+// set the zIndex for sorting
-+elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
-
-

zIndexを正しく動作させるには、値を分散させるために大きな数値を選ぶ必要があります。 -そうしないと多くの値が同じ値になってしまいます。 -ラベルがページの他の部分と重ならないように、ラベルのコンテナの z-index を設定し、新しいスタッキングコンテキストを作成します。

-
#labels {
-  position: absolute;  /* let us position ourself inside the container */
-+  z-index: 0;          /* make a new stacking context so children don't sort with rest of page */
-  left: 0;             /* make our position the top left of the container */
-  top: 0;
-  color: white;
-  z-index: 0;
-}
-
-

これでラベルは常に正しい順序で表示されます。

-

- -

-

もう1つの問題を確認ために、もう1つの例をやってみましょう。 -Googleマップのような地球儀を描いて、国名のラベルを貼ってみましょう。

-

国境を含むこのデータを見つけました。 -このデータはCC-BY-SAライセンスです。

-

データを読み込んで、国の概要と国名、その位置を含むJSONデータを生成するためのコードを書いてみました。

-
- -

JSONデータは以下のような配列です。

-
[
-  {
-    "name": "Algeria",
-    "min": [
-      -8.667223,
-      18.976387
-    ],
-    "max": [
-      11.986475,
-      37.091385
-    ],
-    "area": 238174,
-    "lat": 28.163,
-    "lon": 2.632,
-    "population": {
-      "2005": 32854159
-    }
-  },
-  ...
-
-

min、max、lat、lon、これは全て緯度と経度です。

-

ロードしてみましょう。 -このコードは多くのオブジェクトを最適化のコードの流用ですが、オブジェクトをたくさん描画しているわけではないので要求されたレンダリングと同じ解決策を使います。

-

まずは球体を作り、アウトラインテクスチャを使います。

-
{
-  const loader = new THREE.TextureLoader();
-  const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
-  const geometry = new THREE.SphereGeometry(1, 64, 32);
-  const material = new THREE.MeshBasicMaterial({map: texture});
-  scene.add(new THREE.Mesh(geometry, material));
-}
-
-

ローダー関数を作ってJSONファイルをロードしてみましょう。

-
async function loadJSON(url) {
-  const req = await fetch(url);
-  return req.json();
-}
-
-

そして、その関数を呼び出します。

-
let countryInfos;
-async function loadCountryData() {
-  countryInfos = await loadJSON('resources/data/world/country-info.json');
-     ...
-  }
-  requestRenderIfNotRequested();
-}
-loadCountryData();
-
-

では、そのデータを使ってラベルを生成して配置してみましょう。

-

多くのオブジェクトを最適化の記事ではヘルパーオブジェクトの小さなシーングラフを設定し、地球儀上の緯度と経度の位置を簡単に計算できるようにしました。 -仕組みの説明はその記事を参照して下さい。

-
const lonFudge = Math.PI * 1.5;
-const latFudge = Math.PI;
-// these helpers will make it easy to position the boxes
-// We can rotate the lon helper on its Y axis to the longitude
-const lonHelper = new THREE.Object3D();
-// We rotate the latHelper on its X axis to the latitude
-const latHelper = new THREE.Object3D();
-lonHelper.add(latHelper);
-// The position helper moves the object to the edge of the sphere
-const positionHelper = new THREE.Object3D();
-positionHelper.position.z = 1;
-latHelper.add(positionHelper);
-
-

これを使い、各ラベルの位置を計算します。

-
const labelParentElem = document.querySelector('#labels');
-for (const countryInfo of countryInfos) {
-  const {lat, lon, name} = countryInfo;
-
-  // adjust the helpers to point to the latitude and longitude
-  lonHelper.rotation.y = THREE.MathUtils.degToRad(lon) + lonFudge;
-  latHelper.rotation.x = THREE.MathUtils.degToRad(lat) + latFudge;
-
-  // get the position of the lat/lon
-  positionHelper.updateWorldMatrix(true, false);
-  const position = new THREE.Vector3();
-  positionHelper.getWorldPosition(position);
-  countryInfo.position = position;
-
-  // add an element for each country
-  const elem = document.createElement('div');
-  elem.textContent = name;
-  labelParentElem.appendChild(elem);
-  countryInfo.elem = elem;
-
-

上記のコードは、立方体のラベルを作成するために書いたコードに非常に似てます。 -これで配列 countryInfos ができました。 -国ごとのlabel要素に elem プロパティを追加し、位置を表す position を追加しました。

-

立方体の場合と同じく、ラベルの位置とレンダリング時間を更新します。

-
const tempV = new THREE.Vector3();
-
-function updateLabels() {
-  // exit if we have not yet loaded the JSON file
-  if (!countryInfos) {
-    return;
-  }
-
-  for (const countryInfo of countryInfos) {
-    const {position, elem} = countryInfo;
-
-    // get the normalized screen coordinate of that position
-    // x and y will be in the -1 to +1 range with x = -1 being
-    // on the left and y = -1 being on the bottom
-    tempV.copy(position);
-    tempV.project(camera);
-
-    // convert the normalized position to CSS coordinates
-    const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
-    const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
-
-    // move the elem to that position
-    elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
-
-    // set the zIndex for sorting
-    elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
-  }
-}
-
-

上記のコードは、前の立方体のコードと実質的に似ています。 -唯一の大きな違いは、初期化時にラベルの位置を事前に計算しました。 -地球儀が動かないため可能です。カメラだけが動きます。

-

最後にループ処理するrender関数の中で updateLabels を呼び出します。

-
function render() {
-  renderRequested = false;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-  controls.update();
-
-+  updateLabels();
-
-  renderer.render(scene, camera);
-}
-
-

そして、これが結果です。

-

- -

-

ラベルが多すぎる!

-

問題は2つあります。

-
    -
  1. 地球儀の背面の国のラベルも表示されてる

    -
  2. -
  3. ラベルが多すぎる

    -
  4. -
-

問題1では球体以外に交わるものがないので、上記で説明したように RayCaster を使う事はできません。 -代わりにできるのは特定の国が離れているか確認する事です。 -これはラベルの位置が球体の周囲にあるために動作します。 -実際には単位球体を使っており、半径1.0の球体です。 -つまり、位置は単位方向になっており計算が比較的簡単になります。

-
const tempV = new THREE.Vector3();
-+const cameraToPoint = new THREE.Vector3();
-+const cameraPosition = new THREE.Vector3();
-+const normalMatrix = new THREE.Matrix3();
-
-function updateLabels() {
-  // exit if we have not yet loaded the JSON file
-  if (!countryInfos) {
-    return;
-  }
-
-+  const minVisibleDot = 0.2;
-+  // get a matrix that represents a relative orientation of the camera
-+  normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
-+  // get the camera's position
-+  camera.getWorldPosition(cameraPosition);
-  for (const countryInfo of countryInfos) {
-    const {position, elem} = countryInfo;
-
-+    // Orient the position based on the camera's orientation.
-+    // Since the sphere is at the origin and the sphere is a unit sphere
-+    // this gives us a camera relative direction vector for the position.
-+    tempV.copy(position);
-+    tempV.applyMatrix3(normalMatrix);
-+
-+    // compute the direction to this position from the camera
-+    cameraToPoint.copy(position);
-+    cameraToPoint.applyMatrix4(camera.matrixWorldInverse).normalize();
-+
-+    // get the dot product of camera relative direction to this position
-+    // on the globe with the direction from the camera to that point.
-+    // 1 = facing directly towards the camera
-+    // 0 = exactly on tangent of the sphere from the camera
-+    // < 0 = facing away
-+    const dot = tempV.dot(cameraToPoint);
-+
-+    // if the orientation is not facing us hide it.
-+    if (dot < minVisibleDot) {
-+      elem.style.display = 'none';
-+      continue;
-+    }
-+
-+    // restore the element to its default display style
-+    elem.style.display = '';
-
-    // get the normalized screen coordinate of that position
-    // x and y will be in the -1 to +1 range with x = -1 being
-    // on the left and y = -1 being on the bottom
-    tempV.copy(position);
-    tempV.project(camera);
-
-    // convert the normalized position to CSS coordinates
-    const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
-    const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
-
-    // move the elem to that position
-    countryInfo.elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
-
-    // set the zIndex for sorting
-    elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
-  }
-}
-
-

上記では位置を方向として使用し、カメラに対して相対的にその方向を取得しています。 -カメラから地球儀上のその位置までのカメラの相対的な方向を取得し、内積 を取得します。 -内積はベクトル間の角度のコサインを返します。 -これで -1 〜 +1までの値を取得できます。 --1はラベルがカメラに向いており、0はラベルがカメラから見て球体の端にあって、0より大きいものは背面にあります。 -その値を使用してラベル要素の表示・非表示をします。

-
-
-
-
-
- -

上記の図では、カメラからその位置までのラベルの向きの内積を表しています。 -方向を回転させるとカメラに直接向いている時は、内積は-1.0になります。 -カメラに相対する球体の接線上にある時は0.0になります。 -別の言い方をすれば、2つのベクトルがお互いに90度垂直な時は0になります。

-

問題2はラベルが多すぎて、どのラベルを表示するか決める方法が必要です。 -1つの方法は大きな国のラベルのみを表示します。 -読込中のデータには、国がカバーする領域の最小値と最大値が含まれています。 -そこから面積を計算し、その面積を使い表示するか決められます。

-

初期化時に面積を計算してみましょう。

-
const labelParentElem = document.querySelector('#labels');
-for (const countryInfo of countryInfos) {
-  const {lat, lon, min, max, name} = countryInfo;
-
-  // adjust the helpers to point to the latitude and longitude
-  lonHelper.rotation.y = THREE.MathUtils.degToRad(lon) + lonFudge;
-  latHelper.rotation.x = THREE.MathUtils.degToRad(lat) + latFudge;
-
-  // get the position of the lat/lon
-  positionHelper.updateWorldMatrix(true, false);
-  const position = new THREE.Vector3();
-  positionHelper.getWorldPosition(position);
-  countryInfo.position = position;
-
-+  // compute the area for each country
-+  const width = max[0] - min[0];
-+  const height = max[1] - min[1];
-+  const area = width * height;
-+  countryInfo.area = area;
-
-  // add an element for each country
-  const elem = document.createElement('div');
-  elem.textContent = name;
-  labelParentElem.appendChild(elem);
-  countryInfo.elem = elem;
-}
-
-

レンダリング時にその領域を使い、ラベルを表示するか決めましょう。

-
+const large = 20 * 20;
-const maxVisibleDot = 0.2;
-// get a matrix that represents a relative orientation of the camera
-normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
-// get the camera's position
-camera.getWorldPosition(cameraPosition);
-for (const countryInfo of countryInfos) {
--  const {position, elem} = countryInfo;
-+  const {position, elem, area} = countryInfo;
-+  // large enough?
-+  if (area < large) {
-+    elem.style.display = 'none';
-+    continue;
-+  }
-
-  ...
-
-

私にはこの設定のための良い値が何か分からないです。 -値を操作できるようにGUIを追加します。

-
import * as THREE from 'three';
-import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-+import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
-
-
+const settings = {
-+  minArea: 20,
-+  maxVisibleDot: -0.2,
-+};
-+const gui = new GUI({width: 300});
-+gui.add(settings, 'minArea', 0, 50).onChange(requestRenderIfNotRequested);
-+gui.add(settings, 'maxVisibleDot', -1, 1, 0.01).onChange(requestRenderIfNotRequested);
-
-function updateLabels() {
-  if (!countryInfos) {
-    return;
-  }
-
--  const large = 20 * 20;
--  const maxVisibleDot = -0.2;
-+  const large = settings.minArea * settings.minArea;
-  // get a matrix that represents a relative orientation of the camera
-  normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
-  // get the camera's position
-  camera.getWorldPosition(cameraPosition);
-  for (const countryInfo of countryInfos) {
-
-    ...
-
-    // if the orientation is not facing us hide it.
--    if (dot > maxVisibleDot) {
-+    if (dot > settings.maxVisibleDot) {
-      elem.style.display = 'none';
-      continue;
-    }
-
-

その結果が以下です。

-

- -

-

回転させて地球儀の背面にいったラベルが消えるようになりました。 -minVisibleDot を調整してカットオフの変化を見る事ができます。 -また、minArea の値を調整して大きな国や小さな国を表示したりもできます。

-

このコード修正を通して、Googleマップにどれだけの労力が投入されているのかを実感しました。 -Googleマップの開発者は、どのラベルを表示するかを決定しなければなりません。 -色んな基準を使っているのは間違いないですね。 -例えば、あなたの現在地、デフォルトの言語設定、アカウントを持っている場合はアカウントの設定、人口や人気度を使用している可能性、ビューの中央にある国を優先している可能性など。 -考える事はたくさんあります。

-

これらの例がHTML要素を3Dに配置する方法について、あなたにいくつかのアイデアを与えられたと思います。 -変更したい事がいくつかあります。

-

次は国を選んでハイライトするようにしてみましょう。

-

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/backgrounds.html b/manual/ja/backgrounds.html deleted file mode 100644 index 0e6edccd22aca3..00000000000000 --- a/manual/ja/backgrounds.html +++ /dev/null @@ -1,248 +0,0 @@ - - - の背景とスカイボックス - - - - - - - - - - - - - -
-
-

の背景とスカイボックス

-
-
-
-

このサイトのほとんどの記事では、背景に無地の色を使っています。

-

静的な背景として追加するには、CSSを設定するだけで簡単にできます。 -Three.jsのレスポンシブデザインの記事を例にすると、変更が必要なのは2箇所だけです。

-

キャンバスにCSSを追加して背景を画像に設定する必要があります。

-
<style>
-body {
-    margin: 0;
-}
-#c {
-    width: 100%;
-    height: 100%;
-    display: block;
-+    background: url(resources/images/daikanyama.jpg) no-repeat center center;
-+    background-size: cover;
-}
-</style>
-
-

そして、何も描画していない場所が透明になるように WebGLRendereralpha を指定します。

-
function main() {
-  const canvas = document.querySelector('#c');
--  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+  const renderer = new THREE.WebGLRenderer({
-+    antialias: true,
-+    canvas,
-+    alpha: true,
-+  });
-
-

これで背景を設定できました。

-

- -

-

背景にポストプロセス効果を与えたい場合は、Three.jsを使い背景を描画する必要があります。

-

THREE.jsで簡単にできます。シーンの背景をテクスチャに設定すれば良いのです。

-
const loader = new THREE.TextureLoader();
-const bgTexture = loader.load('resources/images/daikanyama.jpg');
-bgTexture.colorSpace = THREE.SRGBColorSpace;
-scene.background = bgTexture;
-
-

このようになります。

-

- -

-

背景画像がありますが、画面に合わせて引き伸ばされています。

-

この問題はテクスチャの repeatoffset プロパティを設定して画像の一部だけを表示する事で解決できます。

-
function render(time) {
-
-   ...
-
-+  // Set the repeat and offset properties of the background texture
-+  // to keep the image's aspect correct.
-+  // Note the image may not have loaded yet.
-+  const canvasAspect = canvas.clientWidth / canvas.clientHeight;
-+  const imageAspect = bgTexture.image ? bgTexture.image.width / bgTexture.image.height : 1;
-+  const aspect = imageAspect / canvasAspect;
-+
-+  bgTexture.offset.x = aspect > 1 ? (1 - 1 / aspect) / 2 : 0;
-+  bgTexture.repeat.x = aspect > 1 ? 1 / aspect : 1;
-+
-+  bgTexture.offset.y = aspect > 1 ? 0 : (1 - aspect) / 2;
-+  bgTexture.repeat.y = aspect > 1 ? 1 : aspect;
-
-  ...
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-

今度はThree.jsで背景を描画してます。 -CSSの時と比べて目に見える違いはありませんが、ポストプロセス効果を使うと背景にも影響が出て違いがわかります。

-

- -

-

もちろん、静的な背景は通常は3Dシーンに求めるものではありません。 -その代わりに何らかの スカイボックス が欲しいです。 -スカイボックスとは空の絵が描かれたキューブの事です。 -キューブの中にカメラを入れると背景に空があるように見えます。

-

スカイボックスを実装する最も一般的な方法は、キューブを作りテクスチャを適用して内側から描画する事です。 -キューブの各面に地平線の画像のようなテクスチャ(テクスチャ座標を使用)を貼り付けます。 -また、スカイボックスの代わりに天球やスカイドームを使う事もよくあります。 -それらでの実装は自分で調べればわかると思います。 -キューブや球体を作りテクスチャを適用し、THREE.BackSide としてマークを付けます。 -そして、外側ではなく内側にレンダリングし、直接シーンに入れるか、上記のようにするか、2つのシーンを作るか、スカイボックス/球体/ドームを描くための特別なシーンと -他の全てのものを描画するために使用します。 -描画には通常の PerspectiveCamera を使います。OrthographicCamera は必要ないです。

-

もう1つの解決策は キューブマップ を使用する事です。 -キューブマップは、キューブの側面である6つの側面を持つ特殊な種類のテクスチャです。 -標準的なテクスチャ座標を使用するのではなく、中心から外側に向けた方向を使用し、どこで色を取得するかを決定します。

-

カリフォルニア州マウンテンビューにあるコンピュータ歴史博物館のキューブマップの画像6枚をご紹介します。

-
- - - -
-
- - - -
- -

CubeTextureLoader を使用してキューブマップ画像を読み込み、シーンの背景として使用します。

-
{
-  const loader = new THREE.CubeTextureLoader();
-  const texture = loader.load([
-    'resources/images/cubemaps/computer-history-museum/pos-x.jpg',
-    'resources/images/cubemaps/computer-history-museum/neg-x.jpg',
-    'resources/images/cubemaps/computer-history-museum/pos-y.jpg',
-    'resources/images/cubemaps/computer-history-museum/neg-y.jpg',
-    'resources/images/cubemaps/computer-history-museum/pos-z.jpg',
-    'resources/images/cubemaps/computer-history-museum/neg-z.jpg',
-  ]);
-  scene.background = texture;
-}
-
-

レンダリング時には、repeatやoffsetプロパティでテクスチャを調整する必要はありません。

-
function render(time) {
-
-   ...
-
--  // Set the repeat and offset properties of the background texture
--  // to keep the image's aspect correct.
--  // Note the image may not have loaded yet.
--  const canvasAspect = canvas.clientWidth / canvas.clientHeight;
--  const imageAspect = bgTexture.image ? bgTexture.image.width / bgTexture.image.height : 1;
--  const aspect = imageAspect / canvasAspect;
--
--  bgTexture.offset.x = aspect > 1 ? (1 - 1 / aspect) / 2 : 0;
--  bgTexture.repeat.x = aspect > 1 ? 1 / aspect : 1;
--
--  bgTexture.offset.y = aspect > 1 ? 0 : (1 - aspect) / 2;
--  bgTexture.repeat.y = aspect > 1 ? 1 : aspect;
-
-  ...
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-

カメラを回転できるようにコントロールを追加してみましょう。

-
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-
-
const fov = 75;
-const aspect = 2;  // the canvas default
-const near = 0.1;
--const far = 5;
-+const far = 100;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
--camera.position.z = 2;
-+camera.position.z = 3;
-
-+const controls = new OrbitControls(camera, canvas);
-+controls.target.set(0, 0, 0);
-+controls.update();
-
-

これを試してみて下さい。 -ドラッグしてカメラを回転させ、キューブマップが周囲を取り囲んでいるのを見てみましょう。

-

- -

-

もう1つの方法は正距円筒図を使用する事です。 -360カメラで撮るとこんな感じの写真になります。

-

このサイトで見つけたのがこちらです。

-
- -

たいして変わらないですね。 -まず、正距円筒図をテクスチャとして読み込み、読み込み後のコールバックで WebGLCubeRenderTarget.fromEquirectangularTexture を呼び出して、正距円筒図テクスチャからキューブマップを生成します。

-
{
--  const loader = new THREE.CubeTextureLoader();
--  const texture = loader.load([
--    'resources/images/cubemaps/computer-history-museum/pos-x.jpg',
--    'resources/images/cubemaps/computer-history-museum/neg-x.jpg',
--    'resources/images/cubemaps/computer-history-museum/pos-y.jpg',
--    'resources/images/cubemaps/computer-history-museum/neg-y.jpg',
--    'resources/images/cubemaps/computer-history-museum/pos-z.jpg',
--    'resources/images/cubemaps/computer-history-museum/neg-z.jpg',
--  ]);
--  scene.background = texture;
-+  const loader = new THREE.TextureLoader();
-+  const texture = loader.load(
-+    'resources/images/equirectangularmaps/tears_of_steel_bridge_2k.jpg',
-+    () => {
-+      texture.mapping = THREE.EquirectangularReflectionMapping;
-+      texture.colorSpace = THREE.SRGBColorSpace;
-+      scene.background = texture;
-+    });
-}
-
-

そして、これが全てです。

-

- -

-

テクスチャを読み込み時に行うのではなく、あらかじめ等角画像をキューブマップに変換しておく事ともできます。こんなサイトもあります

- -
-
-
- - - - - - - - diff --git a/manual/ja/billboards.html b/manual/ja/billboards.html deleted file mode 100644 index b41ed3d48d3740..00000000000000 --- a/manual/ja/billboards.html +++ /dev/null @@ -1,282 +0,0 @@ - - - のビルボード - - - - - - - - - - - - - -
-
-

のビルボード

-
-
-
-

前回のページでは CanvasTextureを使ってラベルとバッジを作りました。バッジなどが常にカメラの方向を向いて文字が読める状態になっているという効果が欲しい時があります(訳註:ビルボード効果と言います)。Three.jsはSpriteSpriteMaterial を使ってビルボード効果を実現できます。

-

In a previous article we used a CanvasTexture -to make labels / badges on characters. Sometimes we'd like to make labels or -other things that always face the camera. Three.js provides the Sprite and -SpriteMaterial to make this happen.

-

この記事からサンプルを拝借してSpriteSpriteMaterialを使ってみましょう。

-
function makePerson(x, labelWidth, size, name, color) {
-  const canvas = makeLabelCanvas(labelWidth, size, name);
-  const texture = new THREE.CanvasTexture(canvas);
-  // because our canvas is likely not a power of 2
-  // in both dimensions set the filtering appropriately.
-  texture.minFilter = THREE.LinearFilter;
-  texture.wrapS = THREE.ClampToEdgeWrapping;
-  texture.wrapT = THREE.ClampToEdgeWrapping;
-
--  const labelMaterial = new THREE.MeshBasicMaterial({
-+  const labelMaterial = new THREE.SpriteMaterial({
-    map: texture,
--    side: THREE.DoubleSide,
-    transparent: true,
-  });
-
-  const root = new THREE.Object3D();
-  root.position.x = x;
-
-  const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
-  root.add(body);
-  body.position.y = bodyHeight / 2;
-
-  const head = new THREE.Mesh(headGeometry, bodyMaterial);
-  root.add(head);
-  head.position.y = bodyHeight + headRadius * 1.1;
-
--  const label = new THREE.Mesh(labelGeometry, labelMaterial);
-+  const label = new THREE.Sprite(labelMaterial);
-  root.add(label);
-  label.position.y = bodyHeight * 4 / 5;
-  label.position.z = bodyRadiusTop * 1.01;
-
-

はい、常にラベルがカメラの方を向くようになりました。

-

- -

-

しかし角度によってはラベルがオブジェクトに食い込んでしまうことがあります。

-
- -

ラベルの位置を動かしましょう。

-
+// if units are meters then 0.01 here makes size
-+// of the label into centimeters.
-+const labelBaseScale = 0.01;
-const label = new THREE.Sprite(labelMaterial);
-root.add(label);
--label.position.y = bodyHeight * 4 / 5;
--label.position.z = bodyRadiusTop * 1.01;
-+label.position.y = head.position.y + headRadius + size * labelBaseScale;
-
--// if units are meters then 0.01 here makes size
--// of the label into centimeters.
--const labelBaseScale = 0.01;
-label.scale.x = canvas.width  * labelBaseScale;
-label.scale.y = canvas.height * labelBaseScale;
-
-

- -

-

ビルボード効果を使ってファケード(訳註:ハリボテのようなもの)を作ることもできます。

-

つまり3Dオブジェクトは重いので3Dオブジェクトを描画する代わりに同じ絵を描いた板を用意するということです。

-

さっそくやってみます。たくさんの木があるシーンを作ってみます。1つの木はシリンダーとコーンでできています。

-

まずはファケードを使わずに単純に3Dオブジェクトを並べてみます。

-
const trunkRadius = .2;
-const trunkHeight = 1;
-const trunkRadialSegments = 12;
-const trunkGeometry = new THREE.CylinderGeometry(
-    trunkRadius, trunkRadius, trunkHeight, trunkRadialSegments);
-
-const topRadius = trunkRadius * 4;
-const topHeight = trunkHeight * 2;
-const topSegments = 12;
-const topGeometry = new THREE.ConeGeometry(
-    topRadius, topHeight, topSegments);
-
-const trunkMaterial = new THREE.MeshPhongMaterial({color: 'brown'});
-const topMaterial = new THREE.MeshPhongMaterial({color: 'green'});
-
-

草の部分と幹の部分をそれぞれMeshで作り親オブジェクトObject3Dに加えます。

-
function makeTree(x, z) {
-  const root = new THREE.Object3D();
-  const trunk = new THREE.Mesh(trunkGeometry, trunkMaterial);
-  trunk.position.y = trunkHeight / 2;
-  root.add(trunk);
-
-  const top = new THREE.Mesh(topGeometry, topMaterial);
-  top.position.y = trunkHeight + topHeight / 2;
-  root.add(top);
-
-  root.position.set(x, 0, z);
-  scene.add(root);
-
-  return root;
-}
-
-

たくさん作ってみましょう。

-
for (let z = -50; z <= 50; z += 10) {
-  for (let x = -50; x <= 50; x += 10) {
-    makeTree(x, z);
-  }
-}
-
-

地面も一応作ります。

-
// add ground
-{
-  const size = 400;
-  const geometry = new THREE.PlaneGeometry(size, size);
-  const material = new THREE.MeshPhongMaterial({color: 'gray'});
-  const mesh = new THREE.Mesh(geometry, material);
-  mesh.rotation.x = Math.PI * -0.5;
-  scene.add(mesh);
-}
-
-

空の色は青にします。

-
const scene = new THREE.Scene();
--scene.background = new THREE.Color('white');
-+scene.background = new THREE.Color('lightblue');
-
-

はい、木がたくさんできました。

-

- -

-

121個の木があります。1つにつき12ポリゴンのコーンと48ポリゴンのシリンダーがあるので1つの木は60ポリゴンです。 -これが121個あるので7260ポリゴンです。このシンプルな木ならそれほど問題ありませんが、リアルな木を作ろうとしたら1つの木につき1000から3000のポリゴンがあるのが普通です。ということは121個表示するには36万3千ポリゴン必要です。木を表示するだけで動作が重くなるかもしれません。

-

そこでファケードを使ってポリゴン数を落とします。

-

ペイントソフトで描いた絵をPlaneにはってもいいのですがここは学んだことを使いましょう。

-

RenderTargetを使ってThree.js内で絵を描き、Planeに貼ってみます。 -この記事が参考になります。

-
function frameArea(sizeToFitOnScreen, boxSize, boxCenter, camera) {
-  const halfSizeToFitOnScreen = sizeToFitOnScreen * 0.5;
-  const halfFovY = THREE.MathUtils.degToRad(camera.fov * .5);
-  const distance = halfSizeToFitOnScreen / Math.tan(halfFovY);
-
-  camera.position.copy(boxCenter);
-  camera.position.z += distance;
-
-  // pick some near and far values for the frustum that
-  // will contain the box.
-  camera.near = boxSize / 100;
-  camera.far = boxSize * 100;
-
-  camera.updateProjectionMatrix();
-}
-
-function makeSpriteTexture(textureSize, obj) {
-  const rt = new THREE.WebGLRenderTarget(textureSize, textureSize);
-
-  const aspect = 1;  // because the render target is square
-  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-
-  scene.add(obj);
-
-  // compute the box that contains obj
-  const box = new THREE.Box3().setFromObject(obj);
-
-  const boxSize = box.getSize(new THREE.Vector3());
-  const boxCenter = box.getCenter(new THREE.Vector3());
-
-  // set the camera to frame the box
-  const fudge = 1.1;
-  const size = Math.max(...boxSize.toArray()) * fudge;
-  frameArea(size, size, boxCenter, camera);
-
-  renderer.autoClear = false;
-  renderer.setRenderTarget(rt);
-  renderer.render(scene, camera);
-  renderer.setRenderTarget(null);
-  renderer.autoClear = true;
-
-  scene.remove(obj);
-
-  return {
-    position: boxCenter.multiplyScalar(fudge),
-    scale: size,
-    texture: rt.texture,
-  };
-}
-
-

まずフィールドオブビュー(fov)を設定しています。カメラの視野範囲におさまる木をこの記事と同じ方法で計算しています。

-

さらにframeAreaを使っています。これは木を表示する最も近いカメラの位置を計算してカメラに設定しています。仮想的なスタジオで木の写真をとっているような状態です。

-

レンダーターゲットに木がおさまるようにサイズを1.1倍(fudge倍)しています。ここで注意が必要なのはカメラで撮影した木の映像がレンダーターゲットをはみ出したり逆に小さすぎたりする場合です。もちろんこうした状態は事前に精緻な計算をしておけば良いのですがここではfudgeで調整しています。

-

木の撮影ができたのでレンダーターゲットにレンダリングして元の3Dオブジェクトは消しておきます。

-

シーンにはライトだけがある状態です。

-
const scene = new THREE.Scene();
--scene.background = new THREE.Color('lightblue');
-
-

最後にテクスチャを作って位置とサイズを調整します。

-
// make billboard texture
-const tree = makeTree(0, 0);
-const facadeSize = 64;
-const treeSpriteInfo = makeSpriteTexture(facadeSize, tree);
-
-

これで重い3Dモデルの木の代わりにファケードを使った木ができました。

-
+function makeSprite(spriteInfo, x, z) {
-+  const {texture, offset, scale} = spriteInfo;
-+  const mat = new THREE.SpriteMaterial({
-+    map: texture,
-+    transparent: true,
-+  });
-+  const sprite = new THREE.Sprite(mat);
-+  scene.add(sprite);
-+  sprite.position.set(
-+      offset.x + x,
-+      offset.y,
-+      offset.z + z);
-+  sprite.scale.set(scale, scale, scale);
-+}
-
-for (let z = -50; z <= 50; z += 10) {
-  for (let x = -50; x <= 50; x += 10) {
--    makeTree(x, z);
-+    makeSprite(treeSpriteInfo, x, z);
-  }
-}
-
-

背景も変えてみます。

-
scene.background = new THREE.Color('lightblue');
-
-

完成です。

-

- -

-

3Dモデルと違って近づくとハリボテであることがバレてしまいます。今回は64x64ピクセルで作りましたがもちろん高解像にすることもできます。しかしそれでも3Dモデルのように近づいてもエッジが綺麗というわけにはいかないので、通常はカメラが近づくことがない遠く離れた木や山に使います。

-

他にはカメラの向きを変えても同じように見える問題がありますが、これは8個のファケードを用意して別の角度から見たときに別のファケードが見えるようにすればいいでしょう。

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/cameras.html b/manual/ja/cameras.html deleted file mode 100644 index 7ac3174586b1e6..00000000000000 --- a/manual/ja/cameras.html +++ /dev/null @@ -1,525 +0,0 @@ - - - のカメラ - - - - - - - - - - - - - -
-
-

のカメラ

-
-
-
-

この記事はThree.jsの連載記事の1つです。 -最初の記事はThree.jsの基礎知識です。 -まだ読んでいない場合、そこから始めると良いかもしれません。

-

three.jsでのカメラの話をしましょう。 -最初の記事でいくつか取り上げましたが、ここではもっと詳しく取り上げます。

-

PerspectiveCamera(透視投影カメラ) はthree.jsで最も一般的なカメラで、今までの記事で使ってきました。 -遠くのものが近くのものよりも小さく見える3Dビューを提供します。

-

PerspectiveCamera錐台 を定義します。 -錐台とは先端が切り取られたピラミッドのような3D形状の事です。 -つまり、cube(立方体)、cone(円錐体)、sphere(球体)、cylinder(円柱)、frustum(錐台)は全て異なる種類の固体名です。

-
-
cube
-
cone
-
sphere
-
cylinder
-
frustum
-
- -

私はこの事を何年も知らなかったです。 -どこかの本やページで 錐台 について書かれていると目が点になります。 -錐台が固体名と理解すると、それらの記述を急に理解できるようになりました 😅

-

PerspectiveCamera には4つのプロパティをもとに錐台が定義されています。 -near は錐台の正面がどこから始まるかを定義します。 -far は錐台が終了する場所です。 -fov は視野角で、カメラから near 単位で指定された視野角を得るために正しい高さが計算され、錐台の前面と背面の高さを定義します。 -aspect は錐台の前面と背面の幅です。 -錐台の幅は高さにaspectを掛けたものです。

-

-

前回の記事から地面となる平面、球体、立方体のあるシーンを利用し、カメラの設定を調整してみましょう。

-

nearfar の設定用に MinMaxGUIHelper を作成します。 -far が常に near よりも大きい値になるようにします。 -MinMaxGUIHelperは minmax のプロパティがあり、lil-guiで調整します。 -GUIで値を調整すると2つのプロパティに設定されます。

-
class MinMaxGUIHelper {
-  constructor(obj, minProp, maxProp, minDif) {
-    this.obj = obj;
-    this.minProp = minProp;
-    this.maxProp = maxProp;
-    this.minDif = minDif;
-  }
-  get min() {
-    return this.obj[this.minProp];
-  }
-  set min(v) {
-    this.obj[this.minProp] = v;
-    this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
-  }
-  get max() {
-    return this.obj[this.maxProp];
-  }
-  set max(v) {
-    this.obj[this.maxProp] = v;
-    this.min = this.min;  // this will call the min setter
-  }
-}
-
-

これでGUIを以下のように設定できます。

-
function updateCamera() {
-  camera.updateProjectionMatrix();
-}
-
-const gui = new GUI();
-gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
-const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
-gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
-gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
-
-

カメラ設定の変更時、カメラの updateProjectionMatrix 関数を呼び出す必要があります。 -updateCamera という関数を作り、それをlil-gui変更時に呼び出すようにします。

-

- -

-

値を調整すると動作が確認できます。 -aspect を調整したい場合は、新しいウィンドウでサンプルを開いてからウィンドウサイズを変更して下さい。

-

それでもまだ少し見づらいので、2つのカメラを持つサンプルに変えます。 -1つ目のカメラは上記で見たシーンを表示し、2つ目のカメラは1つ目のカメラが描画してるシーンを見ている別のカメラとし、そのカメラの錐台を表示します。

-

そのためにthree.jsのシザー関数を利用します。 -シザー機能を使い、カメラを2台並べて2つのシーンを描画するように変更してみましょう。

-

まず、HTMLとCSSを使って2つの並んでるDOM要素を定義してみましょう。 -両方のカメラが簡単に独自の OrbitControls を持てるようにします。

-
<body>
-  <canvas id="c"></canvas>
-+  <div class="split">
-+     <div id="view1" tabindex="1"></div>
-+     <div id="view2" tabindex="2"></div>
-+  </div>
-</body>
-
-

このview1とview2をキャンバスの上に重ねて表示させます。

-
.split {
-  position: absolute;
-  left: 0;
-  top: 0;
-  width: 100%;
-  height: 100%;
-  display: flex;
-}
-.split>div {
-  width: 100%;
-  height: 100%;
-}
-
-

次に CameraHelper を追加します。 -CameraHelperCamera の錐台を描画します。

-
const cameraHelper = new THREE.CameraHelper(camera);
-
-...
-
-scene.add(cameraHelper);
-
-

view1とview2のDOM要素をquerySelectorしましょう。

-
const view1Elem = document.querySelector('#view1');
-const view2Elem = document.querySelector('#view2');
-
-

既存の OrbitControls をview1にのみ反応するようにします。

-
-const controls = new OrbitControls(camera, canvas);
-+const controls = new OrbitControls(camera, view1Elem);
-
-

2つ目の PerspectiveCameraOrbitControls を作ってみましょう。 -2つ目の OrbitControls は2つ目のカメラに関連付けし、view2から入力を取得します。

-
const camera2 = new THREE.PerspectiveCamera(
-  60,  // fov
-  2,   // aspect
-  0.1, // near
-  500, // far
-);
-camera2.position.set(40, 10, 30);
-camera2.lookAt(0, 5, 0);
-
-const controls2 = new OrbitControls(camera2, view2Elem);
-controls2.target.set(0, 5, 0);
-controls2.update();
-
-

最後にキャンバスの一部だけをレンダリングするために、シザー機能を使い各カメラの視点からシーンをレンダリングします。

-

ここにDOM要素を渡すと、キャンバスに重なる矩形を計算する関数があります。 -その矩形にシザーとビューポートを設定し、アスペクト比を返します。

-
function setScissorForElement(elem) {
-  const canvasRect = canvas.getBoundingClientRect();
-  const elemRect = elem.getBoundingClientRect();
-
-  // compute a canvas relative rectangle
-  const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
-  const left = Math.max(0, elemRect.left - canvasRect.left);
-  const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
-  const top = Math.max(0, elemRect.top - canvasRect.top);
-
-  const width = Math.min(canvasRect.width, right - left);
-  const height = Math.min(canvasRect.height, bottom - top);
-
-  // setup the scissor to only render to that part of the canvas
-  const positiveYUpBottom = canvasRect.height - bottom;
-  renderer.setScissor(left, positiveYUpBottom, width, height);
-  renderer.setViewport(left, positiveYUpBottom, width, height);
-
-  // return the aspect
-  return width / height;
-}
-
-

この関数を使って render 関数でシーンを2回描画できます。

-
  function render() {
-
--    if (resizeRendererToDisplaySize(renderer)) {
--      const canvas = renderer.domElement;
--      camera.aspect = canvas.clientWidth / canvas.clientHeight;
--      camera.updateProjectionMatrix();
--    }
-
-+    resizeRendererToDisplaySize(renderer);
-+
-+    // turn on the scissor
-+    renderer.setScissorTest(true);
-+
-+    // render the original view
-+    {
-+      const aspect = setScissorForElement(view1Elem);
-+
-+      // adjust the camera for this aspect
-+      camera.aspect = aspect;
-+      camera.updateProjectionMatrix();
-+      cameraHelper.update();
-+
-+      // don't draw the camera helper in the original view
-+      cameraHelper.visible = false;
-+
-+      scene.background.set(0x000000);
-+
-+      // render
-+      renderer.render(scene, camera);
-+    }
-+
-+    // render from the 2nd camera
-+    {
-+      const aspect = setScissorForElement(view2Elem);
-+
-+      // adjust the camera for this aspect
-+      camera2.aspect = aspect;
-+      camera2.updateProjectionMatrix();
-+
-+      // draw the camera helper in the 2nd view
-+      cameraHelper.visible = true;
-+
-+      scene.background.set(0x000040);
-+
-+      renderer.render(scene, camera2);
-+    }
-
--    renderer.render(scene, camera);
-
-    requestAnimationFrame(render);
-  }
-
-  requestAnimationFrame(render);
-}
-
-

上記のコードはview1とview2を区別するために、view2をレンダリング時のシーンの背景色を紺色にしています。

-

また、render 関数内で全て更新しているため、updateCamera のコードを削除できます。

-
-function updateCamera() {
--  camera.updateProjectionMatrix();
--}
-
-const gui = new GUI();
--gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
-+gui.add(camera, 'fov', 1, 180);
-const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
--gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
--gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
-+gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near');
-+gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far');
-
-

片方のviewを使い、もう片方の錐台を見るれるようになりました。

-

- -

-

左側はオリジナルのビュー、右側はカメラの錐台を表示するビューがあります。 -マウスで nearfarfov を調整してカメラを動かすと、右側に表示されている錐台の内側だけが左側のシーンに表示されています。

-

near を20くらいに調整すると、錐台に入らずオブジェクトの正面が消えます。 -far を35以下に調整すると、錐台に入らず地上の平面が消えていきます。

-

ここで疑問が湧いてきました。 -near を0.0000000001に far を10000000000000に設定し、全てを見えるようにできないでしょうか? -なぜなら、GPUは何かが前後にあるかを判断する精度が高いからです。 -その精度は nearfar の間に分散しています。 -さらに悪い事にデフォルトではカメラの近くの精度は細かく、カメラから遠い精度は粗くなっています。 -単位の値は near から始まり、far に近づくにつれて徐々に拡大していきます。

-

上記のサンプルから始めて、20個の球体を1列に挿入するコードに変更してみましょう。

-
{
-  const sphereRadius = 3;
-  const sphereWidthDivisions = 32;
-  const sphereHeightDivisions = 16;
-  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
-  const numSpheres = 20;
-  for (let i = 0; i < numSpheres; ++i) {
-    const sphereMat = new THREE.MeshPhongMaterial();
-    sphereMat.color.setHSL(i * .73, 1, 0.5);
-    const mesh = new THREE.Mesh(sphereGeo, sphereMat);
-    mesh.position.set(-sphereRadius - 1, sphereRadius + 2, i * sphereRadius * -2.2);
-    scene.add(mesh);
-  }
-}
-
-

near を0.00001に設定してみましょう。

-
const fov = 45;
-const aspect = 2;  // the canvas default
--const near = 0.1;
-+const near = 0.00001;
-const far = 100;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-
-

値の編集時に0.00001を許容するようにGUIコードを微調整します。

-
-gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
-+gui.add(minMaxGUIHelper, 'min', 0.00001, 50, 0.00001).name('near').onChange(updateCamera);
-
-

何が起こると思いますか?

-

- -

-

これはGPUがどのピクセルが前後にあるか判断する精度が不足してる時に Zファイティング が発生する例です。

-

あなたのマシンでは問題が表示されない可能性がありますが、私のマシンでは以下のように表示されます。

-
- -

1つ目の解決策はどのピクセルが前後にあるかを計算するために、three.jsの別メソッドを使用します。 -これは WebGLRenderer の作成時に logarithmicDepthBuffer を有効にします。

-
-const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+const renderer = new THREE.WebGLRenderer({
-+  antialias: true,
-+  canvas,
-+  logarithmicDepthBuffer: true,
-+});
-
-

これで上手く動くかもしれません。

-

- -

-

これで問題が解決しない場合、この解決策が使えない理由の1つに遭遇した事になります。 -その理由は、特定のGPUのみをサポートしているためです。 -2018年9月現在、ほとんどのデスクトップがこの解決策をサポートしていますが、モバイルデバイスはほとんどサポートしていません。

-

この解決策を選択しないもう1つの理由は、標準的な解決策よりも大幅に遅くなる可能性があります。

-

この解決策は解像度に制限があります。 -near をさらに小さくしたり far をさらに大きくしたりすると最終的に同じ問題にぶつかります。

-

nearfar の設定は、常にユースケースに合った値を選択して下さい。 -near はカメラからできるだけ離れた所に置き、オブジェクトが消えないようにしましょう。 -far はカメラからできるだけ近い所に置き、オブジェクトが消えないようにしましょう。

-

もしまつげを見れるぐらい誰かの顔をクローズアップし、背景には50キロ離れた山までの道のりを見る巨大なシーンを描画したい場合、他の創造的な解決策を見つける必要があるでしょう。 -この解決策は後にしましょう。 -とりあえず自分のニーズに合わせて nearfar は適切な値を選択しましょう。

-

2番目に一般的なカメラは OrthographicCamera(平行投影カメラ) です。 -錐台を指定するのではなく、leftrighttopbottomnearfar の設定でボックスを指定します。 -ボックスを投影しているので遠近感はありません。

-

上記のview1とview2のサンプルを変更し、最初のビューで OrthographicCamera を使うようにしましょう。

-

最初に OrthographicCamera を設定します。

-
const left = -1;
-const right = 1;
-const top = 1;
-const bottom = -1;
-const near = 5;
-const far = 50;
-const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
-camera.zoom = 0.2;
-
-

leftbottom を-1、righttop を1にしました。 -これで箱の幅が2、高さが2になりますが、描画している矩形のアスペクト比で lefttop を調整します。 -zoom プロパティでカメラで実際に表示される値を簡単に調整できます。

-

GUIに zoom の設定を追加してみましょう。

-
const gui = new GUI();
-+gui.add(camera, 'zoom', 0.01, 1, 0.01).listen();
-
-

listen 呼び出しはlil-guiに変更を監視するようにします。 -これは OrbitControls がズームも制御できるからです。 -例えばマウスのスクロールホイールは OrbitControls でズームします。

-

最後に左側をレンダリングする部分を変更して OrthographicCamera を更新します。

-
{
-  const aspect = setScissorForElement(view1Elem);
-
-  // update the camera for this aspect
--  camera.aspect = aspect;
-+  camera.left   = -aspect;
-+  camera.right  =  aspect;
-  camera.updateProjectionMatrix();
-  cameraHelper.update();
-
-  // don't draw the camera helper in the original view
-  cameraHelper.visible = false;
-
-  scene.background.set(0x000000);
-  renderer.render(scene, camera);
-}
-
-

これで OrthographicCamera が動作しているのが見れるようになりました。

-

- -

-

three.jsで2次元のものを描画する場合には、OrthographicCamera が最もよく使われます。 -カメラの表示台数を決める必要があります。 -例えば、1ピクセルのキャンバスをカメラの1単位と一致させたい場合、次のような事ができます。

-

原点を中心に置き、1ピクセル = three.jsの1単位とするには次のようにします。

-
camera.left = -canvas.width / 2;
-camera.right = canvas.width / 2;
-camera.top = canvas.height / 2;
-camera.bottom = -canvas.height / 2;
-camera.near = -1;
-camera.far = 1;
-camera.zoom = 1;
-
-

原点を2Dキャンバスのように左上に配置したい場合は、次のようにします。

-
camera.left = 0;
-camera.right = canvas.width;
-camera.top = 0;
-camera.bottom = canvas.height;
-camera.near = -1;
-camera.far = 1;
-camera.zoom = 1;
-
-

この場合、左上の角は2Dキャンバスのように0, 0になります。

-

やってみましょう!まずはカメラの設定をします。

-
const left = 0;
-const right = 300;  // default canvas size
-const top = 0;
-const bottom = 150;  // default canvas size
-const near = -1;
-const far = 1;
-const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
-camera.zoom = 1;
-
-

続いて、6枚のテクスチャをロードし、6枚の平面を作ってみましょう。 -各平面は THREE.Object3D を親にし、平面の中心を左上にして簡単にオフセットできるようにします。

-
const loader = new THREE.TextureLoader();
-const textures = [
-  loader.load('resources/images/flower-1.jpg'),
-  loader.load('resources/images/flower-2.jpg'),
-  loader.load('resources/images/flower-3.jpg'),
-  loader.load('resources/images/flower-4.jpg'),
-  loader.load('resources/images/flower-5.jpg'),
-  loader.load('resources/images/flower-6.jpg'),
-];
-const planeSize = 256;
-const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
-const planes = textures.map((texture) => {
-  const planePivot = new THREE.Object3D();
-  scene.add(planePivot);
-  texture.magFilter = THREE.NearestFilter;
-  const planeMat = new THREE.MeshBasicMaterial({
-    map: texture,
-    side: THREE.DoubleSide,
-  });
-  const mesh = new THREE.Mesh(planeGeo, planeMat);
-  planePivot.add(mesh);
-  // move plane so top left corner is origin
-  mesh.position.set(planeSize / 2, planeSize / 2, 0);
-  return planePivot;
-});
-
-

キャンバスサイズの変更時、カメラを更新する必要があります。

-
function render() {
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    camera.right = canvas.width;
-    camera.bottom = canvas.height;
-    camera.updateProjectionMatrix();
-  }
-
-  ...
-
-

planesTHREE.Mesh の配列であり、各平面に1つずつあります。 -これらを時間に応じて移動させてみましょう。

-
function render(time) {
-  time *= 0.001;  // convert to seconds;
-
-  ...
-
-  const distAcross = Math.max(20, canvas.width - planeSize);
-  const distDown = Math.max(20, canvas.height - planeSize);
-
-  // total distance to move across and back
-  const xRange = distAcross * 2;
-  const yRange = distDown * 2;
-  const speed = 180;
-
-  planes.forEach((plane, ndx) => {
-    // compute a unique time for each plane
-    const t = time * speed + ndx * 300;
-
-    // get a value between 0 and range
-    const xt = t % xRange;
-    const yt = t % yRange;
-
-    // set our position going forward if 0 to half of range
-    // and backward if half of range to range
-    const x = xt < distAcross ? xt : xRange - xt;
-    const y = yt < distDown   ? yt : yRange - yt;
-
-    plane.position.set(x, y, 0);
-  });
-
-  renderer.render(scene, camera);
-
-

2Dキャンバスのようにピクセル計算を使い、画像がキャンバスの縁からピクセルのように跳ね返っているのが分かります。

-

- -

-

OrthographicCamera のもう1つの一般的な用途は3Dモデリングツールやゲームエンジンで、上、下、左、右、正面、背面のビューを描画する場合です。

-
- -

上記のスクリーンショットでは右上のビューが透視投影図、左上のビューが平行投影図です。

-

それがカメラの基本です。 -カメラを動かすための一般的な方法は別の記事で紹介します。 -とりあえずについてのページに移りましょう。

-

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/canvas-textures.html b/manual/ja/canvas-textures.html deleted file mode 100644 index 4aa9ebe26129c3..00000000000000 --- a/manual/ja/canvas-textures.html +++ /dev/null @@ -1,411 +0,0 @@ - - - のキャンバステクスチャ - - - - - - - - - - - - - -
-
-

のキャンバステクスチャ

-
-
-
-

この記事はThree.jsのテクスチャからの続きです。 -まだ読んでない人はそちらから先に読んでみるといいかもしれません。

-

前回のテクスチャの記事ではテクスチャは画像ファイルを使っていました。 -実行時にテクスチャを生成したい場合もあります。 -これを行う方法の1つは CanvasTexture を使用する事です。

-

キャンバステクスチャは <canvas> を入力として受け取ります。 -2D canvas APIでキャンバスに描画する方法を知らない場合、MDNに良いチュートリアルがあります

-

簡単なキャンバスのプログラムを作ってみましょう。 -ランダムな場所にランダムな色で点を描画します。

-
const ctx = document.createElement('canvas').getContext('2d');
-document.body.appendChild(ctx.canvas);
-ctx.canvas.width = 256;
-ctx.canvas.height = 256;
-ctx.fillStyle = '#FFF';
-ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
-
-function randInt(min, max) {
-  if (max === undefined) {
-    max = min;
-    min = 0;
-  }
-  return Math.random() * (max - min) + min | 0;
-}
-
-function drawRandomDot() {
-  ctx.fillStyle = `#${randInt(0x1000000).toString(16).padStart(6, '0')}`;
-  ctx.beginPath();
-
-  const x = randInt(256);
-  const y = randInt(256);
-  const radius = randInt(10, 64);
-  ctx.arc(x, y, radius, 0, Math.PI * 2);
-  ctx.fill();
-}
-
-function render() {
-  drawRandomDot();
-  requestAnimationFrame(render);
-}
-requestAnimationFrame(render);
-
-

結構簡単ですね。

-

- -

-

これをテクスチャとして使ってみましょう。 -まずは前回の記事の立方体のテクスチャにしてみます。 -画像を読込するコードを削除します。 -代わりにキャンバスを作成し CanvasTexture を作成してキャンバスに渡します。

-
const cubes = [];  // just an array we can use to rotate the cubes
--const loader = new THREE.TextureLoader();
--
-+const ctx = document.createElement('canvas').getContext('2d');
-+ctx.canvas.width = 256;
-+ctx.canvas.height = 256;
-+ctx.fillStyle = '#FFF';
-+ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
-+const texture = new THREE.CanvasTexture(ctx.canvas);
-
-const material = new THREE.MeshBasicMaterial({
--  map: loader.load('resources/images/wall.jpg'),
-+  map: texture,
-});
-const cube = new THREE.Mesh(geometry, material);
-scene.add(cube);
-cubes.push(cube);  // add to our list of cubes to rotate
-
-

描画のループ処理でランダムな点を描画するコードを呼び出します。

-
function render(time) {
-  time *= 0.001;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-+  drawRandomDot();
-+  texture.needsUpdate = true;
-
-  cubes.forEach((cube, ndx) => {
-    const speed = .2 + ndx * .1;
-    const rot = time * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-

唯一の余計な事は CanvasTextureneedsUpdate プロパティを設定し、Three.jsにキャンバスの最新のコンテンツでテクスチャを更新する事です。

-

これでキャンバスのテクスチャキューブができました。

-

- -

-

注意点としてThree.jsを使ってキャンバスに描画する場合、 -この記事 で説明している RenderTarget を使った方が良いでしょう。

-

キャンバステクスチャの一般的な使用例は、シーンにテキストを提供する事です。 -例えばキャラクターのバッジに名前を入れたい場合、キャンバステクスチャを使いバッジのテクスチャを作成します。

-

3人のキャラクターがいるシーンを作り、それぞれにバッジやラベルを付けてみましょう。

-

上記の例から立方体に関連する全てのコードを削除してみましょう。 -背景を白にして、ライトを2つ追加してみましょう。

-
const scene = new THREE.Scene();
-+scene.background = new THREE.Color('white');
-+
-+function addLight(position) {
-+  const color = 0xFFFFFF;
-+  const intensity = 1;
-+  const light = new THREE.DirectionalLight(color, intensity);
-+  light.position.set(...position);
-+  scene.add(light);
-+  scene.add(light.target);
-+}
-+addLight([-3, 1, 1]);
-+addLight([ 2, 1, .5]);
-
-

2Dキャンバスを使い、ラベルを作るコードを作ってみましょう。

-
+function makeLabelCanvas(size, name) {
-+  const borderSize = 2;
-+  const ctx = document.createElement('canvas').getContext('2d');
-+  const font =  `${size}px bold sans-serif`;
-+  ctx.font = font;
-+  // measure how long the name will be
-+  const doubleBorderSize = borderSize * 2;
-+  const width = ctx.measureText(name).width + doubleBorderSize;
-+  const height = size + doubleBorderSize;
-+  ctx.canvas.width = width;
-+  ctx.canvas.height = height;
-+
-+  // need to set font again after resizing canvas
-+  ctx.font = font;
-+  ctx.textBaseline = 'top';
-+
-+  ctx.fillStyle = 'blue';
-+  ctx.fillRect(0, 0, width, height);
-+  ctx.fillStyle = 'white';
-+  ctx.fillText(name, borderSize, borderSize);
-+
-+  return ctx.canvas;
-+}
-
-

続いて体はシリンダー、頭はスフィア、ラベルはプレーンを使い簡単なキャラクターを作ります。

-

まずは共有のジオメトリを作ってみましょう。

-
+const bodyRadiusTop = .4;
-+const bodyRadiusBottom = .2;
-+const bodyHeight = 2;
-+const bodyRadialSegments = 6;
-+const bodyGeometry = new THREE.CylinderGeometry(
-+    bodyRadiusTop, bodyRadiusBottom, bodyHeight, bodyRadialSegments);
-+
-+const headRadius = bodyRadiusTop * 0.8;
-+const headLonSegments = 12;
-+const headLatSegments = 5;
-+const headGeometry = new THREE.SphereGeometry(
-+    headRadius, headLonSegments, headLatSegments);
-+
-+const labelGeometry = new THREE.PlaneGeometry(1, 1);
-
-

では、これらのパーツからキャラクターを作る機能を作ってみましょう。

-
+function makePerson(x, size, name, color) {
-+  const canvas = makeLabelCanvas(size, name);
-+  const texture = new THREE.CanvasTexture(canvas);
-+  // because our canvas is likely not a power of 2
-+  // in both dimensions set the filtering appropriately.
-+  texture.minFilter = THREE.LinearFilter;
-+  texture.wrapS = THREE.ClampToEdgeWrapping;
-+  texture.wrapT = THREE.ClampToEdgeWrapping;
-+
-+  const labelMaterial = new THREE.MeshBasicMaterial({
-+    map: texture,
-+    side: THREE.DoubleSide,
-+    transparent: true,
-+  });
-+  const bodyMaterial = new THREE.MeshPhongMaterial({
-+    color,
-+    flatShading: true,
-+  });
-+
-+  const root = new THREE.Object3D();
-+  root.position.x = x;
-+
-+  const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
-+  root.add(body);
-+  body.position.y = bodyHeight / 2;
-+
-+  const head = new THREE.Mesh(headGeometry, bodyMaterial);
-+  root.add(head);
-+  head.position.y = bodyHeight + headRadius * 1.1;
-+
-+  const label = new THREE.Mesh(labelGeometry, labelMaterial);
-+  root.add(label);
-+  label.position.y = bodyHeight * 4 / 5;
-+  label.position.z = bodyRadiusTop * 1.01;
-+
-+  // if units are meters then 0.01 here makes size
-+  // of the label into centimeters.
-+  const labelBaseScale = 0.01;
-+  label.scale.x = canvas.width  * labelBaseScale;
-+  label.scale.y = canvas.height * labelBaseScale;
-+
-+  scene.add(root);
-+  return root;
-+}
-
-

上記のようにルートの Object3D に体、頭、ラベルを配置して位置を調整しています。 -これでキャラクターを移動させたい場合、ルートオブジェクトを移動します。 -体の高さは2です。 -1が1メートルに等しい場合、上記のコードはラベルをcm単位で作成してます。 -背の高さがcmのサイズなのでテキストに合うような幅が必要です。

-

あとはラベルでキャラクターを作ればいいです。

-
+makePerson(-3, 32, 'Purple People Eater', 'purple');
-+makePerson(-0, 32, 'Green Machine', 'green');
-+makePerson(+3, 32, 'Red Menace', 'red');
-
-

残作業はカメラを動かせるように OrbitControls を追加します。

-
import * as THREE from 'three';
-+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-
-
const fov = 75;
-const aspect = 2;  // the canvas default
-const near = 0.1;
--const far = 5;
-+const far = 50;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
--camera.position.z = 2;
-+camera.position.set(0, 2, 5);
-
-+const controls = new OrbitControls(camera, canvas);
-+controls.target.set(0, 2, 0);
-+controls.update();
-
-

そして、簡単なラベルを取得します。

-

- -

-

気になる点がいくつかあります。

-
    -
  • 拡大するとラベルがかなり低解像度になる
  • -
-

簡単な解決策はありません。 -もっと複雑なフォントの描画テクニックがありますが、私は解決策を知りません。 -また、フォントデータをダウンロードするので時間がかかります。

-

解決策の1つはラベルの解像度を上げる事です。 -渡されたサイズを現在の2倍に設定し、labelBaseScale を現在の半分に設定してみて下さい。

-
    -
  • 名前が長いほどラベルが長くなります
  • -
-

これを修正したければ代わりに固定サイズのラベルを貼り、テキストを押しつぶします。

-

これはとても簡単です。基本となる幅を渡し、幅に合わせてテキストを拡大縮小します。

-
-function makeLabelCanvas(size, name) {
-+function makeLabelCanvas(baseWidth, size, name) {
-  const borderSize = 2;
-  const ctx = document.createElement('canvas').getContext('2d');
-  const font =  `${size}px bold sans-serif`;
-  ctx.font = font;
-  // measure how long the name will be
-+  const textWidth = ctx.measureText(name).width;
-
-  const doubleBorderSize = borderSize * 2;
--  const width = ctx.measureText(name).width + doubleBorderSize;
-+  const width = baseWidth + doubleBorderSize;
-  const height = size + doubleBorderSize;
-  ctx.canvas.width = width;
-  ctx.canvas.height = height;
-
-  // need to set font again after resizing canvas
-  ctx.font = font;
--  ctx.textBaseline = 'top';
-+  ctx.textBaseline = 'middle';
-+  ctx.textAlign = 'center';
-
-  ctx.fillStyle = 'blue';
-  ctx.fillRect(0, 0, width, height);
-
-+  // scale to fit but don't stretch
-+  const scaleFactor = Math.min(1, baseWidth / textWidth);
-+  ctx.translate(width / 2, height / 2);
-+  ctx.scale(scaleFactor, 1);
-  ctx.fillStyle = 'white';
-  ctx.fillText(name, borderSize, borderSize);
-
-  return ctx.canvas;
-}
-
-

次にラベルの幅を渡します。

-
-function makePerson(x, size, name, color) {
--  const canvas = makeLabelCanvas(size, name);
-+function makePerson(x, labelWidth, size, name, color) {
-+  const canvas = makeLabelCanvas(labelWidth, size, name);
-
-...
-
-}
-
--makePerson(-3, 32, 'Purple People Eater', 'purple');
--makePerson(-0, 32, 'Green Machine', 'green');
--makePerson(+3, 32, 'Red Menace', 'red');
-+makePerson(-3, 150, 32, 'Purple People Eater', 'purple');
-+makePerson(-0, 150, 32, 'Green Machine', 'green');
-+makePerson(+3, 150, 32, 'Red Menace', 'red');
-
-

テキストが中央揃えのラベルを取得し、それに合わせて拡大縮小されています。

-

- -

-

上記ではそれぞれのテクスチャに新しいキャンバスを使用しました。 -テクスチャごとにキャンバスを使うかはあなた次第です。 -頻繁に更新する必要がある場合は、テクスチャごとに1つのキャンバスを使用するのがベストな選択かもしれません。

-

めったに更新されない場合は、Three.jsで強制的にテクスチャを使用し、1つのキャンバスを複数のテクスチャに使用できます。

-
+const ctx = document.createElement('canvas').getContext('2d');
-
-function makeLabelCanvas(baseWidth, size, name) {
-  const borderSize = 2;
--  const ctx = document.createElement('canvas').getContext('2d');
-  const font =  `${size}px bold sans-serif`;
-
-  ...
-
-}
-
-+const forceTextureInitialization = function() {
-+  const material = new THREE.MeshBasicMaterial();
-+  const geometry = new THREE.PlaneGeometry();
-+  const scene = new THREE.Scene();
-+  scene.add(new THREE.Mesh(geometry, material));
-+  const camera = new THREE.Camera();
-+
-+  return function forceTextureInitialization(texture) {
-+    material.map = texture;
-+    renderer.render(scene, camera);
-+  };
-+}();
-
-function makePerson(x, labelWidth, size, name, color) {
-  const canvas = makeLabelCanvas(labelWidth, size, name);
-  const texture = new THREE.CanvasTexture(canvas);
-  // because our canvas is likely not a power of 2
-  // in both dimensions set the filtering appropriately.
-  texture.minFilter = THREE.LinearFilter;
-  texture.wrapS = THREE.ClampToEdgeWrapping;
-  texture.wrapT = THREE.ClampToEdgeWrapping;
-+  forceTextureInitialization(texture);
-
-  ...
-
-

- -

-

もう1つの問題はラベルが常にカメラに向いているとは限らない事です。 -ラベルをバッジにしているなら、それは良い事なのかもしれません。 -3Dゲームでプレイヤーの上に名前を置くためにラベルを使用している場合は、ラベルが常にカメラの方を向くようにしたいかもしれません。 -その方法はビルボードの記事で取り上げます。

-

特にラベルの場合はもう1つの解決策はHTMLを使う事です。 -この記事のラベルは他のオブジェクトで隠したい場合には良いですが、HTMLラベルは常に上にあります。

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/cleanup.html b/manual/ja/cleanup.html deleted file mode 100644 index a33a034b832147..00000000000000 --- a/manual/ja/cleanup.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Cleanup - - - - - - - - - - - - - -
-
-

Cleanup

-
-
-
-

すいません、この記事はまだ翻訳してません. Translations Welcome! 😄

-

ここに元の英語の記事があります.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/custom-buffergeometry.html b/manual/ja/custom-buffergeometry.html deleted file mode 100644 index f2e79cc218f657..00000000000000 --- a/manual/ja/custom-buffergeometry.html +++ /dev/null @@ -1,388 +0,0 @@ - - - のカスタムバッファジオメトリ - - - - - - - - - - - - - -
-
-

のカスタムバッファジオメトリ

-
-
-
-

BufferGeometryは全てのジオメトリを表現する方法です。 -BufferGeometryはBufferAttributeを使います。1つのBufferAttributeはジオメトリを作るための1種類のデータに対応しています。vertexの位置情報を格納するためのBufferAttribute、color情報を格納するためのBufferAttribute、normal情報を格納するためのBufferAttributeがそれぞれあります。

-
- -

上の図ではposition, normal, color, uvそれぞれのattribute情報を格納したBufferAttributeを表しています。これらは並列な配列です。並列な配列というのはN番目にあるデータはN番目のvertexに対応しており、それがattributeの数だけあるという意味です。図ではindex=4のattributeがハイライトされています。

-
- -

上の図のハイライトされたvertexには、このvertexに接する全ての面に異なるnormalが必要です。normalとはどの方向を向いているかの情報です。 -この図ではnormalは角の頂点の周りの矢印で示されており、その頂点に接する全ての面には異なる方向を指すnormalが必要です。

-

同様に面ごとに違うUVも必要です。 -UVはテクスチャのどの部分に頂点位置が対応しているか指定するテクスチャ座標です。 -緑の面はFテクスチャの右上に対応するUV、青い面は左上に対応するUV、赤の面は左下に対応したUVが必要な事が分かります。

-

単一のvertexはこれらの情報の合成として表現されます。 -頂点が異なる部分を必要とする場合、それは異なる頂点でなければなりません。

-

簡単な例としてBufferGeometryを使って立方体を作ってみましょう。立方体を例にするのはvertexがfaceによって共有されているように見えて実は共有されていないからです。この例ではまずすべてのvertexの情報をリストアップして並列の配列に変換してBufferAttributeを作り、最後にBufferGeometryを作ります。

-

立方体に必要な情報をすべてリストアップします。Geometryでは1つのvertexを複数のfaceで共有できましたが今回は共有できないことに注意してください。つまり1つの立方体を作るために36個のvertexが必要になります。1つの面につき2つの三角形、1つの三角形につき3つのvertex、これが6面あるので36個のvertexが必要になる計算です。

-
const vertices = [
-  // front
-  { pos: [-1, -1,  1], norm: [ 0,  0,  1], uv: [0, 0], },
-  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
-  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
-
-  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
-  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 0,  0,  1], uv: [1, 1], },
-  // right
-  { pos: [ 1, -1,  1], norm: [ 1,  0,  0], uv: [0, 0], },
-  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
-
-  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
-  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
-  { pos: [ 1,  1, -1], norm: [ 1,  0,  0], uv: [1, 1], },
-  // back
-  { pos: [ 1, -1, -1], norm: [ 0,  0, -1], uv: [0, 0], },
-  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
-  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
-
-  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
-  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
-  { pos: [-1,  1, -1], norm: [ 0,  0, -1], uv: [1, 1], },
-  // left
-  { pos: [-1, -1, -1], norm: [-1,  0,  0], uv: [0, 0], },
-  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
-  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
-
-  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
-  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
-  { pos: [-1,  1,  1], norm: [-1,  0,  0], uv: [1, 1], },
-  // top
-  { pos: [ 1,  1, -1], norm: [ 0,  1,  0], uv: [0, 0], },
-  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
-
-  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
-  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
-  { pos: [-1,  1,  1], norm: [ 0,  1,  0], uv: [1, 1], },
-  // bottom
-  { pos: [ 1, -1,  1], norm: [ 0, -1,  0], uv: [0, 0], },
-  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
-  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
-
-  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
-  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
-  { pos: [-1, -1, -1], norm: [ 0, -1,  0], uv: [1, 1], },
-];
-
-

次にこれを3つの並列な配列に変換します。 -(訳註:並列な配列parallel arraysとは例えば頂点を指定する配列と色を指定する配列があり1つの頂点をレンダリングするために2つの配列の同じインデックスの要素を指定するような使われ方をする配列のことです。次の例ではpositions, normals, uvsの3つの配列が並列の配列として使われています)

-
const positions = [];
-const normals = [];
-const uvs = [];
-for (const vertex of vertices) {
-  positions.push(...vertex.pos);
-  normals.push(...vertex.norm);
-  uvs.push(...vertex.uv);
-}
-
-

最後にそれぞれの配列に対してBufferAttributeを作りBufferGeometryに指定します。

-
  const geometry = new THREE.BufferGeometry();
-  const positionNumComponents = 3;
-  const normalNumComponents = 3;
-  const uvNumComponents = 2;
-  geometry.setAttribute(
-      'position',
-      new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
-  geometry.setAttribute(
-      'normal',
-      new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
-  geometry.setAttribute(
-      'uv',
-      new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
-
-

名前の付け方に注意してください。three.jsで決められている名前以外を指定することはできません(カスタムシェーダーを使用する場合は別です)。position, normal, uvはthree.jsで決められている名前です。ここでは指定していませんがcolorも指定可能です。

-

上の例ではpositions, normals, uvsの3つのJavaScriptのネイティブ配列を作りました。次にFloat32Array型のTypedArraysに変換します。BufferAttributeはネイティブ配列ではなくTypedArrayである必要があります。さらにそれぞれのBufferAttributeに対して「1つのvertexに対していくつの要素が必要か」を指定する必要があります。例えばpositionやnormalsは3次元なので1つのvertexつき3つの要素を必要とします。UVはテクスチャ上の2次元の点なので2つの要素を必要とします。

-

- -

-

かなり大量のデータです。この配列からvertexを選ぶときにはインデックスを使います。1つの三角形は3つのvertexで構成されていて2つの三角形が1つのfaceを作っています。これが6枚で1つの立方体を構成しています。1つのfaceを構成する2つの三角形を作っているvertexは2つが同じデータを持っています。position, normal, UVすべて同じです。そこで重複しているデータを1つ消して1つにして、そのデータを別のインデックスで指定します。

-

ではまず重複したデータを1つにします。

-
const vertices = [
-  // front
-  { pos: [-1, -1,  1], norm: [ 0,  0,  1], uv: [0, 0], }, // 0
-  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], }, // 1
-  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], }, // 2
--
--  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
--  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 0,  0,  1], uv: [1, 1], }, // 3
-  // right
-  { pos: [ 1, -1,  1], norm: [ 1,  0,  0], uv: [0, 0], }, // 4
-  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], }, // 5
--
--  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
--  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], }, // 6
-  { pos: [ 1,  1, -1], norm: [ 1,  0,  0], uv: [1, 1], }, // 7
-  // back
-  { pos: [ 1, -1, -1], norm: [ 0,  0, -1], uv: [0, 0], }, // 8
-  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], }, // 9
--
--  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
--  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
-  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], }, // 10
-  { pos: [-1,  1, -1], norm: [ 0,  0, -1], uv: [1, 1], }, // 11
-  // left
-  { pos: [-1, -1, -1], norm: [-1,  0,  0], uv: [0, 0], }, // 12
-  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], }, // 13
--
--  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
--  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
-  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], }, // 14
-  { pos: [-1,  1,  1], norm: [-1,  0,  0], uv: [1, 1], }, // 15
-  // top
-  { pos: [ 1,  1, -1], norm: [ 0,  1,  0], uv: [0, 0], }, // 16
-  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], }, // 17
--
--  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
--  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], }, // 18
-  { pos: [-1,  1,  1], norm: [ 0,  1,  0], uv: [1, 1], }, // 19
-  // bottom
-  { pos: [ 1, -1,  1], norm: [ 0, -1,  0], uv: [0, 0], }, // 20
-  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], }, // 21
--
--  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
--  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
-  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], }, // 22
-  { pos: [-1, -1, -1], norm: [ 0, -1,  0], uv: [1, 1], }, // 23
-];
-
-

はい、24個になりました。これに対して36個のインデックスを指定して36個のvertexを作ります。BufferGeometry.setIndexにより36個のインデックスを使って12個の三角形を作ります。

-
geometry.setAttribute(
-    'position',
-    new THREE.BufferAttribute(positions, positionNumComponents));
-geometry.setAttribute(
-    'normal',
-    new THREE.BufferAttribute(normals, normalNumComponents));
-geometry.setAttribute(
-    'uv',
-    new THREE.BufferAttribute(uvs, uvNumComponents));
-
-+geometry.setIndex([
-+   0,  1,  2,   2,  1,  3,  // front
-+   4,  5,  6,   6,  5,  7,  // right
-+   8,  9, 10,  10,  9, 11,  // back
-+  12, 13, 14,  14, 13, 15,  // left
-+  16, 17, 18,  18, 17, 19,  // top
-+  20, 21, 22,  22, 21, 23,  // bottom
-+]);
-
-

- -

-

Geometryと同じようにBufferGeometrycomputeVertexNormalsメソッドを持っています。これは特に指定がない場合に自動的にnormalを計算するメソッドです。ただしGeometryの場合と違いvertexがfaceによって共有されていないためにcomputeVertexNormalsの結果も少し違います。

-
-
-
-
-
- -

シリンダーでcomputeVertexNormalsの違いを比較してみましょう。よく見ると左のシリンダーには縫い目が見えると思います。これはvertexを共有することができないためにUVも異なるためです。ちょっとしたことですが、気になるときは自分でnormalを指定すれば良いだけです。

-

ネイティブの配列を使う代わりにTypedArraysを使うこともできます。TypedArrayは最初に配列の大きさを指定する必要があるため少し面倒です。ネイティブの配列はpushで追加してlengthで配列の長さを確認することができます。TypedArrayにはpushメソッドがないのであらかじめ用意した配列に注意しながら要素を入れていく必要があります。

-

この例では最初に大きなデータを使っているので配列の長さを意識することはそれほど大変ではありません。

-
-const positions = [];
--const normals = [];
--const uvs = [];
-+const numVertices = vertices.length;
-+const positionNumComponents = 3;
-+const normalNumComponents = 3;
-+const uvNumComponents = 2;
-+const positions = new Float32Array(numVertices * positionNumComponents);
-+const normals = new Float32Array(numVertices * normalNumComponents);
-+const uvs = new Float32Array(numVertices * uvNumComponents);
-+let posNdx = 0;
-+let nrmNdx = 0;
-+let uvNdx = 0;
-for (const vertex of vertices) {
--  positions.push(...vertex.pos);
--  normals.push(...vertex.norm);
--  uvs.push(...vertex.uv);
-+  positions.set(vertex.pos, posNdx);
-+  normals.set(vertex.norm, nrmNdx);
-+  uvs.set(vertex.uv, uvNdx);
-+  posNdx += positionNumComponents;
-+  nrmNdx += normalNumComponents;
-+  uvNdx += uvNumComponents;
-}
-
-geometry.setAttribute(
-    'position',
--    new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
-+    new THREE.BufferAttribute(positions, positionNumComponents));
-geometry.setAttribute(
-    'normal',
--    new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
-+    new THREE.BufferAttribute(normals, normalNumComponents));
-geometry.setAttribute(
-    'uv',
--    new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
-+    new THREE.BufferAttribute(uvs, uvNumComponents));
-
-geometry.setIndex([
-   0,  1,  2,   2,  1,  3,  // front
-   4,  5,  6,   6,  5,  7,  // right
-   8,  9, 10,  10,  9, 11,  // back
-  12, 13, 14,  14, 13, 15,  // left
-  16, 17, 18,  18, 17, 19,  // top
-  20, 21, 22,  22, 21, 23,  // bottom
-]);
-
-

- -

-

TypedArrayはプログラムが走っている状態でvertexの編集をしたいときに便利です。

-

良い例が思いつかないのでとりあえずメッシュの四角形が出たり入ったりする球体を作ってみます。

-

球体の位置とindexを生成するコードです。四角形の中でvertexを共有していますが四角形と四角形でvertexを共有することはありません。共有してしまうと1つの四角形が出たり入ったりするたびに隣の四角形が移動してしまいます。今回は別々に移動させたいのでそうしています。

-

面倒なので3つのObject3D階層を用意して球体のvertexを計算します。くわしくはたくさんのオブジェクトを最適化するこの記事をご覧ください。

-
function makeSpherePositions(segmentsAround, segmentsDown) {
-  const numVertices = segmentsAround * segmentsDown * 6;
-  const numComponents = 3;
-  const positions = new Float32Array(numVertices * numComponents);
-  const indices = [];
-
-  const longHelper = new THREE.Object3D();
-  const latHelper = new THREE.Object3D();
-  const pointHelper = new THREE.Object3D();
-  longHelper.add(latHelper);
-  latHelper.add(pointHelper);
-  pointHelper.position.z = 1;
-  const temp = new THREE.Vector3();
-
-  function getPoint(lat, long) {
-    latHelper.rotation.x = lat;
-    longHelper.rotation.y = long;
-    longHelper.updateMatrixWorld(true);
-    return pointHelper.getWorldPosition(temp).toArray();
-  }
-
-  let posNdx = 0;
-  let ndx = 0;
-  for (let down = 0; down < segmentsDown; ++down) {
-    const v0 = down / segmentsDown;
-    const v1 = (down + 1) / segmentsDown;
-    const lat0 = (v0 - 0.5) * Math.PI;
-    const lat1 = (v1 - 0.5) * Math.PI;
-
-    for (let across = 0; across < segmentsAround; ++across) {
-      const u0 = across / segmentsAround;
-      const u1 = (across + 1) / segmentsAround;
-      const long0 = u0 * Math.PI * 2;
-      const long1 = u1 * Math.PI * 2;
-
-      positions.set(getPoint(lat0, long0), posNdx);  posNdx += numComponents;
-      positions.set(getPoint(lat1, long0), posNdx);  posNdx += numComponents;
-      positions.set(getPoint(lat0, long1), posNdx);  posNdx += numComponents;
-      positions.set(getPoint(lat1, long1), posNdx);  posNdx += numComponents;
-
-      indices.push(
-        ndx, ndx + 1, ndx + 2,
-        ndx + 2, ndx + 1, ndx + 3,
-      );
-      ndx += 4;
-    }
-  }
-  return {positions, indices};
-}
-
-

こんな感じです。

-
const segmentsAround = 24;
-const segmentsDown = 16;
-const {positions, indices} = makeSpherePositions(segmentsAround, segmentsDown);
-
-

returnされているpositionは単位球(半径が1の球体)なのでそのままこのデータをnormalに使えます。

-
const normals = positions.slice();
-
-

attributeも設定しましょう。

-
const geometry = new THREE.BufferGeometry();
-const positionNumComponents = 3;
-const normalNumComponents = 3;
-
-+const positionAttribute = new THREE.BufferAttribute(positions, positionNumComponents);
-+positionAttribute.setUsage(THREE.DynamicDrawUsage);
-geometry.setAttribute(
-    'position',
-+    positionAttribute);
-geometry.setAttribute(
-    'normal',
-    new THREE.BufferAttribute(normals, normalNumComponents));
-geometry.setIndex(indices);
-
-

position attributeに対する参照を保存しています。dynamicに指定しているところも注意が必要です。これはTHREE.jsに「これからこのattributeは変更が加えられる」ことを教えます。renderループではpositionを毎度アップデートします。

-
const temp = new THREE.Vector3();
-
-...
-
-for (let i = 0; i < positions.length; i += 3) {
-  const quad = (i / 12 | 0);
-  const ringId = quad / segmentsAround | 0;
-  const ringQuadId = quad % segmentsAround;
-  const ringU = ringQuadId / segmentsAround;
-  const angle = ringU * Math.PI * 2;
-  temp.fromArray(normals, i);
-  temp.multiplyScalar(THREE.MathUtils.lerp(1, 1.4, Math.sin(time + ringId + angle) * .5 + .5));
-  temp.toArray(positions, i);
-}
-positionAttribute.needsUpdate = true;
-
-

最後にpositionAttribute.needsUpdateを設定してTHREE.jsに変更が必要であることを伝えます。

-

- -

-

BufferGeometryを作ってBufferAttributeをアップデートする方法を紹介しました。

-

- - - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/debugging-glsl.html b/manual/ja/debugging-glsl.html deleted file mode 100644 index c06028299876c4..00000000000000 --- a/manual/ja/debugging-glsl.html +++ /dev/null @@ -1,94 +0,0 @@ - - - でのGLSLデバッグ - - - - - - - - - - - - - -
-
-

でのGLSLデバッグ

-
-
-
-

このサイトではJavaScriptについては教えてないと同様にGLSLについても教えていません。 -それは本当に大きなトピックです。GLSLを学びたいのであればこれらの記事から始めてみて下さい。

-

すでにGLSLを知っている場合はデバッグのヒントをいくつか紹介します。

-

新しくGLSLシェーダーを作成し何も表示されない場合、一般的に最初にするのはフラグメントシェーダーでソリッドカラーに変更する事です。例えばシェーダーの一番下を以下のようにします。

-
void main() {
-
-  ...
-
-  gl_FragColor = vec4(1, 0, 0, 1);  // red
-}
-
-

描画しようとしたオブジェクトが表示されれば、その問題がフラグメントシェーダーに関係している事がわかります。良くないテクスチャ、初期化がないuniform変数、間違った値を持つuniform変数の可能性がありますが、私には見るべきポイントがあります。

-

いくつかをテストするために入力値を描画してみようと思います。 -例えばフラグメントシェーダーで法線を使用している場合は次のように追加します。

-
gl_FragColor = vec4(vNormal * 0.5 + 0.5, 1);
-
-

法線は-1から+1までなので、0.5を掛けて0.5を足すと0.0から1.0までの値が得られて色で判別できて便利です。

-

動作すると知ってるコード変更をいくつか試してみて下さい。正常な通常の描画がどのように見えるか目に見えてわかるようになります。 -通常に見えない箇所があれば、どこを見て直せば良いかのヒントがあります。 -フラグメントシェーダーで法線を操作している場合、その操作の結果を描画するために同じテクニックが使えます。

-
- -

テクスチャを使用している場合も同様に、テクスチャ座標で以下のように描画できます。

-
gl_FragColor = vec4(fract(vUv), 0, 1);
-
-

fract は0から1の範囲外のテクスチャ座標を使用している場合に使用します。 -これは texture.repeat が1よりも大きい値に設定されている場合によく見られます。

-
- -

フラグメントシェーダーの全ての値に対して同様の事ができます。 -その範囲を把握し、0.0から1.0にスケーリングし gl_FragColor に設定するコードを追加します。

-

テクスチャを確認するには CanvasTextureDataTexture が動作するもので試してみて下さい。

-

逆に言えば gl_FragColor を赤に設定しても何も表示されない場合、問題は頂点シェーダーに関連したものにあるかもしれません。 -一部の行列が間違っていたり、attributesが間違ったデータを持っていたり、設定が間違っていたりする事があります。

-

私ならまず行列を見ます。renderer.renderer.render(scene, camera) の呼出直後にブレークポイントを設定し、インスペクタで展開を始めるかもしれません。 -カメラのワールド座標とプロジェクション座標は NaN でいっぱいではないのか? -シーンを展開し children を見てみるとワールド座標が妥当に見えるか( NaN がない)、各行列の最後の4つの値がシーンにとって妥当に見えるかを確認します。 -シーンが50 x 50 x 50 x 50の単位であるはずが、いくつかの行列が552352623.123の場合は明らかに何かが間違っている事がわかります。

-
- -

フラグメントシェーダーの時と同様に、頂点シェーダーの値をフラグメントシェーダーに渡すと頂点シェーダーから値を描画する事もできます。 -両方のシェーダーでvaryingを宣言し、正しい値なのかわからない値を渡します。 -実際にシェーダーが法線を使っている場合、値が0.0から1.0になるように vNormal を値に設定し法線を表示するようにします。 -描画結果を見て予想通りか確認します。

-

もう一つの良い点はよりシンプルなシェーダーを使う事です。 -MeshBasicMaterial でデータを描画できますか? -可能であれば試してみて期待通りに表示される事を確認して下さい。

-

期待通りでない場合、ジオメトリをビジュアライズできる最もシンプルな頂点シェーダーは何でしょうか? -通常は次のようなシンプルなものです。

-
gl_Position = projection * modelView * vec4(position.xyz, 1);
-
-

それが動作する場合は一度に少しずつ変更を追加していきます。

-

もう一つの方法はShader Editor extension for Chromeを使うか、他のブラウザでは同様なものを使う事です。 -他のシェーダーがどのように動作してるか見る良い方法です。また、上記で記載してきたフラグメントシェーダーの変更を行うとライブで結果確認できるので良いです。

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/debugging-javascript.html b/manual/ja/debugging-javascript.html deleted file mode 100644 index 23394930d3f80b..00000000000000 --- a/manual/ja/debugging-javascript.html +++ /dev/null @@ -1,396 +0,0 @@ - - - でのJavaScriptデバッグ - - - - - - - - - - - - - -
-
-

でのJavaScriptデバッグ

-
-
-
-

この記事のほとんどはTHREE.jsのデバッグと言うより、一般的なJavaScriptのデバッグの内容です。THREE.js初心者にはJavaScript初心者も多いので、この記事を読んで困った時に簡単に解決できるようになると良いと思います。

-

デバッグは大きなトピックであり、この記事で全てをカバーできませんが、JavaScriptに慣れていない場合はいくつかのヒントを得られると思います。デバッグに関しては、時間をかけて学ぶ事を強くお勧めします。デバッグはあなたの学習を大いに助けてくれます。

-

ブラウザの開発者ツールを学ぶ

-

全てのブラウザには開発者ツールがあります。 -Chrome, -Firefox, -Safari, -Edge.

-

Chromeでは アイコンをクリックし、その他のツール -> デベロッパーツールを選択すると開発者ツールが表示されます。そこにはキーボードのショートカットも表示されています。

-
- -

Firefoxでは アイコンをクリックし、"ウェブ開発"から"開発者ツール"を選択します。

-
- -

Safariでは詳細設定メニューから開発メニューを有効にする必要があります。

-
- -

次に開発メニューで"Webインスペクタの表示/接続"を選択します。

-
- -

Chromeを使ってAndroidやタブレットでChrome上で実行されているウェブページをデバッグする事もできます。 -同様にSafariではiPhoneやiPadでSafari上で実行されているウェブページをPCでデバッグする事ができます

-

私はChromeを一番よく知ってるのでChromeを例にしますが、ほとんどのブラウザは似たような機能を持っているため、全てのブラウザで簡単に同じ機能を適用できるはずです。

-

キャッシュをオフにする

-

ブラウザはダウンロードしたデータを再利用します。これはウェブサイトを2回目に訪れた際、サイトを表示するために必要な多くのファイルは再びダウンロードされず、ユーザーにとって素晴らしい事です。

-

一方でこれはウェブ開発に悪い影響を与える可能性があります。PC上でファイルを変更しリロードしても、前回ダウンロードしたバージョンを使用しているため変更内容が表示されません。

-

ウェブ開発中の解決策の1つは、キャッシュをオフにする事です。これによりブラウザは常に最新バージョンのファイルを取得する事ができます。

-

最初にデベロッパーツールのSettingsメニューを選択します。

-
- -

次に "Disable Cache (while DevTools is open)" を選択します。

-
- -

JavaScriptコンソールを使用する

-

全てのdevtoolsの中には console があります。ここには警告やエラーメッセージが表示されます。

-

メッセージを読みましょう!!

-

一般的にはメッセージは1つか2つしかありません。

-
- -

もし他のメッセージがあればメッセージを読みましょう。例えば

-
- -

"three"を"threee"とスペルミスしました。

-

以下のように console.log であなた自身がconsoleに情報を表示する事もできます。

-
console.log(someObject.position.x, someObject.position.y, someObject.position.z);
-
-

さらにクールな事にオブジェクトのログを記録したり検査する事ができます。例えばgLTFの記事からルートシーンのオブジェクトをログに表示できます。

-
  {
-    const gltfLoader = new GLTFLoader();
-    gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
-      const root = gltf.scene;
-      scene.add(root);
-+      console.log(root);
-
-

そしてそのオブジェクトをJavaScriptコンソールで展開できます。

-
- -

スタックトレースを含む赤色メッセージを表示する場合は console.error を使う事ができます。

-

データを画面に表示させる

-

もう1つの分かりやすい方法は <div><pre> を追加しデータを入れる事です。

-

最も分かりやすい方法はいくつかのHTML要素を作成する事です。

-
<canvas id="c"></canvas>
-+<div id="debug">
-+  <div>x:<span id="x"></span></div>
-+  <div>y:<span id="y"></span></div>
-+  <div>z:<span id="z"></span></div>
-+</div>
-
-

キャンバスの上に残るようにスタイルを整えます(キャンバスがページを埋めていると仮定します)。

-
<style>
-#debug {
-  position: absolute;
-  left: 1em;
-  top: 1em;
-  padding: 1em;
-  background: rgba(0, 0, 0, 0.8);
-  color: white;
-  font-family: monospace;
-}
-</style>
-
-

そして要素を探して内容を設定します。

-
// at init time
-const xElem = document.querySelector('#x');
-const yElem = document.querySelector('#y');
-const zElem = document.querySelector('#z');
-
-// at render or update time
-xElem.textContent = someObject.position.x.toFixed(3);
-yElem.textContent = someObject.position.y.toFixed(3);
-zElem.textContent = someObject.position.z.toFixed(3);
-
-

これはリアルタイムな値を見る時はとても便利です。

-

- -

-

または画面にデータを貼り付けるのにクリアロガーを作成する方法もあります。私はその言葉を作っただけですが、私が手がけたゲームの多くはこの解決法を使っています。 -このアイデアは1フレーム分だけメッセージを表示するバッファを持つ事です。 -データを表示したいコードのどの部分でも、フレームごとにバッファにデータを追加する関数を呼び出します。これは上記のデータのピースごとに要素を作成するよりもはるかに少ない作業です。

-

例えば上記のHTMLを以下のように変更してみましょう。

-
<canvas id="c"></canvas>
-<div id="debug">
-  <pre></pre>
-</div>
-
-

このクリアバックバッファを管理するための簡単なクラスを作ってみましょう。

-
class ClearingLogger {
-  constructor(elem) {
-    this.elem = elem;
-    this.lines = [];
-  }
-  log(...args) {
-    this.lines.push([...args].join(' '));
-  }
-  render() {
-    this.elem.textContent = this.lines.join('\n');
-    this.lines = [];
-  }
-}
-
-

次にマウスをクリックするたびに2秒間のランダムな方向に移動するメッシュを作成する簡単な例を作ってみましょう。レスポンシブデザインの記事から例を紹介します。

-

マウスをクリックするたびに新しい Mesh を追加するコードは以下の通りです。

-
const geometry = new THREE.SphereGeometry();
-const material = new THREE.MeshBasicMaterial({color: 'red'});
-
-const things = [];
-
-function rand(min, max) {
-  if (max === undefined) {
-    max = min;
-    min = 0;
-  }
-  return Math.random() * (max - min) + min;
-}
-
-function createThing() {
-  const mesh = new THREE.Mesh(geometry, material);
-  scene.add(mesh);
-  things.push({
-    mesh,
-    timer: 2,
-    velocity: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5)),
-  });
-}
-
-canvas.addEventListener('click', createThing);
-
-

このコードは作成したメッシュを移動させログに記録し、タイマーが切れたら削除します。

-
const logger = new ClearingLogger(document.querySelector('#debug pre'));
-
-let then = 0;
-function render(now) {
-  now *= 0.001;  // convert to seconds
-  const deltaTime = now - then;
-  then = now;
-
-  ...
-
-  logger.log('fps:', (1 / deltaTime).toFixed(1));
-  logger.log('num things:', things.length);
-  for (let i = 0; i < things.length;) {
-    const thing = things[i];
-    const mesh = thing.mesh;
-    const pos = mesh.position;
-    logger.log(
-        'timer:', thing.timer.toFixed(3),
-        'pos:', pos.x.toFixed(3), pos.y.toFixed(3), pos.z.toFixed(3));
-    thing.timer -= deltaTime;
-    if (thing.timer <= 0) {
-      // remove this thing. Note we don't advance `i`
-      things.splice(i, 1);
-      scene.remove(mesh);
-    } else {
-      mesh.position.addScaledVector(thing.velocity, deltaTime);
-      ++i;
-    }
-  }
-
-  renderer.render(scene, camera);
-  logger.render();
-
-  requestAnimationFrame(render);
-}
-
-

以下のサンプルでマウスをクリックして下さい。

-

- -

-

クエリパラメーター

-

もう1つ覚えておきたいのは、ウェブページにはクエリパラメーターやアンカーを介してデータを渡す事ができます。検索とハッシュと呼ばれる事があります。

-
https://domain/path/?query#anchor
-

これを使用しオプション機能やパラメーターを渡す事ができます。

-

先ほどの例では次のようにしています。デバッグ機能はURLに ?debug=true を指定した場合にのみ表示されます。

-

まず、クエリストリングを解析するコードが必要です。

-
/**
-  * Returns the query parameters as a key/value object.
-  * Example: If the query parameters are
-  *
-  *    abc=123&def=456&name=gman
-  *
-  * Then `getQuery()` will return an object like
-  *
-  *    {
-  *      abc: '123',
-  *      def: '456',
-  *      name: 'gman',
-  *    }
-  */
-function getQuery() {
-  return Object.fromEntries(new URLSearchParams(window.location.search).entries());
-}
-
-

そうすると、debug要素をデフォルトでは表示しないようにする事ができるかもしれません。

-
<canvas id="c"></canvas>
-+<div id="debug" style="display: none;">
-  <pre></pre>
-</div>
-
-

このコードをみると ?debug=true が渡された場合のみデバッグ情報を表示するのが分かります。

-
const query = getQuery();
-const debug = query.debug === 'true';
-const logger = debug
-   ? new ClearingLogger(document.querySelector('#debug pre'))
-   : new DummyLogger();
-if (debug) {
-  document.querySelector('#debug').style.display = '';
-}
-
-

?debug=true の場合は何も渡さないように DummyLogger を作りました。

-
class DummyLogger {
-  log() {}
-  render() {}
-}
-
-

以下のURLを使用して確認する事ができます。

-

debug-js-params.html

-

上記にはデバッグ情報はありません。

-

debug-js-params.html?debug=true

-

こちらにはデバッグ情報があります。

-

複数のパラメーターは somepage.html?someparam=somevalue&someotherparam=someothervalue のように'&'で区切る事で渡せます。 -このようなパラメータを使用するとあらゆる種類のオプションを渡す事ができます。 -speed=0.01 のようにアプリの速度を遅くしてわかりやすくしたり、showHelpers=true のように他のレッスンで見られる照明や影、カメラの錐台を表示するヘルパーを追加してもいいかもしれません。

-

デバッガの使い方を学ぶ

-

どのブラウザにもデバッガがあり、プログラムを1行ごとに一時停止し全ての変数を検査する事ができます。

-

デバッガの使い方を教えるのはあまりにも大きなトピックなので、ここではいくつかのリンクを紹介します。

- -

デバッガなどで NaN がないかチェックする

-

NaN は Not A Numberの略です。これは数学的に意味のない事をした場合、JavaScript が値として代入するものです。

-

簡単な例としては

-
- -

何か開発中に画面に何も表示されない事がよくあるので、私は NaN が表示されたらその場所からすぐにいくつかの値を確認します。

-

例として最初にgLTFファイルの読込の記事でパスを作り始めた時に2次元曲線を作るSplineCurveクラスを使って曲線を作ってみました。

-

そのカーブを利用してこのように車を動かしました。

-
curve.getPointAt(zeroToOnePointOnCurve, car.position);
-
-

内部的には curve.getPointAt は第2引数に渡されたオブジェクトに対して set 関数を呼び出します。この場合、第2引数は car.position であり、これは Vector3 です。Vector3set 関数はx, y, zの3つの引数を必要としますが、SplineCurve は2次元曲線なので、xとyだけを指定して car.position.set を呼び出します。

-

その結果、car.position.set はxにx、yにy、zに undefined をセットします。

-

デバッガで matrixWorld を見てみると NaN 値が表示されています。

-
- -

行列を見ると NaN が含まれており、positionrotationscale または他の関数に悪い影響を与えるデータがあるのが見えます。これらの悪いデータから逆算すると問題を追跡するのは簡単です。

-

NaN の上には Infinity もありますが、これはどこかに数学のバグがあるような気がします。

-

コードの中を見て!

-

THREE.jsはオープンソースです。コードの中を見る事を恐れないで下さい! -githubで内部コードを見れます。 -また、デバッガの関数を踏み込んで内部を見る事もできます。その際には three.min.js でなく three.js を見るようにして下さい。three.min.js は最小化・圧縮されたバージョンなので、ダウンロードする際のサイズが小さくなっています。three.js はサイズは大きいですが、デバッグしやすいバージョンです。私はよく three.js に切り替えて、コードのステップスルーを行い、何が起こっているのかを確認しています。

-

requestAnimationFrame はrender関数の一番下へ

-

以下のパターンはよく見かけます。

-
function render() {
-   requestAnimationFrame(render);
-
-   // -- do stuff --
-
-   renderer.render(scene, camera);
-}
-requestAnimationFrame(render);
-
-

以下のように requestAnimationFrame を一番下に置く事をお勧めします。

-
function render() {
-   // -- do stuff --
-
-   renderer.render(scene, camera);
-
-   requestAnimationFrame(render);
-}
-requestAnimationFrame(render);
-
-

最大の理由はエラーが発生した場合にコードが停止する事です。 -requestAnimationFrame を先頭に置くと、既に別のフレームを要求しているためにエラーが発生してもコードを実行し続けます。 -IMOを無視するよりも、それらのエラーを見つける方が良いでしょう。これらのエラーは何かが期待したように表示されない原因になりやすいのですが、コードが停止しない限り、気がつかないかもしれません。

-

単位をチェックして下さい!

-

角度やラジアンを使う時の例を知っておく必要があります。 -残念ながらTHREE.jsではどこでも同じ単位を使用している訳ではありません。 -すぐに思いつくのだとカメラの視野は度単位です。それ以外の角度は全てラジアン単位です。

-

もう1つ注目したいのは、世界単位のサイズです。最近の3Dアプリでは好きな単位を選べるようになっています。あるアプリでは1単位=1cmを選択する事があります。もう1つのアプリでは1台=1フィートを選ぶかもしれません。特定のアプリケーションでは必要なユニットを選択する事ができます。three.jsでは1単位=1メートルを想定しています。 -これは測定器を使用して照明効果を計算する物理ベースのレンダリングなどで重要です。 -スマホがどこにあるか、VRコントローラーがどこにあるかなど、現実世界の単位を扱う必要があるARやVRにとっても重要です。

-

スタックオーバーフローのための 最小で完全で検証可能なサンプルコード の作成

-

THREE.jsの質問をする場合、MCVE(Minimal<最小>、Complete<完全>、Verifiable<検証可能>、Example<サンプル>の略)のコードを提供する事が求められます。

-

最小の部分が重要です。gLTF読込の記事の最後のサンプルコードでパスの動きに問題があったとしましょう。そのサンプルには多くのパーツがあり、リストアップすると

-
    -
  1. HTMLの集まり
  2. -
  3. いくつかのCSS
  4. -
  5. ライティング
  6. -
  7. -
  8. 影を操作するためのlil-guiコード
  9. -
  10. GLTFファイルの読込コード
  11. -
  12. キャンバスのリサイズコード
  13. -
  14. パスに沿って車を移動させるコード
  15. -
-

このコードはかなり大きいです。もし質問がパスの後に続く部分だけであれば、THREE.jsの <canvas><script> タグだけで済むので、ほとんどのHTMLを削除する事ができます。また、CSSとリサイズのコードを削除する事ができます。GLTFのコードもパスだけを気にしているので削除できます。MeshBasicMaterial を使用するとライトとシャドウも削除する事ができます。lil-guiのコードも確実に削除できます。 -このコードはテクスチャ付きの地面を作ります。GridHelper を使った方が簡単です。 -最終的にもし質問したい事がパス上での移動についてなら、ロードされた車モデルの代わりにパス上にキューブを使用する事ができます。

-

以上の事を考慮したミニマムなサンプルコードを紹介します。271行から135行に縮小しました。パスを単純化する事でさらに縮小する事も考られます。3,4点のパスは、21点のパスと同じように動作するかもしれません。

-

- -

-

OrbitController を残してるのはカメラを動かして何が起こっているのかを把握するのに便利だからですが、問題によってはこれも削除できるかもしれません。

-

MCVEを作る上で一番良い点は、自分自身で解決する事が多いという事です。不要なものを取り除いて可能な限り小さなサンプルコードを作って問題を再現する事で、バグにたどり着く事が多いからです。

-

その上でStack Overflowで自分のコードを見てもらうのは、回答者の時間を尊重する事になります。最小限のサンプルを作る事で、誰かがあなたを助ける事がはるかに簡単になります。また、その過程で以下を学ぶ事ができます。

-

Stack Overflowに質問を投稿する際、コードをスニペットにする事が重要です。 -もちろん、MCVEを試すためにJSFiddleやCodepen、または同様のサイトを使用する事は歓迎しますが、実際にStack Overflowに質問を投稿するようになったら、質問自体に問題を再現するためのコードを記述する必要があります。 -スニペットを作る事でその条件を満たしています。

-

また、このサイト上の全てのライブサンプルはスニペットとして実行されるべきである事に注意して下さい。HTML、CSS、JavaScriptの部分をスニペットエディタのそれぞれの部分にコピーするだけです。ただし、自分の問題に関係のない部分を削除し、必要最低限のコードにするのを忘れないようにして下さい。

-

これらに従えば、あなたの問題は助けを得る可能性がはるかに高くなります。

-

MeshBasicMaterial を使用する

-

MeshBasicMaterial はライトを使用しないので、何かが表示されない理由を取り除く1つの方法です。もしオブジェクトが MeshBasicMaterial を使用して表示されない場合は、コードの他の部分ではなくマテリアルやライトに問題がある可能性が高い事がわかります。

-

カメラの nearfar の設定を確認する

-

PerspectiveCamera には nearfar の設定があり、それはカメラの記事 で説明しています。 -オブジェクトを含む空間に合わせて設定されている事を確認して下さい。 -例えば near = 0.001、far = 1000000のような大きな値に一時的に設定する事もできます。 -奥行き解像度の問題が発生する可能性がありますが、少なくともカメラの前にあるオブジェクトを見る事ができるようになります。

-

カメラの前にシーンがある事を確認する

-

時にはカメラの前になく何も出てこない事もあります。カメラを制御できない場合は OrbitController のようなカメラコントロールを追加してみて下さい。 -あるいはこの記事で紹介されているコードを使ってシーンをフレーミングしてみて下さい。 -このコードはシーンの一部のサイズを見つけ、カメラを移動して nearfar の設定を調整し、それが見えるようにします。

-

カメラの前に何かを置く

-

これは全てに失敗した場合は、動作するものから始めてゆっくりと何かを追加していくという方法です。何もない画面が表示された場合は、直接カメラの前に何かを置いてみて下さい。 -球体や箱を作り MeshBasicMaterialのようなシンプルなマテリアルを与えて、それを画面上に表示できるようにします。 -その後、少しずつ追加してテストを開始します。最終的にはバグを再現するか、途中で発見するかのどちらかになります。

-
-

以上、JavaScriptのデバッグのヒントでした。GLSLをデバッグするためのいくつかのヒントも見てみましょう。

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/fog.html b/manual/ja/fog.html deleted file mode 100644 index 2d9180f59d37d6..00000000000000 --- a/manual/ja/fog.html +++ /dev/null @@ -1,265 +0,0 @@ - - - のフォグ - - - - - - - - - - - - - -
-
-

のフォグ

-
-
-
-

この記事はThree.jsの連載記事の1つです。 -最初の記事はThree.jsの基礎知識です。 -まだ読んでいない場合、そこから始めると良いかもしれません。 -カメラの記事を読んでない方は、まずはこの記事を読んでみて下さい。

-

一般的には3Dエンジンでのフォグ(霧)は、カメラからの距離によって特定の色にフェードアウトする方法です。 -three.jsでは Fog または FogExp2 オブジェクトを作成し、シーンにfog プロパティを設定してフォグを追加します。

-

Fog はカメラからの距離を表す nearfar があります。 -near よりも近いものはフォグの影響を受けません。 -far より遠いものはフォグの影響を受けます。 -nearfar の中間部分では、マテリアルの色からフォグの色にグラデーションします。

-

また、カメラからの距離で急激にグラデーションする FogExp2 もあります。

-

どちらのタイプのフォグも使用するにはフォグを作成してシーンに割り当てます。

-
const scene = new THREE.Scene();
-{
-  const color = 0xFFFFFF;  // white
-  const near = 10;
-  const far = 100;
-  scene.fog = new THREE.Fog(color, near, far);
-}
-
-

FogExp2 の場合は次のようなコードになります。

-
const scene = new THREE.Scene();
-{
-  const color = 0xFFFFFF;
-  const density = 0.1;
-  scene.fog = new THREE.FogExp2(color, density);
-}
-
-

FogExp2 は現実表現に近いですが、Fog の方が一般的によく使われています。 -Fogは適用する場所を選択できるので、ある距離まではクリアなシーンを表示し、その距離を過ぎるとフェードアウトした色にできます。

-
-
-
-
THREE.Fog
-
-
-
-
THREE.FogExp2
-
-
- -

ここで注意すべき点は、フォグは レンダリングされる ものに適用される事です。 -以下はオブジェクトの色の各ピクセルの計算の一部です。 -つまり、シーンを特定の色にフェードさせたい場合、フォグ 背景色を同じ色に設定します。 -背景色は scene.background プロパティで設定します。 -背景色は THREE.Color で指定します。例えば

-
scene.background = new THREE.Color('#F00');  // red
-
-
-
-
-
fog blue, background red
-
-
-
-
fog blue, background blue
-
-
- -

以下はフォグを追加した例です。 -シーンにフォグを追加し背景色を設定します。

-
const scene = new THREE.Scene();
-
-+{
-+  const near = 1;
-+  const far = 2;
-+  const color = 'lightblue';
-+  scene.fog = new THREE.Fog(color, near, far);
-+  scene.background = new THREE.Color(color);
-+}
-
-

以下の例ではカメラの near は 0.1、far は 5です。 -カメラは z = 2 にあります。 -立方体は1の大きさで z = 0 です。 -つまり、フォグを near = 1far = 2 と設定し、立方体の中心付近でフェードアウトしています。

-

- -

-

フォグを調整するインターフェースを追加してみましょう。 -ここでもlil-guiを使用します。 -lil-guiはオブジェクトとプロパティを受け取り、インタフェースを自動生成します。 -これでフォグの nearfar プロパティを簡単に操作できます。 -しかし、 nearfar より大きい場合は無効になります。 -lil-guiで nearfar を操作するヘルパーを作ってみましょう。

-
// We use this class to pass to lil-gui
-// so when it manipulates near or far
-// near is never > far and far is never < near
-class FogGUIHelper {
-  constructor(fog) {
-    this.fog = fog;
-  }
-  get near() {
-    return this.fog.near;
-  }
-  set near(v) {
-    this.fog.near = v;
-    this.fog.far = Math.max(this.fog.far, v);
-  }
-  get far() {
-    return this.fog.far;
-  }
-  set far(v) {
-    this.fog.far = v;
-    this.fog.near = Math.min(this.fog.near, v);
-  }
-}
-
-

次のコードを追加します。

-
{
-  const near = 1;
-  const far = 2;
-  const color = 'lightblue';
-  scene.fog = new THREE.Fog(color, near, far);
-  scene.background = new THREE.Color(color);
-+
-+  const fogGUIHelper = new FogGUIHelper(scene.fog);
-+  gui.add(fogGUIHelper, 'near', near, far).listen();
-+  gui.add(fogGUIHelper, 'far', near, far).listen();
-}
-
-

nearfar のパラメーターは、フォグを調整する最小値と最大値を設定します。 -これはカメラ設定時にセットします。

-

最後の2行の .listen() をlil-guiに変更し listen するようにします。 -これで nearfar の変更時、lil-guiが他のプロパティのUIを更新してくれます。

-

フォグの色を変更できますが、上記で述べたようにフォグの色と背景色を同期させる必要があります。 -lil-gui操作時に両方の色を変更する virtual プロパティをヘルパーに追加してみましょう。

-

lil-guiでは4つの方法で色を操作できます。

-
    -
  1. CSSの6桁の16進数(例: #112233)

    -
  2. -
  3. 色相、彩度、値、オブジェクト (例: {h: 60, s: 1, v: })

    -
  4. -
  5. RGB (例: [255, 128, 64])

    -
  6. -
  7. RGBA(例:[127, 200, 75, 0.3]

    -
  8. -
-

lil-guiが単一の値を操作するので、16進数を使うのが一番簡単です。 -幸運な事に THREE.ColorgetHexString を使用でき、文字列を簡単に取得できます。

-
// We use this class to pass to lil-gui
-// so when it manipulates near or far
-// near is never > far and far is never < near
-+// Also when lil-gui manipulates color we'll
-+// update both the fog and background colors.
-class FogGUIHelper {
-*  constructor(fog, backgroundColor) {
-    this.fog = fog;
-+    this.backgroundColor = backgroundColor;
-  }
-  get near() {
-    return this.fog.near;
-  }
-  set near(v) {
-    this.fog.near = v;
-    this.fog.far = Math.max(this.fog.far, v);
-  }
-  get far() {
-    return this.fog.far;
-  }
-  set far(v) {
-    this.fog.far = v;
-    this.fog.near = Math.min(this.fog.near, v);
-  }
-+  get color() {
-+    return `#${this.fog.color.getHexString()}`;
-+  }
-+  set color(hexString) {
-+    this.fog.color.set(hexString);
-+    this.backgroundColor.set(hexString);
-+  }
-}
-
-

gui.addColor を呼び出し、ヘルパーのvirtualプロパティにcolorのlil-guiを追加します。

-
{
-  const near = 1;
-  const far = 2;
-  const color = 'lightblue';
-  scene.fog = new THREE.Fog(color, near, far);
-  scene.background = new THREE.Color(color);
-
-*  const fogGUIHelper = new FogGUIHelper(scene.fog, scene.background);
-  gui.add(fogGUIHelper, 'near', near, far).listen();
-  gui.add(fogGUIHelper, 'far', near, far).listen();
-+  gui.addColor(fogGUIHelper, 'color');
-}
-
-

- -

-

near を1.9、far を2.0にすると以下のようになりました。 -曇っていない状態と完全に曇っている状態との中間では、シャープなグラデーションします。 -near = 1.1、far = 2.9 とすると、カメラから 2 離れて回転する立方体で最も滑らかになります。

-

最後に、マテリアルでレンダリングされたオブジェクトがフォグの影響を受けるか判断するために、マテリアルにはboolean型のfogプロパティがあります。 -そのマテリアルを使用してる場合は、フォグの影響を受けます。 -ほとんどのマテリアルのデフォルトは true です。 -フォグを消去する理由は、運転席やコックピットからの視点で3Dの車のシミュレーターを作っている時を想像して下さい。 -車内から見ると、車内の全てのものはフォグを外しておきたいと思うでしょう。

-

フォグの良い例としては、家の外に濃いフォグが出ている場合が挙げられます。 -例えば、フォグが2m先(near = 2)から始まり、4m先(far = 4)でフォグがあるとします。 -部屋の長さは2メートル以上、家の長さは4メートル以上で、家の中にフォグがかからないように設定が必要です。 -設定しない場合に家の中に立っている時に部屋の奥の壁の外を見ると、フォグの中にいるように見えてしまいます。

-
-
-
-
fog: true, all
-
-
- -

部屋の奥の壁と天井にフォグがかかっています。 -家のマテリアルのフォグをオフにすると、この問題が解決できます。

-
-
-
-
fog: true, only outside materials
-
-
- -

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/fundamentals.html b/manual/ja/fundamentals.html deleted file mode 100644 index 3dc8eeea5e3dce..00000000000000 --- a/manual/ja/fundamentals.html +++ /dev/null @@ -1,319 +0,0 @@ - - - の基礎知識 - - - - - - - - - - - - - -
-
-

の基礎知識

-
-
-
-

これはthree.jsの最初の連載記事です。 -Three.js は、できるだけ簡単にWebページ上に3Dコンテンツを表示する3Dライブラリです。

-

Three.jsはWebGLと混同される事がよくあります。 -常にではないですが、ほとんどの場合three.jsはWebGLを使用して3Dを描画するためです。 -WebGLはポイントやライン、三角形のみを描画する非常に低レベルのシステムです。 -three.jsを使わない場合、WebGLで何か便利な事を行うには通常かなりのコードが必要です。 -もしWebGLを直接書く場合、シーンやライト、シャドウやマテリアル、テクスチャや3D数学をあなた自身で制御する必要があります。

-

これらのチュートリアルはJavaScriptを知っている事を前提としており、ほとんどの部分でES6スタイルを使用します。 -あなたがすでに知っている事が期待される簡潔なリストはこちらをご覧下さい。 -three.jsがサポートするほとんどのブラウザは自動更新されるため、ほとんどのユーザーはこのコードを実行できます。 -古いブラウザーで実行したい場合は、Babel のようなトランスパイラーを調べて下さい。 -もちろん、本当に古いブラウザを実行しているユーザーはthree.jsを実行できないマシンを持っている可能性があります。

-

ほとんどのプログラミング言語を学ぶ時、最初にする事は "Hello World!" を表示する事です。 -3Dで最初に行う最も一般的な事の1つは、3Dキューブを作成する事です。 -それでは "Hello Cube!" から始めましょう。

-

最初に必要なのは <canvas> タグです。

-
<body>
-  <canvas id="c"></canvas>
-</body>
-
-

Three.jsはcanvasに描画するため、canvasをthree.jsに渡す必要があります。

-
<script type="module">
-import * as THREE from 'three';
-
-function main() {
-  const canvas = document.querySelector('#c');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-  ...
-</script>
-
-

scriptタグに type="module" を含めることが重要です。 -これにより import キーワードを使用してthree.jsを読み込む事ができます。 -three.jsを読み込む方法は他にもありますが、r106の時点ではモジュールを使用する事をお勧めします。 -モジュールには、必要な他のモジュールを簡単にインポートできるという利点があります。 -これにより、依存している追加のスクリプトを手動で読み込む必要がなくなります。

-

ここにいくつかの難解な事があります。 -もしcanvasをthree.jsに渡さない場合、canvasをdocumentに追加する必要があります。 -canvasを追加する場所は、ユースケースに応じて変わる可能性があります。 -また、コードを変更する必要があるため、canvasをthree.jsに渡す事は少し柔軟性があると感じます。 -canvasをどこにでも配置でき、コードを見つける事ができます。 -canvasをdocumentに挿入するコードがあるかのように、ユースケースが変更された場合、そのコードを変更する必要があります。

-

canvasを検索した後、WebGLRenderer を作成します。 -rendererは提供された全てのデータを取得し、canvasにレンダリングします。

-

今はWebGLを使用して3Dをcanvasにレンダリングする WebGLRenderer があります。

-

次はカメラが必要です。

-
const fov = 75;
-const aspect = 2;  // the canvas default
-const near = 0.1;
-const far = 5;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-
-

fovfield of view の略です。この場合、垂直方向に75度を表します。 -three.jsのほとんどの角度はラジアン単位ですが、いくつかの理由でパースペクティブカメラは角度を設定します。

-

aspect はcanvasの表示アスペクトです。 -別の記事で詳細を説明しますが、デフォルト値はcanvasは300x150ピクセルで、アスペクトは300/150、または2になります。 -nearfar は、レンダリングされるカメラの前のスペースを表します。 -その範囲の前またはその範囲の後はクリップされます(描画されません)。

-

これらの4つの設定は "錐台" を定義します。 -錐台 は、先端が切り取られたピラミッドのような3D形状の名前です。 -つまり、"錐台" という言葉は、球、立方体、角柱、錐台のような別の3D形状と考えて下さい。

-

-

近距離および遠距離の平面の高さは、視野によって決まります。 -両方の平面の幅は、視野とアスペクトによって決まります。

-

定義された錐台内のすべてが描画されます。錐台外は何も描画しません。

-

カメラはデフォルトで、Yを上にして-Z軸を見下ろします。 -立方体を原点に配置するので、カメラを原点から少し後ろに動かし、何かを見えるようにする必要があります。

-
camera.position.z = 2;
-
-

以下が私たちが目指しているものです。

-

-

上の図では、カメラが z = 2 にある事がわかります。 --Z軸を見下ろしています。 -錐台は、カメラの正面から0.1単位で開始し、カメラの正面の5単位に移動します。 -この図では見下ろしているため、視野はアスペクトの影響を受けます。 -canvasは高さの2倍の幅であるため、ビュー全体で視野は垂直方向の視野である指定された75度よりもはるかに広くなります。

-

次は Scene を作成します。 -three.jsの Scene は、シーングラフのフォームのルートです。 -three.jsで描画するものはすべてシーンに追加する必要があります。 -Sceneの仕組みの詳細は、今後の記事で説明します。

-
const scene = new THREE.Scene();
-
-

次はボックスのデータが含まれている BoxGeometry を作成します。 -Three.jsで表示するほとんど全てのものには、3Dオブジェクトを構成する頂点を定義するジオメトリが必要です。

-
const boxWidth = 1;
-const boxHeight = 1;
-const boxDepth = 1;
-const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-
-

基本的なマテリアルを作成し、色を設定します。 -色は標準のCSSスタイルの6桁の16進数の色値を使用して指定できます。

-
const material = new THREE.MeshBasicMaterial({color: 0x44aa88});
-
-

次に Mesh を作成します。 MeshGeometry (オブジェクトの形状)と Material (オブジェクトの描画方法、光沢または平坦、適用する色、適用するテクステャなど)を組み合わせます。 -シーン内のオブジェクトの位置、方向、スケールと同様です。

-
const cube = new THREE.Mesh(geometry, material);
-
-

最後にメッシュをシーンに追加します。

-
scene.add(cube);
-
-

次にレンダラーの描画関数にシーンとカメラに渡し、シーンをレンダリングします。

-
renderer.render(scene, camera);
-
-

これが実際の例です。

-

- -

-

私たちの視点からは、これを3Dキューブと言うのは少し難しいです。Z軸がマイナス値で奥にあり、1つの面しか見ていません。

-

回転アニメーションさせて、3Dキューブで描画されている事を明らかにします。アニメーションさせるには requestAnimationFrame を使用しループ処理内で描画します。

-

これがループです。

-
function render(time) {
-  time *= 0.001;  // convert time to seconds
-
-  cube.rotation.x = time;
-  cube.rotation.y = time;
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-requestAnimationFrame(render);
-
-

requestAnimationFrame は、何かをアニメーションさせたい時に使います。 -これに呼び出される関数を渡します。今回渡す関数は render です。 -関数を呼び出し、表示に関連する何かを更新するとブラウザがページを再描画します。 -私たちの場合、three.jsの renderer.render 関数を呼び出しシーンを描画します。 -requestAnimationFrame は、ページがロードされて渡した関数が呼ばれるまで少し時間がかかります。その時間はミリ秒単位です。 -秒に変換する方が簡単に扱えるため、ここではミリ秒を秒に変換します。

-

次にキューブのXとY回転に現在の時間を設定します。 -回転は ラジアン 単位です。 -円の中に2πのラジアンがあります。キューブは1秒間に約6.28ごと各軸で1回転します。

-

シーンをレンダリングし、別のアニメーションフレームをループし続けます。 -ループの外側で requestAnimationFrame を1回呼び出してループを開始します。

-

- -

-

少し良くなりましたが、まだ3Dには見えません。3Dに見えるようにいくつか光源を追加します。つまり、ライトを追加しましょう。 -three.jsには 今後の記事 で紹介する多くの種類のライトがあります。 -とりあえずディレクショナルライトを作成しましょう。

-
{
-  const color = 0xFFFFFF;
-  const intensity = 3;
-  const light = new THREE.DirectionalLight(color, intensity);
-  light.position.set(-1, 2, 4);
-  scene.add(light);
-}
-
-

ディレクショナルライトは位置とターゲットを持っています。どちらもデフォルト値は 0, 0, 0 です。 -今回はライトの位置を -1、2、4 に設定しているため、カメラの後ろの少し左上側にあります。 -ターゲットはまだ 0, 0, 0 なので原点に向かって輝きます。

-

また、マテリアルを変更する必要があります。MeshBasicMaterial はライトの影響を受けません。ライトの影響をうける MeshPhongMaterial に変更してみましょう。

-
-const material = new THREE.MeshBasicMaterial({color: 0x44aa88});  // greenish blue
-+const material = new THREE.MeshPhongMaterial({color: 0x44aa88});  // greenish blue
-
-

そして、ここで動作しています。

-

- -

-

これでかなり3Dに見えるようになりました。

-

楽しんでみるために、さらに2つのキューブを追加しましょう。

-

各キューブに同じジオメトリを使用しますが、キューブごとに異なるカラーが適用できるため、異なるマテリアルを作成します。

-

最初に、指定した色で新しいマテリアルを作成する関数を作ります。 -次に、指定したジオメトリをシーンに追加し、X位置を設定したメッシュを作成します。

-
function makeInstance(geometry, color, x) {
-  const material = new THREE.MeshPhongMaterial({color});
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  cube.position.x = x;
-
-  return cube;
-}
-
-

次に、異なる色とX位置を指定した Mesh インスタンスを配列に保存する関数を3回呼び出します。

-
const cubes = [
-  makeInstance(geometry, 0x44aa88,  0),
-  makeInstance(geometry, 0x8844aa, -2),
-  makeInstance(geometry, 0xaa8844,  2),
-];
-
-

最後に、描画関数の中でで3つのキューブすべてを回転します。 -それぞれにわずかに異なる回転を計算し適用します。

-
function render(time) {
-  time *= 0.001;  // convert time to seconds
-
-  cubes.forEach((cube, ndx) => {
-    const speed = 1 + ndx * .1;
-    const rot = time * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
-  ...
-
-

それがここにあります。

-

- -

-

上記のトップダウン図と比較すると、期待通りである事がわかります。 -X = -2 および X = +2の場合、キューブは部分的に錐台の外側にあります。 -錐台の外側は何だか誇張して歪んでおり、キャンバスの向こう側はとても極端です。

-

この短いイントロが学習を始めるのに役立つ事を願っています。 -次は複数の状況に適応できるようにレスポンシブサイトでのコードもカバーします

-
-

es6モジュール、three.js、およびフォルダー構造

-

バージョンr106以降でthree.jsを使用する好ましい方法は -es6モジュールです。

-

-es6モジュールはスクリプトのロードに import を使う事ができます。 -また、HTMLのインラインに <script type="module"> タグが使えます。 -両方とも以下に例を示します。 -

-
<script type="module">
-import * as THREE from 'three';
-
-...
-
-</script>
-
-

-パスは絶対パス、または相対パスでなければなりません。 -相対パスは常に ./ または ../ から始まり、 -<img><a> など他のタグと異なります。 -

-

-同じスクリプトへの参照は、絶対パスである限り一度だけロードされます。 -three.jsの場合、すべてのexamplesを正しいフォルダ階層に入れる必要があります。 -

-
someFolder
- |
- ├-build
- | |
- | +-three.module.js
- |
- +-examples
-   |
-   +-jsm
-     |
-     +-controls
-     | |
-     | +-OrbitControls.js
-     | +-TrackballControls.js
-     | +-...
-     |
-     +-loaders
-     | |
-     | +-GLTFLoader.js
-     | +-...
-     |
-     ...
-
-

-このフォルダー構造が必要な理由は、 OrbitControls.js のようなexamplesのスクリプトには相対パスがハードコーディングされてるからです。 -

-
import * as THREE from '../../../build/three.module.js';
-
-

-同じフォルダ構造を使用すると、importしたthreeとexampleライブラリは両方とも同じthree.module.jsを参照します。 -

-
import * as THREE from './someFolder/build/three.module.js';
-import {OrbitControls} from './someFolder/addons/controls/OrbitControls.js';
-
-

これにはCDNを使用する場合も含まれます。 three.modules.js のパスが /build/three.modules.js のようになってる事を確認して下さい。例えば

-
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@<version>/build/three.module.js';
-import {OrbitControls} from 'https://cdn.jsdelivr.net/npm/three@<version>/addons/controls/OrbitControls.js';
-
-
- -
-
-
- - - - - - - - diff --git a/manual/ja/game.html b/manual/ja/game.html deleted file mode 100644 index 26ed2f5d00b2da..00000000000000 --- a/manual/ja/game.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Making a Game - - - - - - - - - - - - - -
-
-

Making a Game

-
-
-
-

すいません、この記事はまだ翻訳してません. Translations Welcome! 😄

-

ここに元の英語の記事があります.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/indexed-textures.html b/manual/ja/indexed-textures.html deleted file mode 100644 index b60f50974fb769..00000000000000 --- a/manual/ja/indexed-textures.html +++ /dev/null @@ -1,593 +0,0 @@ - - - 圧縮テクスチャのピッキングとカラー - - - - - - - - - - - - - -
-
-

圧縮テクスチャのピッキングとカラー

-
-
-
-

この記事はThree.jsでHTML要素を3Dに揃えるの続きです。 -まだ読んでいない人はそちらから読んでみて下さい。

-

Three.jsを使う時には、クリエイティブな解決策を考えなければならない事もあります。 -これが素晴らしい解決策かわかりませんが、共有して何か解決策の提案があるか見てみようと思いました。

-

前回の記事では3Dの地球儀の周りに国名を表示しました。 -ユーザーに国を選択させて、その国の選択内容を表示させるにはどうすれば良いでしょうか?

-

最初に思いつくのは国ごとにジオメトリを生成する事です。 -以前取り上げたピッキングを使った解決策があります。 -国ごとに3Dジオメトリを作ります。 -ユーザーが国のメッシュをクリックすると、どの国がクリックされたのか分かります。

-

この解決策を確認するために前回の記事のアウトライン生成に使った同じデータを使用し、全ての国の3Dメッシュを生成してみました。 -その結果、15.5MBのバイナリGLTF(.glb)ファイルができました。 -15.5MBをダウンロードさせるのは、私にはやりすぎのように思えます。

-

データを圧縮する方法はたくさんあります。 -1つ目はアウトラインの解像度を下げるアルゴリズムを適用する事です。 -その解決策を追求する時間を割いていませんでした。 -アメリカの国境にとっては大勝利でしょうね。 -カナダの国境はおそらくもっと少ないです。

-

別の解決策としては、実際のデータ圧縮だけを使用する方法もあります。 -例えばファイルを圧縮すると11MBになりました。 -30%減ですが、間違いなく物足りないです。

-

全てのデータを32ビットのfloat値ではなく、16ビットの範囲内の値として格納できます。 -もしくはdraco compressionのようなものを使う事もできるし、それだけで充分かもしれません。 -私は確認してないですが、ぜひご自身で確認して結果がどうなったか教えて下さい😅

-

私の場合はピッキングの記事の最後に取り上げたGPUピッキングの解決策について考えてみました。 -この解決策では、メッシュのIDを表すユニークなカラーで全てのメッシュを描画しました。 -全てのメッシュを描画し、クリックしてカラーを確認しました。

-

そこからインスピレーションを得て、国の配列の中でそれぞれの国のカラーがインデックス番号になり、国の地図を事前に生成できました。 -そうすれば、GPUピッキング技術と似たように使う事ができます。 -この圧縮テクスチャ(インデックステクスチャ)を使って地球儀を画面外に描画します。 -ユーザーがクリックしたピクセルのカラーを見ると国のIDが分かります。

-

そこでこのようなテクスチャを生成するコードを書いてみました。

-
- -

注:このテクスチャを生成するために使用されたデータはこのウェブサイトからのものでCC-BY-SAライセンスです。

-

たったの217KBになり、国のメッシュの14MBよりずっと良いです。 -解像度を下げる事もできそうですが、今の所は217KBで充分だと思います。

-

これを使い国をピッキングしてみましょう。

-

GPUピッキング例からコードを取得すると、ピッキングシーンが必要です。

-
const pickingScene = new THREE.Scene();
-pickingScene.background = new THREE.Color(0);
-
-

そして、ピッキングシーンに圧縮ステクスチャの地球儀の追加が必要です。

-
{
-  const loader = new THREE.TextureLoader();
-  const geometry = new THREE.SphereGeometry(1, 64, 32);
-
-+  const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
-+  indexTexture.minFilter = THREE.NearestFilter;
-+  indexTexture.magFilter = THREE.NearestFilter;
-+
-+  const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture});
-+  pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
-
-  const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
-  const material = new THREE.MeshBasicMaterial({map: texture});
-  scene.add(new THREE.Mesh(geometry, material));
-}
-
-

前に使った GPUPickingHelper クラスをコピーしましょう。 -少し変更しました。

-
class GPUPickHelper {
-  constructor() {
-    // create a 1x1 pixel render target
-    this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
-    this.pixelBuffer = new Uint8Array(4);
--    this.pickedObject = null;
--    this.pickedObjectSavedColor = 0;
-  }
-  pick(cssPosition, scene, camera) {
-    const {pickingTexture, pixelBuffer} = this;
-
-    // set the view offset to represent just a single pixel under the mouse
-    const pixelRatio = renderer.getPixelRatio();
-    camera.setViewOffset(
-        renderer.getContext().drawingBufferWidth,   // full width
-        renderer.getContext().drawingBufferHeight,  // full top
-        cssPosition.x * pixelRatio | 0,             // rect x
-        cssPosition.y * pixelRatio | 0,             // rect y
-        1,                                          // rect width
-        1,                                          // rect height
-    );
-    // render the scene
-    renderer.setRenderTarget(pickingTexture);
-    renderer.render(scene, camera);
-    renderer.setRenderTarget(null);
-    // clear the view offset so rendering returns to normal
-    camera.clearViewOffset();
-    //read the pixel
-    renderer.readRenderTargetPixels(
-        pickingTexture,
-        0,   // x
-        0,   // y
-        1,   // width
-        1,   // height
-        pixelBuffer);
-
-+    const id =
-+        (pixelBuffer[0] << 16) |
-+        (pixelBuffer[1] <<  8) |
-+        (pixelBuffer[2] <<  0);
-+
-+    return id;
--    const id =
--        (pixelBuffer[0] << 16) |
--        (pixelBuffer[1] <<  8) |
--        (pixelBuffer[2]      );
--    const intersectedObject = idToObject[id];
--    if (intersectedObject) {
--      // pick the first object. It's the closest one
--      this.pickedObject = intersectedObject;
--      // save its color
--      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
--      // set its emissive color to flashing red/yellow
--      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
--    }
-  }
-}
-
-

これで国を選択できるようになりました。

-
const pickHelper = new GPUPickHelper();
-
-function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function pickCountry(event) {
-  // exit if we have not loaded the data yet
-  if (!countryInfos) {
-    return;
-  }
-
-  const position = getCanvasRelativePosition(event);
-  const id = pickHelper.pick(position, pickingScene, camera);
-  if (id > 0) {
-    // we clicked a country. Toggle its 'selected' property
-    const countryInfo = countryInfos[id - 1];
-    const selected = !countryInfo.selected;
-    // if we're selecting this country and modifiers are not
-    // pressed unselect everything else.
-    if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
-      unselectAllCountries();
-    }
-    numCountriesSelected += selected ? 1 : -1;
-    countryInfo.selected = selected;
-  } else if (numCountriesSelected) {
-    // the ocean or sky was clicked
-    unselectAllCountries();
-  }
-  requestRenderIfNotRequested();
-}
-
-function unselectAllCountries() {
-  numCountriesSelected = 0;
-  countryInfos.forEach((countryInfo) => {
-    countryInfo.selected = false;
-  });
-}
-
-canvas.addEventListener('pointerup', pickCountry);
-
-

上記のコードでは、国の配列に selected プロパティを設定/解除しています。 -Shiftctrlcmd を押すと複数の国を選択できます。

-

残作業は選択した国を表示させるだけです。 -ラベルを更新してみましょう。

-
function updateLabels() {
-  // exit if we have not loaded the data yet
-  if (!countryInfos) {
-    return;
-  }
-
-  const large = settings.minArea * settings.minArea;
-  // get a matrix that represents a relative orientation of the camera
-  normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
-  // get the camera's position
-  camera.getWorldPosition(cameraPosition);
-  for (const countryInfo of countryInfos) {
--    const {position, elem, area} = countryInfo;
--    // large enough?
--    if (area < large) {
-+    const {position, elem, area, selected} = countryInfo;
-+    const largeEnough = area >= large;
-+    const show = selected || (numCountriesSelected === 0 && largeEnough);
-+    if (!show) {
-      elem.style.display = 'none';
-      continue;
-    }
-
-    ...
-
-

これで国を選択できるようになります。

-

- -

-

地域に基づいて国を表示しますが、1つをクリックすると1つだけラベルが表示されます。

-

国を選択する合理的な解決策のように思えますが、選択された国を強調表示するにはどうでしょうか?

-

パレットグラフィックス からインスピレーションを得る事ができます。

-

パレットグラフィックスインデックスカラーはAtari 800、Amiga、ファミコン、スーパーファミコン、IBMの古いPCなどの古いシステムで使われていました。 -ビットマップをRGBAカラー8ビット、1ピクセル32バイト以上で格納するのではなく、ビットマップを8ビット以下の値で格納していました。 -各ピクセルの値はパレットへのインデックスです。 -そのため例えば画像内の値が3であれば "color 3を表示する" という事になります。 -color 3が何色かは "パレット" と呼ばれる別の場所で定義されています。

-

JavaScriptでは次のようなコードにできます。

-
const face7x7PixelImageData = [
-  0, 1, 1, 1, 1, 1, 0,
-  1, 0, 0, 0, 0, 0, 1,
-  1, 0, 2, 0, 2, 0, 1,
-  1, 0, 0, 0, 0, 0, 1,
-  1, 0, 3, 3, 3, 0, 1,
-  1, 0, 0, 0, 0, 0, 1,
-  0, 1, 1, 1, 1, 1, 1,
-];
-
-const palette = [
-  [255, 255, 255],  // white
-  [  0,   0,   0],  // black
-  [  0, 255, 255],  // cyan
-  [255,   0,   0],  // red
-];
-
-

画像データの各ピクセルがパレットへのインデックスとなります。 -上記のパレットを使い、画像データを解釈すると次のような画像が得られます。

-
- -

この場合、上記のテクスチャは国ごとに異なるIDを持っています。 -そこでパレットのテクスチャを通して同じテクスチャを使い、それぞれの国のカラーを出せば良いのです。 -パレットの質感を変える事でそれぞれの国を彩る事ができます。 -例えばパレットテクスチャ全体を黒に設定し、パレット内のある国のエントリーを別のカラーにするとその国だけを強調表示できます。

-

パレット化されたインデックスグラフィックスを行うにはカスタムシェーダーコードが必要です。 -three.jsでデフォルトのシェーダーを変更してみましょう。 -そうすれば、必要に応じてライティングなどの機能も使えます。

-

アニメーションする多くのオブジェクトを最適化の記事で解説したように、 -マテリアルの onBeforeCompile プロパティに関数を追加するとデフォルトのシェーダーを変更できます。

-

デフォルトのフラグメントシェーダーはコンパイル前は以下のようになっています。

-
#include <common>
-#include <color_pars_fragment>
-#include <uv_pars_fragment>
-#include <map_pars_fragment>
-#include <alphamap_pars_fragment>
-#include <aomap_pars_fragment>
-#include <lightmap_pars_fragment>
-#include <envmap_pars_fragment>
-#include <fog_pars_fragment>
-#include <specularmap_pars_fragment>
-#include <logdepthbuf_pars_fragment>
-#include <clipping_planes_pars_fragment>
-void main() {
-    #include <clipping_planes_fragment>
-    vec4 diffuseColor = vec4( diffuse, opacity );
-    #include <logdepthbuf_fragment>
-    #include <map_fragment>
-    #include <color_fragment>
-    #include <alphamap_fragment>
-    #include <alphatest_fragment>
-    #include <specularmap_fragment>
-    ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );
-    #ifdef USE_LIGHTMAP
-        reflectedLight.indirectDiffuse += texture2D( lightMap, vLightMapUv ).xyz * lightMapIntensity;
-    #else
-        reflectedLight.indirectDiffuse += vec3( 1.0 );
-    #endif
-    #include <aomap_fragment>
-    reflectedLight.indirectDiffuse *= diffuseColor.rgb;
-    vec3 outgoingLight = reflectedLight.indirectDiffuse;
-    #include <envmap_fragment>
-    gl_FragColor = vec4( outgoingLight, diffuseColor.a );
-    #include <premultiplied_alpha_fragment>
-    #include <tonemapping_fragment>
-    #include <colorspace_fragment>
-    #include <fog_fragment>
-}
-
-

これらのスニペットを全て調べてみるとthree.jsはベースマテリアルのカラーを管理するために diffuseColor という変数が使用されています。 -これは <color_fragment> スニペット に設定されているので修正できるはずです。

-

シェーダーで diffuseColor は既にアウトラインテクスチャのカラーになっているはずなので、パレットテクスチャからカラーを探して最終的な結果にそれらをミックスします。

-

前にやったように検索文字列と置換文字列の配列を作り、Material.onBeforeCompile でシェーダーに適用します。

-
{
-  const loader = new THREE.TextureLoader();
-  const geometry = new THREE.SphereGeometry(1, 64, 32);
-
-  const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
-  indexTexture.minFilter = THREE.NearestFilter;
-  indexTexture.magFilter = THREE.NearestFilter;
-
-  const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture});
-  pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
-
-+  const fragmentShaderReplacements = [
-+    {
-+      from: '#include <common>',
-+      to: `
-+        #include <common>
-+        uniform sampler2D indexTexture;
-+        uniform sampler2D paletteTexture;
-+        uniform float paletteTextureWidth;
-+      `,
-+    },
-+    {
-+      from: '#include <color_fragment>',
-+      to: `
-+        #include <color_fragment>
-+        {
-+          vec4 indexColor = texture2D(indexTexture, vUv);
-+          float index = indexColor.r * 255.0 + indexColor.g * 255.0 * 256.0;
-+          vec2 paletteUV = vec2((index + 0.5) / paletteTextureWidth, 0.5);
-+          vec4 paletteColor = texture2D(paletteTexture, paletteUV);
-+          // diffuseColor.rgb += paletteColor.rgb;   // white outlines
-+          diffuseColor.rgb = paletteColor.rgb - diffuseColor.rgb;  // black outlines
-+        }
-+      `,
-+    },
-+  ];
-
-  const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
-  const material = new THREE.MeshBasicMaterial({map: texture});
-+  material.onBeforeCompile = function(shader) {
-+    fragmentShaderReplacements.forEach((rep) => {
-+      shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
-+    });
-+  };
-  scene.add(new THREE.Mesh(geometry, material));
-}
-
-

上記のように indexTexturepaletteTexturepaletteTextureWidth の3つのユニフォームを追加します。 -indexTexture からカラーを取得してインデックスに変換します。 -vUv はthree.jsで提供されているテクスチャ座標です。 -そのインデックスを使い、パレットテクスチャからカラーを取り出します。 -その結果を現在の diffuseColor とミックスします。 -diffuseColor は黒と白のアウトラインテクスチャなので、2色を加えると白のアウトラインになります。 -現在の拡散色(ディフューズカラー)を引くと黒いアウトラインになります。

-

レンダリング前にパレットテクスチャと3つのユニフォームを設定する必要があります。

-

パレットテクスチャは、国ごとに1色 + 海のための1色を保持するのに十分な幅が必要です(id = 0)。 -240の国があります。 -国のリストがロードされるまで待ち、正確な数字を得るか、それを調べる事ができます。 -少し大きめの数字を選んでも、あまり害はないので512を選びましょう。

-

パレットテクスチャを作成するコードは以下の通りです。

-
const maxNumCountries = 512;
-const paletteTextureWidth = maxNumCountries;
-const paletteTextureHeight = 1;
-const palette = new Uint8Array(paletteTextureWidth * 4);
-const paletteTexture = new THREE.DataTexture(
-    palette, paletteTextureWidth, paletteTextureHeight);
-paletteTexture.minFilter = THREE.NearestFilter;
-paletteTexture.magFilter = THREE.NearestFilter;
-
-

DataTexture はテクスチャの生データを与える事ができます。 -今回はは512のRGBAカラーを4バイトずつ与え、それぞれのバイトが赤、緑、青で0〜255の値を使用します。

-

ランダムなカラーで塗りつぶしましょう!

-
for (let i = 1; i < palette.length; ++i) {
-  palette[i] = Math.random() * 256;
-}
-// set the ocean color (index #0)
-palette.set([100, 200, 255, 255], 0);
-paletteTexture.needsUpdate = true;
-
-

パレットテクスチャを palette 配列の内容で更新したい時は、常に paletteTexture.needsUpdatetrue にする必要があります。

-

これはマテリアルのユニフォームに設定する必要があります。

-
const geometry = new THREE.SphereGeometry(1, 64, 32);
-const material = new THREE.MeshBasicMaterial({map: texture});
-material.onBeforeCompile = function(shader) {
-  fragmentShaderReplacements.forEach((rep) => {
-    shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
-  });
-+  shader.uniforms.paletteTexture = {value: paletteTexture};
-+  shader.uniforms.indexTexture = {value: indexTexture};
-+  shader.uniforms.paletteTextureWidth = {value: paletteTextureWidth};
-};
-scene.add(new THREE.Mesh(geometry, material));
-
-

ランダムなカラーがついた国を手に入れる事ができました。

-

- -

-

インデックスとパレットテクスチャの動作が確認できたので、パレットを操作してハイライトにしてみましょう。

-

まず、three.jsスタイルカラーを渡してパレットのテクスチャに入れる値を与える関数を作ってみましょう。

-
const tempColor = new THREE.Color();
-function get255BasedColor(color) {
-  tempColor.set(color);
-  const base = tempColor.toArray().map(v => v * 255);
-  base.push(255); // alpha
-  return base;
-}
-
-

このように color = get255BasedColor('red') を呼び出すと [255, 0, 0] のような配列が返されます。

-

次はそれを使って、いくつかのカラーを作ってパレットを埋めていきましょう。

-
const selectedColor = get255BasedColor('red');
-const unselectedColor = get255BasedColor('#444');
-const oceanColor = get255BasedColor('rgb(100,200,255)');
-resetPalette();
-
-function setPaletteColor(index, color) {
-  palette.set(color, index * 4);
-}
-
-function resetPalette() {
-  // make all colors the unselected color
-  for (let i = 1; i < maxNumCountries; ++i) {
-    setPaletteColor(i, unselectedColor);
-  }
-
-  // set the ocean color (index #0)
-  setPaletteColor(0, oceanColor);
-  paletteTexture.needsUpdate = true;
-}
-
-

これらの関数を使い、国が選択された時にパレットを更新してみましょう。

-
function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function pickCountry(event) {
-  // exit if we have not loaded the data yet
-  if (!countryInfos) {
-    return;
-  }
-
-  const position = getCanvasRelativePosition(event);
-  const id = pickHelper.pick(position, pickingScene, camera);
-  if (id > 0) {
-    const countryInfo = countryInfos[id - 1];
-    const selected = !countryInfo.selected;
-    if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
-      unselectAllCountries();
-    }
-    numCountriesSelected += selected ? 1 : -1;
-    countryInfo.selected = selected;
-+    setPaletteColor(id, selected ? selectedColor : unselectedColor);
-+    paletteTexture.needsUpdate = true;
-  } else if (numCountriesSelected) {
-    unselectAllCountries();
-  }
-  requestRenderIfNotRequested();
-}
-
-function unselectAllCountries() {
-  numCountriesSelected = 0;
-  countryInfos.forEach((countryInfo) => {
-    countryInfo.selected = false;
-  });
-+  resetPalette();
-}
-
-

1つ以上の国を強調する事ができるようにする必要があります。

-

- -

-

これで動作しました!

-

ちょっとした事ですが、選択状態を変えないと地球儀を回せないです。 -国を選択してから地球儀を回転させると選択範囲が変わります。

-

修正してみましょう。 -思いつく限りでは2つの事が確認できます。 -クリックしてから手放すまでにどれだけの時間が経過したか。 -もう1つはユーザーが実際にマウスを動かしたかどうかです。 -時間が短かったり、マウスを動かさなかった場合はクリックだったのではないでしょうか。 -そうでなければ、地球を引っ張っていこうとしていたのでしょう。

-
+const maxClickTimeMs = 200;
-+const maxMoveDeltaSq = 5 * 5;
-+const startPosition = {};
-+let startTimeMs;
-+
-+function recordStartTimeAndPosition(event) {
-+  startTimeMs = performance.now();
-+  const pos = getCanvasRelativePosition(event);
-+  startPosition.x = pos.x;
-+  startPosition.y = pos.y;
-+}
-
-function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function pickCountry(event) {
-  // exit if we have not loaded the data yet
-  if (!countryInfos) {
-    return;
-  }
-
-+  // if it's been a moment since the user started
-+  // then assume it was a drag action, not a select action
-+  const clickTimeMs = performance.now() - startTimeMs;
-+  if (clickTimeMs > maxClickTimeMs) {
-+    return;
-+  }
-+
-+  // if they moved assume it was a drag action
-+  const position = getCanvasRelativePosition(event);
-+  const moveDeltaSq = (startPosition.x - position.x) ** 2 +
-+                      (startPosition.y - position.y) ** 2;
-+  if (moveDeltaSq > maxMoveDeltaSq) {
-+    return;
-+  }
-
--  const position = {x: event.clientX, y: event.clientY};
-  const id = pickHelper.pick(position, pickingScene, camera);
-  if (id > 0) {
-    const countryInfo = countryInfos[id - 1];
-    const selected = !countryInfo.selected;
-    if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
-      unselectAllCountries();
-    }
-    numCountriesSelected += selected ? 1 : -1;
-    countryInfo.selected = selected;
-    setPaletteColor(id, selected ? selectedColor : unselectedColor);
-    paletteTexture.needsUpdate = true;
-  } else if (numCountriesSelected) {
-    unselectAllCountries();
-  }
-  requestRenderIfNotRequested();
-}
-
-function unselectAllCountries() {
-  numCountriesSelected = 0;
-  countryInfos.forEach((countryInfo) => {
-    countryInfo.selected = false;
-  });
-  resetPalette();
-}
-
-+canvas.addEventListener('pointerdown', recordStartTimeAndPosition);
-canvas.addEventListener('pointerup', pickCountry);
-
-

これらの変更を加えると私にはそれが機能しているように見えます

-

- -

-

私はUXの専門家ではないので、もっと良い解決策があれば教えて下さい。

-

インデックスグラフィックスがどのように役立つのか、また、three.jsが作るシェーダーを変更してシンプルな機能を追加する方法が少しでも理解してもらえたと思います。 -シェーダーが書かれている言語のGLSLの使い方は、この記事で扱うには内容が大きすぎます。 -ポストプロセスの記事にGLSLのリンクがあるので確認してみて下さい。

- -
-
-
- - - - - - - - diff --git a/manual/ja/lights.html b/manual/ja/lights.html deleted file mode 100644 index e5d9f9a2219ffc..00000000000000 --- a/manual/ja/lights.html +++ /dev/null @@ -1,445 +0,0 @@ - - - のライト - - - - - - - - - - - - - -
-
-

のライト

-
-
-
-

この記事はThree.jsの連載記事の1つです。 -最初の記事はThree.jsの基礎知識です。 -まだ読んでいない場合は、Three.jsの基礎知識やセットアップから始めると良いと思います。 -前回の記事はテクスチャでした。

-

今回はthree.jsの色々な種類のライトの使い方を確認していきます。

-

前回のサンプルコードからカメラの設定を修正しましょう。 -視野角(fov)を45度にし、遠平面(far)を100、カメラを原点からY座標に10、Z座標を20にします。

-
*const fov = 45;
-const aspect = 2;  // the canvas default
-const near = 0.1;
-*const far = 100;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-+camera.position.set(0, 10, 20);
-
-

次に OrbitControls を追加します。 -OrbitControls は、カメラをある点を中心に軌道を回転できます。 -OrbitControls はthree.jsのオプション機能なので、importする必要があります。

-
import * as THREE from 'three';
-+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-
-

これでOrbitControlsを利用できます。 -OrbitControls にカメラと入力イベントを取得するDOM要素を渡します。

-
const controls = new OrbitControls(camera, canvas);
-controls.target.set(0, 5, 0);
-controls.update();
-
-

controls.targetのY座標を5にして controls.update を呼び出します。

-

次はライトアップするオブジェクトを作ってみましょう。 -まずは地上となる平面を作ります。 -この平面に2 x 2ピクセルの小さなチェッカーボードのテクスチャを適用します。

-
- -
- -

最初にテクスチャを読み込み、何回テクスチャのリピートを繰り返すかを設定し、フィルターはニアレスト(nearest)にします。 -テクスチャは2 x 2ピクセルのチェッカーボードです。 -テクスチャのリピートは平面の半分の大きさにし、チェッカーボードの1つのチェック部分は1にします。

-
const planeSize = 40;
-
-const loader = new THREE.TextureLoader();
-const texture = loader.load('resources/images/checker.png');
-texture.wrapS = THREE.RepeatWrapping;
-texture.wrapT = THREE.RepeatWrapping;
-texture.magFilter = THREE.NearestFilter;
-texture.colorSpace = THREE.SRGBColorSpace;
-const repeats = planeSize / 2;
-texture.repeat.set(repeats, repeats);
-
-

平面のジオメトリとマテリアルを作り、それを元にシーンに追加するメッシュを作ります。 -平面のデフォルトは縦向きなので横向きになるように回転します。

-
const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
-const planeMat = new THREE.MeshPhongMaterial({
-  map: texture,
-  side: THREE.DoubleSide,
-});
-const mesh = new THREE.Mesh(planeGeo, planeMat);
-mesh.rotation.x = Math.PI * -.5;
-scene.add(mesh);
-
-

キューブと球体を追加し、平面を含めて3つのオブジェクトをライティングします。

-
{
-  const cubeSize = 4;
-  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
-  const cubeMat = new THREE.MeshPhongMaterial({color: '#8AC'});
-  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
-  mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
-  scene.add(mesh);
-}
-{
-  const sphereRadius = 3;
-  const sphereWidthDivisions = 32;
-  const sphereHeightDivisions = 16;
-  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
-  const sphereMat = new THREE.MeshPhongMaterial({color: '#CA8'});
-  const mesh = new THREE.Mesh(sphereGeo, sphereMat);
-  mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
-  scene.add(mesh);
-}
-
-

ライトアップするシーンができたのでライトを追加しましょう!

-

AmbientLight(環境光源)

-

最初に AmbientLight を作りましょう。

-
const color = 0xFFFFFF;
-const intensity = 1;
-const light = new THREE.AmbientLight(color, intensity);
-scene.add(light);
-
-

ライトのパラメーターを調整できるようにします。 -今回もlil-guiを使います。 -lil-guiで色を調整するにはヘルパーが必要です。 -プロパティをlil-guiにCSSの16進数の形で表示します(例: #FF8844)。 -ヘルパーは名前付きプロパティから色を取得し、16進数の文字列に変換してlil-guiに渡します。lil-guiがヘルパーのプロパティを設定し、結果をライトの色に戻します。

-

これがヘルパーです。

-
class ColorGUIHelper {
-  constructor(object, prop) {
-    this.object = object;
-    this.prop = prop;
-  }
-  get value() {
-    return '#' + this.object[this.prop].getHexString();
-  }
-  set value(hexString) {
-    this.object[this.prop].set(hexString);
-  }
-}
-
-

lil-guiの設定は以下の通りです。

-
const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-gui.add(light, 'intensity', 0, 5, 0.01);
-
-

これで以下のような結果になります。

-

- -

-

シーンをクリックしてドラッグして、カメラを軌道に乗せます。

-

環境光源だけでは正しくライティング表現できてません。キューブと球体に陰影がなく形状が平面に見えます。 -以下のように AmbientLight はマテリアルの色とライトの色、ライトの強度(intensity)を掛けてます。

-
color = materialColor * light.color * light.intensity;
-

それだけで方向性がないです。 -この環境光源は100%均一でシーン内の全ての色を変える以外はライティングとしてはあまり役に立ちません。 -環境光源は暗すぎない暗さを作る事ができます。

-

HemisphereLight(半球光源)

-

コードを HemisphereLight に切り替えてみましょう。 -HemisphereLight は空と地面の色を取得し、その2色とマテリアルの色を掛け合わせます。

-

これが新しいコードです。

-
-const color = 0xFFFFFF;
-+const skyColor = 0xB1E1FF;  // light blue
-+const groundColor = 0xB97A20;  // brownish orange
-const intensity = 1;
--const light = new THREE.AmbientLight(color, intensity);
-+const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
-scene.add(light);
-
-

lil-guiのコードを修正し、両方の色を編集してみましょう。

-
const gui = new GUI();
--gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-+gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('skyColor');
-+gui.addColor(new ColorGUIHelper(light, 'groundColor'), 'value').name('groundColor');
-gui.add(light, 'intensity', 0, 5, 0.01);
-
-

これが結果です。

-

- -

-

まだ正しくライティング表現ができてなく、キューブと球体が平面に見えます。 -別のライトと組み合わせて使用される HemisphereLight は、空や地面の色に良い影響を与えます。 -この方法では他のライトと組み合わせて使うか、HemisphereLight を代わりに使うのがベストです。

-

DirectionalLight(平行光源)

-

コードを DirectionalLight に切り替えてみましょう。 -DirectionalLight は太陽を表すのによく使われます。

-
const color = 0xFFFFFF;
-const intensity = 1;
-const light = new THREE.DirectionalLight(color, intensity);
-light.position.set(0, 10, 0);
-light.target.position.set(-5, 0, 0);
-scene.add(light);
-scene.add(light.target);
-
-

シーンに lightlight.target を追加する必要があります。 -three.jsの DirectionalLight はターゲットの方向にライティングします。

-

GUIに追加してlight.targetを動かせるようにしてみましょう。

-
const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-gui.add(light, 'intensity', 0, 5, 0.01);
-gui.add(light.target.position, 'x', -10, 10);
-gui.add(light.target.position, 'z', -10, 10);
-gui.add(light.target.position, 'y', 0, 10);
-
-

- -

-

なんだか見づらいですね。 -Three.jsにはシーンに追加できるヘルパーオブジェクトがたくさんあり、シーンの見えない部分を視覚化するのに役立ちます。 -今回は DirectionalLightHelper を使い、ライトから平面までの線を描画します。 -lightをDirectionalLightHelperに渡してシーンに追加します。

-
const helper = new THREE.DirectionalLightHelper(light);
-scene.add(helper);
-
-

ライトの位置とターゲットの両方を設定できるようにしておきましょう。 -Vector3 が与えられた時に lil-gui を使い x, y, z プロパティを調整できる関数を作ります。

-
function makeXYZGUI(gui, vector3, name, onChangeFn) {
-  const folder = gui.addFolder(name);
-  folder.add(vector3, 'x', -10, 10).onChange(onChangeFn);
-  folder.add(vector3, 'y', 0, 10).onChange(onChangeFn);
-  folder.add(vector3, 'z', -10, 10).onChange(onChangeFn);
-  folder.open();
-}
-
-

変更時は常にヘルパーの update 関数を呼び出す必要があります。 -そのため、lil-guiが値を更新時に onChangeFn 関数を渡しています。

-

makeXYZGUI関数はlight.positionとlight.target.positionの両方に使えます。

-
+function updateLight() {
-+  light.target.updateMatrixWorld();
-+  helper.update();
-+}
-+updateLight();
-
-const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-gui.add(light, 'intensity', 0, 5, 0.01);
-
-+makeXYZGUI(gui, light.position, 'position', updateLight);
-+makeXYZGUI(gui, light.target.position, 'target', updateLight);
-
-

これでライトを動かす事ができるようになりました。

-

- -

-

カメラを軌道に乗せると見やすくなります。 -この平面は DirectionalLight を表しており、DirectionalLightが一方向からのライティングを計算します。 -光の出所はではなく、平面を無限に照らす平行光線です。

-

PointLight(点光源)

-

PointLight はある点から全方向に光を放つライトです。 -コード変更しましょう。

-
const color = 0xFFFFFF;
--const intensity = 1;
-+const intensity = 150;
--const light = new THREE.DirectionalLight(color, intensity);
-+const light = new THREE.PointLight(color, intensity);
-light.position.set(0, 10, 0);
--light.target.position.set(-5, 0, 0);
-scene.add(light);
--scene.add(light.target);
-
-

PointLightHelper に切り替えます。

-
-const helper = new THREE.DirectionalLightHelper(light);
-+const helper = new THREE.PointLightHelper(light);
-scene.add(helper);
-
-

light.targetがないので onChange 関数はもっとシンプルになります。

-
function updateLight() {
--  light.target.updateMatrixWorld();
-  helper.update();
-}
--updateLight();
-
-

PointLightHelper には点がない事に注意して下さい。 -小さなダイヤモンドのワイヤーフレームを描画します。 -簡単に望む任意の形状にできて、ライト自体にメッシュを追加します。

-

PointLightdistanceプロパティを持ちます。 -distance が0ならば PointLight は無限大に輝きます。 -distance が0よりも大きい場合、ライトに向かってライトの全強度を照らし、ライトから離れた distance では影響を受けないようにフェードアウトします。

-

distanceを調整できるようにGUIを設定してみましょう。

-
const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-gui.add(light, 'intensity', 0, 250, 1);
-+gui.add(light, 'distance', 0, 40).onChange(updateLight);
-
-makeXYZGUI(gui, light.position, 'position', updateLight);
--makeXYZGUI(gui, light.target.position, 'target', updateLight);
-
-

これを試してみて下さい。

-

- -

-

distance が > 0 の時にライトがフェードアウトしている事に注目して下さい。

-

SpotLight(集中光線)

-

集中光源は円錐体にライティングする時に効果的です。 -実際は2つの円錐体があります。外側と内側の円錐体です。 -内側と外側の円錐体の間では、ライトは強度のMAX値から0にフェードします。

-

SpotLight を使うには、平行光源と同じようにターゲットが必要です。 -ライトの円錐体がターゲットに向かって照らされます。

-

上記のヘルパーを使って DirectionalLight を修正します。

-
const color = 0xFFFFFF;
--const intensity = 1;
-+const intensity = 150;
--const light = new THREE.DirectionalLight(color, intensity);
-+const light = new THREE.SpotLight(color, intensity);
-scene.add(light);
-scene.add(light.target);
-
--const helper = new THREE.DirectionalLightHelper(light);
-+const helper = new THREE.SpotLightHelper(light);
-scene.add(helper);
-
-

集中光源の円錐体の角度は angleプロパティでラジアン単位で設定します。 -テクスチャ記事DegRadHelper を使い、度数でUIに表示します。

-
gui.add(new DegRadHelper(light, 'angle'), 'value', 0, 90).name('angle').onChange(updateLight);
-
-

内側の円錐はpenumbraプロパティを外側の円錐からの%として設定します。 -penumbra が1の時、ライトは円錐の中心から外側の円錐に向かってフェードしていきます。 -penumbra が5の時、ライトは外側の円錐体の中心から50%からフェードしていきます。

-
gui.add(light, 'penumbra', 0, 1, 0.01);
-
-

- -

-

デフォルトの penumbra が0の場合、集中光源は非常にシャープなエッジを持っていますが、1 に向けて penumbra を調整するとエッジがぼやけます。

-

集中光源の円錐体が見えにくいかもしれません。 -その理由は地面にあります。 -距離を5くらいまで縮めると、円錐体の端が開いているのが見えてきます。

-

RectAreaLight(矩形光源)

-

もう1種類のライトに RectAreaLight があります。 -これはまさにその名の通り、長い蛍光灯のような長方形のエリアのライトや天井にある曇り空のライトです。

-

RectAreaLightMeshStandardMaterialMeshPhysicalMaterial でしか動作しないので、全てのマテリアルを MeshStandardMaterial に変更しましょう。

-
  ...
-
-  const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
--  const planeMat = new THREE.MeshPhongMaterial({
-+  const planeMat = new THREE.MeshStandardMaterial({
-    map: texture,
-    side: THREE.DoubleSide,
-  });
-  const mesh = new THREE.Mesh(planeGeo, planeMat);
-  mesh.rotation.x = Math.PI * -.5;
-  scene.add(mesh);
-}
-{
-  const cubeSize = 4;
-  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
-- const cubeMat = new THREE.MeshPhongMaterial({color: '#8AC'});
-+ const cubeMat = new THREE.MeshStandardMaterial({color: '#8AC'});
-  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
-  mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
-  scene.add(mesh);
-}
-{
-  const sphereRadius = 3;
-  const sphereWidthDivisions = 32;
-  const sphereHeightDivisions = 16;
-  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
--  const sphereMat = new THREE.MeshPhongMaterial({color: '#CA8'});
-+ const sphereMat = new THREE.MeshStandardMaterial({color: '#CA8'});
-  const mesh = new THREE.Mesh(sphereGeo, sphereMat);
-  mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
-  scene.add(mesh);
-}
-
-

RectAreaLight を使用するには、three.jsから追加のimportが必要です。 -ライトを可視化するために RectAreaLightHelper をimportします。

-
import * as THREE from 'three';
-+import {RectAreaLightUniformsLib} from 'three/addons/lights/RectAreaLightUniformsLib.js';
-+import {RectAreaLightHelper} from 'three/addons/helpers/RectAreaLightHelper.js';
-
-

RectAreaLightUniformsLib.init を呼び出します。

-
function main() {
-  const canvas = document.querySelector('#c');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+  RectAreaLightUniformsLib.init();
-
-

RectAreaLightUniformsLibを忘れてもライトは動作しますが、見た目がおかしくなるので忘れないようにして下さい。

-

これでライトを作れました。

-
const color = 0xFFFFFF;
-*const intensity = 5;
-+const width = 12;
-+const height = 4;
-*const light = new THREE.RectAreaLight(color, intensity, width, height);
-light.position.set(0, 10, 0);
-+light.rotation.x = THREE.MathUtils.degToRad(-90);
-scene.add(light);
-
-*const helper = new RectAreaLightHelper(light);
-*light.add(helper);
-
-

注意すべき点は DirectionalLightSpotLight と異なり、RectAreaLight はターゲットを使いません。 -その回転を利用しているだけです。 -もう1つ気をつける事は、ヘルパーがライトの子である必要があります。 -他のヘルパーのようにシーンの子ではありません。

-

GUIも調整してみましょう。 -ライトを回転させて widthheight を調整できるようにします。

-
const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-gui.add(light, 'intensity', 0, 10, 0.01);
-gui.add(light, 'width', 0, 20);
-gui.add(light, 'height', 0, 20);
-gui.add(new DegRadHelper(light.rotation, 'x'), 'value', -180, 180).name('x rotation');
-gui.add(new DegRadHelper(light.rotation, 'y'), 'value', -180, 180).name('y rotation');
-gui.add(new DegRadHelper(light.rotation, 'z'), 'value', -180, 180).name('z rotation');
-
-makeXYZGUI(gui, light.position, 'position');
-
-

そして、これが結果です。

-

- -

- -

シーンにライトを追加するたびに、Three.jsのレンダリング速度が遅くなる事に注意して下さい。

-

次はカメラの扱い方についてです。

-

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/load-gltf.html b/manual/ja/load-gltf.html deleted file mode 100644 index 7dd15962a0d1ac..00000000000000 --- a/manual/ja/load-gltf.html +++ /dev/null @@ -1,642 +0,0 @@ - - - でGLFTファイルを読み込む - - - - - - - - - - - - - -
-
-

でGLFTファイルを読み込む

-
-
-
-

前回のレッスンはOBJファイルの読み込みでした。 -まだ読んでいない方は、まずそちらをチェックしてみて下さい。

-

前回の記事で指摘したように、OBJファイルフォーマットは非常に古くかなり単純です。 -OBJから読み込まれたデータはシーングラフを提供しないため、全て1つの大きなメッシュになります。 -OBJは3Dエディタ間でデータを渡す単純な方法として設計されました。

-

gLTFフォーマットはグラフィックを表示するために設計されたフォーマットです。 -3Dフォーマットは基本的に3~4種類に分けられます。

-
    -
  • 3Dエディタのフォーマット

    -

    これは1つのアプリ固有フォーマットです。.blend (Blender)、.max (3d Studio Max)、.mb、.ma (Maya) など。

    -
  • -
  • 交換フォーマット

    -

    これはOBJ、DAE(Collada)、FBXのようなフォーマットです。 -3Dエディタ間の情報交換をサポートするように設計されています。 -このフォーマットは通常は3Dエディタの内部でのみ使用される追加情報を持ち、必要以上に大きくなります。

    -
  • -
  • アプリのフォーマット

    -

    特定のアプリ、通常はゲームに特化したフォーマットです。

    -
  • -
  • 伝送フォーマット

    -

    gLTFは初めての伝送フォーマットかもしれません。 -VRMLも伝送フォーマットかもしれませんが、VRMLはかなり貧弱なフォーマットでした。

    -

    gLTFは他の全てのフォーマットではできない、以下の事ができるように設計されています。

    -
      -
    1. 伝送を小さくする

      -

      頂点のような大きなデータの多くがバイナリで保存されています。 -gLTFファイルのダウンロード時、ゼロ処理でGPUにアップロードできます。 -その機能は用意されています。 -VRML、OBJ、またはDAEファイルのように頂点がテキストで保存され、解析が必要である事とは対照的です。テキストの頂点位置はバイナリよりも3倍から5倍の大きさです。

      -
    2. -
    3. レンダリングの準備ができている

      -

      これもおそらく他のフォーマット(アプリのフォーマット以外)とは違います。 -glTFファイル内のデータはレンダリングされる事が目的で、編集される事は目的としていません。 -レンダリングに重要でないデータは削除されています。 -ポリゴンが三角形に変換されています。 -マテリアルにはどこでも動くようにサポートされた値を持っています。

      -
    4. -
    -
  • -
-

最低限の手間でglTFファイルをダウンロードし表示できるように、特別に設計されています。 -他のどのフォーマットもこれができなかったので、この設計は良い結果になると信じましょう。

-

私は何を見せれば良いのか迷っていました。 -ある程度のレベルではgLTFファイルの読み込みと表示は、OBJファイルよりも簡単です。 -OBJとは異なり、gLTFではマテリアルはフォーマットの直接的な部分です。 -少なくともglTFファイルを読み込む例を見せるべきで、私が遭遇した問題をレビューすると皆さんが良い情報を得られるかもしれないと思いました。

-

ネットで検索したらantonmoekさんの低ポリゴンのシティモデルを見つけました。 -運が良ければ良い例になるように思えました。

-
- -

OBJファイルの読み込みのコード例から始めて、OBJを読み込むコードを削除し、GLTFを読み込むコードに置き換えました。

-

以前のOBJコードは

-
const mtlLoader = new MTLLoader();
-mtlLoader.loadMtl('resources/models/windmill/windmill-fixed.mtl', (mtl) => {
-  mtl.preload();
-  mtl.materials.Material.side = THREE.DoubleSide;
-  objLoader.setMaterials(mtl);
-  objLoader.load('resources/models/windmill/windmill.obj', (event) => {
-    const root = event.detail.loaderRootNode;
-    scene.add(root);
-    ...
-  });
-});
-
-

新しいGLTFのコードは

-
{
-  const gltfLoader = new GLTFLoader();
-  const url = 'resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf';
-  gltfLoader.load(url, (gltf) => {
-    const root = gltf.scene;
-    scene.add(root);
-    ...
-  });
-
-

自動フレーミングのコードは以前のままです。

-

また OBJLoader を取り除き GLTFLoader を含める必要があります。

-
-import {LoaderSupport} from 'three/addons/loaders/LoaderSupport.js';
--import {OBJLoader} from 'three/addons/loaders/OBJLoader.js';
--import {MTLLoader} from 'three/addons/loaders/MTLLoader.js';
-+import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
-
-

実行すると以下になりました。

-

- -

-

魔法だ!テクスチャーも含めて上手くいっています。

-

次に走り回る車をアニメーションしたかったので、シーンに車が別のエンティティとして設定されているか、それが使用できるように設定されているか確認する必要があります。

-

JavaScriptコンソールにシーングラフをダンプするコードを書いてみました。

-

シーングラフを表示するコードです。

-
function dumpObject(obj, lines = [], isLast = true, prefix = '') {
-  const localPrefix = isLast ? '└─' : '├─';
-  lines.push(`${prefix}${prefix ? localPrefix : ''}${obj.name || '*no-name*'} [${obj.type}]`);
-  const newPrefix = prefix + (isLast ? '  ' : '│ ');
-  const lastNdx = obj.children.length - 1;
-  obj.children.forEach((child, ndx) => {
-    const isLast = ndx === lastNdx;
-    dumpObject(child, lines, isLast, newPrefix);
-  });
-  return lines;
-}
-
-

そして、シーンを読み込み直後に呼び出します。

-
const gltfLoader = new GLTFLoader();
-gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
-  const root = gltf.scene;
-  scene.add(root);
-  console.log(dumpObject(root).join('\n'));
-
-

実行すると このようなリストが得られました。

-
OSG_Scene [Scene]
-  └─RootNode_(gltf_orientation_matrix) [Object3D]
-    └─RootNode_(model_correction_matrix) [Object3D]
-      └─4d4100bcb1c640e69699a87140df79d7fbx [Object3D]
-        └─RootNode [Object3D]
-          │ ...
-          ├─Cars [Object3D]
-          │ ├─CAR_03_1 [Object3D]
-          │ │ └─CAR_03_1_World_ap_0 [Mesh]
-          │ ├─CAR_03 [Object3D]
-          │ │ └─CAR_03_World_ap_0 [Mesh]
-          │ ├─Car_04 [Object3D]
-          │ │ └─Car_04_World_ap_0 [Mesh]
-          │ ├─CAR_03_2 [Object3D]
-          │ │ └─CAR_03_2_World_ap_0 [Mesh]
-          │ ├─Car_04_1 [Object3D]
-          │ │ └─Car_04_1_World_ap_0 [Mesh]
-          │ ├─Car_04_2 [Object3D]
-          │ │ └─Car_04_2_World_ap_0 [Mesh]
-          │ ├─Car_04_3 [Object3D]
-          │ │ └─Car_04_3_World_ap_0 [Mesh]
-          │ ├─Car_04_4 [Object3D]
-          │ │ └─Car_04_4_World_ap_0 [Mesh]
-          │ ├─Car_08_4 [Object3D]
-          │ │ └─Car_08_4_World_ap8_0 [Mesh]
-          │ ├─Car_08_3 [Object3D]
-          │ │ └─Car_08_3_World_ap9_0 [Mesh]
-          │ ├─Car_04_1_2 [Object3D]
-          │ │ └─Car_04_1_2_World_ap_0 [Mesh]
-          │ ├─Car_08_2 [Object3D]
-          │ │ └─Car_08_2_World_ap11_0 [Mesh]
-          │ ├─CAR_03_1_2 [Object3D]
-          │ │ └─CAR_03_1_2_World_ap_0 [Mesh]
-          │ ├─CAR_03_2_2 [Object3D]
-          │ │ └─CAR_03_2_2_World_ap_0 [Mesh]
-          │ ├─Car_04_2_2 [Object3D]
-          │ │ └─Car_04_2_2_World_ap_0 [Mesh]
-          ...
-
-

この結果から全ての車が "Cars" という名前の親の下にある事がわかります。

-
*          ├─Cars [Object3D]
-          │ ├─CAR_03_1 [Object3D]
-          │ │ └─CAR_03_1_World_ap_0 [Mesh]
-          │ ├─CAR_03 [Object3D]
-          │ │ └─CAR_03_World_ap_0 [Mesh]
-          │ ├─Car_04 [Object3D]
-          │ │ └─Car_04_World_ap_0 [Mesh]
-
-

そこで簡単なテストとして、"Cars"ノードの全ての子をY軸の周りに回転させようと思いました。

-

シーンを読み込み後に"Cars"ノードを調べて結果を保存しました。

-
+let cars;
-{
-  const gltfLoader = new GLTFLoader();
-  gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
-    const root = gltf.scene;
-    scene.add(root);
-+    cars = root.getObjectByName('Cars');
-
-

render 関数で cars の全ての子の回転を設定します。

-
+function render(time) {
-+  time *= 0.001;  // convert to seconds
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-+  if (cars) {
-+    for (const car of cars.children) {
-+      car.rotation.y = time;
-+    }
-+  }
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-

これが結果です。

-

- -

-

うーん、残念ながら子のオリジンが回転目的のために設定されておらず、アニメーション用に設計されてないようです。トラックの回転方向が間違っています。

-

3Dで何かする場合の重要なポイントは、事前に計画を立ててアセットを設計する必要があります。アセットを正しい場所に配置し、正しいスケールになるようにデザインします。

-

私は3DCGアーティストではなくBlenderもよく知らないので、ハックしてみます。 -それぞれの車で別の Object3D を親にします。 -次に Object3D オブジェクトを移動させて車を移動させます。 -個々に車のオリジンに Object3D を設定して向きを変えられるので、必要な位置に設定する事ができます。

-

シーングラフのリストを見返すと "Car_08", "CAR_03", "Car_04" の3種類しかないようです。上手くいけば3種類の調整で済みます。

-

コードを以下のように変更しました。 -それぞれの車でインスタンス作成した Object3D を親にし、その Object3D をシーンに追加します。 -車種ごとの設定で向きを固定し、車の親の Object3Dcars 配列を追加します。

-
-let cars;
-+const cars = [];
-{
-  const gltfLoader = new GLTFLoader();
-  gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
-    const root = gltf.scene;
-    scene.add(root);
-
--    cars = root.getObjectByName('Cars');
-+    const loadedCars = root.getObjectByName('Cars');
-+    const fixes = [
-+      { prefix: 'Car_08', rot: [Math.PI * .5, 0, Math.PI * .5], },
-+      { prefix: 'CAR_03', rot: [0, Math.PI, 0], },
-+      { prefix: 'Car_04', rot: [0, Math.PI, 0], },
-+    ];
-+
-+    root.updateMatrixWorld();
-+    for (const car of loadedCars.children.slice()) {
-+      const fix = fixes.find(fix => car.name.startsWith(fix.prefix));
-+      const obj = new THREE.Object3D();
-+      car.getWorldPosition(obj.position);
-+      car.position.set(0, 0, 0);
-+      car.rotation.set(...fix.rot);
-+      obj.add(car);
-+      scene.add(obj);
-+      cars.push(obj);
-+    }
-     ...
-
-

これで車の向きを修正しました。

-

- -

-

さあ、追い回してみましょう。

-

簡単なドライビングシステムを作るのはこの記事では無理がありますが、 -その代わりに入り組んだパスを1本にし、そのパスに車を乗せる事ができそうです。 -以下の画像はBlenderでパスを作っている途中です。

-
- -

Blenderからパスのデータを取り出す方法が必要でした。 -幸運な事にパスだけを選択し、"write nurbs"をチェックしてobjをエクスポートできました。

-
- -

OBJファイルを開くと頂点のリストを得る事ができました。

-
const controlPoints = [
-  [1.118281, 5.115846, -3.681386],
-  [3.948875, 5.115846, -3.641834],
-  [3.960072, 5.115846, -0.240352],
-  [3.985447, 5.115846, 4.585005],
-  [-3.793631, 5.115846, 4.585006],
-  [-3.826839, 5.115846, -14.736200],
-  [-14.542292, 5.115846, -14.765865],
-  [-14.520929, 5.115846, -3.627002],
-  [-5.452815, 5.115846, -3.634418],
-  [-5.467251, 5.115846, 4.549161],
-  [-13.266233, 5.115846, 4.567083],
-  [-13.250067, 5.115846, -13.499271],
-  [4.081842, 5.115846, -13.435463],
-  [4.125436, 5.115846, -5.334928],
-  [-14.521364, 5.115846, -5.239871],
-  [-14.510466, 5.115846, 5.486727],
-  [5.745666, 5.115846, 5.510492],
-  [5.787942, 5.115846, -14.728308],
-  [-5.423720, 5.115846, -14.761919],
-  [-5.373599, 5.115846, -3.704133],
-  [1.004861, 5.115846, -3.641834],
-];
-
-

THREE.jsにはいくつかの曲線クラスがあります。 -CatmullRomCurve3 が使えそうな気がしたので CatmullRomCurve3 を使ってみた。 -この曲線は頂点を通る滑らかな曲線を作ります。

-

実際にはこれらの頂点を直接入力すると次のような曲線が生成されます。

-
- -

しかし、もっと角をシャープにして欲しいです。 -いくつかの頂点を追加し計算すれば、望んだ角度が得られる気がします。 -各頂点のペアで2つのポイントの間の10%と90%の道を計算し、その結果を CatmullRomCurve3 に渡します。

-

このような曲線が得られます。

-
- -

曲線を作成するコードは以下の通りです。

-
let curve;
-let curveObject;
-{
-  const controlPoints = [
-    [1.118281, 5.115846, -3.681386],
-    [3.948875, 5.115846, -3.641834],
-    [3.960072, 5.115846, -0.240352],
-    [3.985447, 5.115846, 4.585005],
-    [-3.793631, 5.115846, 4.585006],
-    [-3.826839, 5.115846, -14.736200],
-    [-14.542292, 5.115846, -14.765865],
-    [-14.520929, 5.115846, -3.627002],
-    [-5.452815, 5.115846, -3.634418],
-    [-5.467251, 5.115846, 4.549161],
-    [-13.266233, 5.115846, 4.567083],
-    [-13.250067, 5.115846, -13.499271],
-    [4.081842, 5.115846, -13.435463],
-    [4.125436, 5.115846, -5.334928],
-    [-14.521364, 5.115846, -5.239871],
-    [-14.510466, 5.115846, 5.486727],
-    [5.745666, 5.115846, 5.510492],
-    [5.787942, 5.115846, -14.728308],
-    [-5.423720, 5.115846, -14.761919],
-    [-5.373599, 5.115846, -3.704133],
-    [1.004861, 5.115846, -3.641834],
-  ];
-  const p0 = new THREE.Vector3();
-  const p1 = new THREE.Vector3();
-  curve = new THREE.CatmullRomCurve3(
-    controlPoints.map((p, ndx) => {
-      p0.set(...p);
-      p1.set(...controlPoints[(ndx + 1) % controlPoints.length]);
-      return [
-        (new THREE.Vector3()).copy(p0),
-        (new THREE.Vector3()).lerpVectors(p0, p1, 0.1),
-        (new THREE.Vector3()).lerpVectors(p0, p1, 0.9),
-      ];
-    }).flat(),
-    true,
-  );
-  {
-    const points = curve.getPoints(250);
-    const geometry = new THREE.BufferGeometry().setFromPoints(points);
-    const material = new THREE.LineBasicMaterial({color: 0xff0000});
-    curveObject = new THREE.Line(geometry, material);
-    scene.add(curveObject);
-  }
-}
-
-

このコードの最初の部分は曲線を作ります。 -コードの2番目の部分は曲線から250頂点を生成し、その250頂点を結んで作られた線を表示するオブジェクトを作成します。

-

このサンプルを実行してもカーブが見えませんでした。 -見えるようにするために、深度テストを無視して最後にレンダリングするようにしました。

-
    curveObject = new THREE.Line(geometry, material);
-+    material.depthTest = false;
-+    curveObject.renderOrder = 1;
-
-

そして、カーブがあまりにも小さすぎる事に気がつきました。

-
- -

Blenderで階層を確認してみると、3DCGアーティストが全ての親となるノードをスケーリングしていました。

-
- -

リアルタイムの3Dアプリではスケーリングが悪です。 -リアルタイム3Dを行う際には、様々な問題が発生し不満が尽きません。 -3Dアプリ開発ではシーン全体を拡大縮小するのはとても簡単ですが、3DCGアーティストはこれを知らない事が多いです。 -もしあなたがリアルタイムの3Dアプリを作ろうと決めたら、3DCGアーティストには絶対に何もスケールしないようにお願いする事をお勧めします。 -スケールを変更する場合は、そのスケールを頂点に適用する方法を見つける必要があります。

-

スケールだけでなく、その親である Cars ノードによって回転されオフセットされます。 -これでは実行時にワールド座標での車の移動が困難になります。 -はっきり言うと、この場合はワールド座標で車を走らせたいのでこの問題が出てきます。 -月が地球を中心に回転しているようなローカル座標で操作される場合、これはあまり問題ではありません。

-

上記で書いたシーングラフをダンプする関数に戻り、各ノードの位置、回転、スケールをダンプしてみましょう。

-
+function dumpVec3(v3, precision = 3) {
-+  return `${v3.x.toFixed(precision)}, ${v3.y.toFixed(precision)}, ${v3.z.toFixed(precision)}`;
-+}
-
-function dumpObject(obj, lines, isLast = true, prefix = '') {
-  const localPrefix = isLast ? '└─' : '├─';
-  lines.push(`${prefix}${prefix ? localPrefix : ''}${obj.name || '*no-name*'} [${obj.type}]`);
-+  const dataPrefix = obj.children.length
-+     ? (isLast ? '  │ ' : '│ │ ')
-+     : (isLast ? '    ' : '│   ');
-+  lines.push(`${prefix}${dataPrefix}  pos: ${dumpVec3(obj.position)}`);
-+  lines.push(`${prefix}${dataPrefix}  rot: ${dumpVec3(obj.rotation)}`);
-+  lines.push(`${prefix}${dataPrefix}  scl: ${dumpVec3(obj.scale)}`);
-  const newPrefix = prefix + (isLast ? '  ' : '│ ');
-  const lastNdx = obj.children.length - 1;
-  obj.children.forEach((child, ndx) => {
-    const isLast = ndx === lastNdx;
-    dumpObject(child, lines, isLast, newPrefix);
-  });
-  return lines;
-}
-
-

それを実行した結果です。

-
OSG_Scene [Scene]
-  │   pos: 0.000, 0.000, 0.000
-  │   rot: 0.000, 0.000, 0.000
-  │   scl: 1.000, 1.000, 1.000
-  └─RootNode_(gltf_orientation_matrix) [Object3D]
-    │   pos: 0.000, 0.000, 0.000
-    │   rot: -1.571, 0.000, 0.000
-    │   scl: 1.000, 1.000, 1.000
-    └─RootNode_(model_correction_matrix) [Object3D]
-      │   pos: 0.000, 0.000, 0.000
-      │   rot: 0.000, 0.000, 0.000
-      │   scl: 1.000, 1.000, 1.000
-      └─4d4100bcb1c640e69699a87140df79d7fbx [Object3D]
-        │   pos: 0.000, 0.000, 0.000
-        │   rot: 1.571, 0.000, 0.000
-        │   scl: 1.000, 1.000, 1.000
-        └─RootNode [Object3D]
-          │   pos: 0.000, 0.000, 0.000
-          │   rot: 0.000, 0.000, 0.000
-          │   scl: 1.000, 1.000, 1.000
-          ├─Cars [Object3D]
-*          │ │   pos: -369.069, -90.704, -920.159
-*          │ │   rot: 0.000, 0.000, 0.000
-*          │ │   scl: 1.000, 1.000, 1.000
-          │ ├─CAR_03_1 [Object3D]
-          │ │ │   pos: 22.131, 14.663, -475.071
-          │ │ │   rot: -3.142, 0.732, 3.142
-          │ │ │   scl: 1.500, 1.500, 1.500
-          │ │ └─CAR_03_1_World_ap_0 [Mesh]
-          │ │       pos: 0.000, 0.000, 0.000
-          │ │       rot: 0.000, 0.000, 0.000
-          │ │       scl: 1.000, 1.000, 1.000
-
-

これは元のシーンの Cars の回転とスケールが削除され、子に適用されています。 -この原因は、GLTFファイルを作成するために使用されたエクスポータが何か特別な作業をしたか、または3DCGアーティストが対応するblendファイルとは異なるバージョンのファイルをエクスポートした可能性が高いです。

-

この事から学ぶ教訓は、blendファイルをダウンロードして自分でエクスポートするべきという事です。 -エクスポートする前に主要なノードを全て検査し、変換を削除しておくべきでした。

-

Topにあるこれら全てのノードは以下の通りです。

-
OSG_Scene [Scene]
-  │   pos: 0.000, 0.000, 0.000
-  │   rot: 0.000, 0.000, 0.000
-  │   scl: 1.000, 1.000, 1.000
-  └─RootNode_(gltf_orientation_matrix) [Object3D]
-    │   pos: 0.000, 0.000, 0.000
-    │   rot: -1.571, 0.000, 0.000
-    │   scl: 1.000, 1.000, 1.000
-    └─RootNode_(model_correction_matrix) [Object3D]
-      │   pos: 0.000, 0.000, 0.000
-      │   rot: 0.000, 0.000, 0.000
-      │   scl: 1.000, 1.000, 1.000
-      └─4d4100bcb1c640e69699a87140df79d7fbx [Object3D]
-        │   pos: 0.000, 0.000, 0.000
-        │   rot: 1.571, 0.000, 0.000
-        │   scl: 1.000, 1.000, 1.000
-
-

この階層構造はもったいないですね。

-

理想的にはシーンは位置、回転、スケールのない単一の"ルート"ノードで構成される事です。 -実行時にはそのルートから全ての子を取り出し、シーン自体を親にする事ができます。 -"Cars"のようなルートの子があるかもしれませんが、全ての車を見つけるのに役立ちます。理想的には平行移動、回転、スケールがないので、最小限の作業でシーンを車の親にする事ができます。

-

いずれにしても、カーブを表示するために使用しているオブジェクトを調整するのが一番手っ取り早いのですが、最善の方法ではないかもしれません。

-

結局はこんな感じですね。

-

最初にカーブの位置を調整し、上手くいきそうな値を見つけました。そして隠しました。

-
{
-  const points = curve.getPoints(250);
-  const geometry = new THREE.BufferGeometry().setFromPoints(points);
-  const material = new THREE.LineBasicMaterial({color: 0xff0000});
-  curveObject = new THREE.Line(geometry, material);
-+  curveObject.scale.set(100, 100, 100);
-+  curveObject.position.y = -621;
-+  curveObject.visible = false;
-  material.depthTest = false;
-  curveObject.renderOrder = 1;
-  scene.add(curveObject);
-}
-
-

それからカーブに沿って移動させるコードを書きました。 -それぞれの車で曲線に沿って0から1までの位置を選び curveObject を用いてワールド座標上の頂点を計算して変換します。 -次にカーブの少し下の方にある別の頂点を選びます。 -車の向きを lookAt で設定し、2点間の中間点に車を配置します。

-
// create 2 Vector3s we can use for path calculations
-const carPosition = new THREE.Vector3();
-const carTarget = new THREE.Vector3();
-
-function render(time) {
-  ...
-
--  for (const car of cars) {
--    car.rotation.y = time;
--  }
-
-+  {
-+    const pathTime = time * .01;
-+    const targetOffset = 0.01;
-+    cars.forEach((car, ndx) => {
-+      // a number between 0 and 1 to evenly space the cars
-+      const u = pathTime + ndx / cars.length;
-+
-+      // get the first point
-+      curve.getPointAt(u % 1, carPosition);
-+      carPosition.applyMatrix4(curveObject.matrixWorld);
-+
-+      // get a second point slightly further down the curve
-+      curve.getPointAt((u + targetOffset) % 1, carTarget);
-+      carTarget.applyMatrix4(curveObject.matrixWorld);
-+
-+      // put the car at the first point (temporarily)
-+      car.position.copy(carPosition);
-+      // point the car the second point
-+      car.lookAt(carTarget);
-+
-+      // put the car between the 2 points
-+      car.position.lerpVectors(carPosition, carTarget, 0.5);
-+    });
-+  }
-
-

そしてそれを実行すると、それぞれの車のオリジンの上に高さが一貫して設定されていなかったので、少しオフセットする必要がありました。

-
const loadedCars = root.getObjectByName('Cars');
-const fixes = [
--  { prefix: 'Car_08', rot: [Math.PI * .5, 0, Math.PI * .5], },
--  { prefix: 'CAR_03', rot: [0, Math.PI, 0], },
--  { prefix: 'Car_04', rot: [0, Math.PI, 0], },
-+  { prefix: 'Car_08', y: 0,  rot: [Math.PI * .5, 0, Math.PI * .5], },
-+  { prefix: 'CAR_03', y: 33, rot: [0, Math.PI, 0], },
-+  { prefix: 'Car_04', y: 40, rot: [0, Math.PI, 0], },
-];
-
-root.updateMatrixWorld();
-for (const car of loadedCars.children.slice()) {
-  const fix = fixes.find(fix => car.name.startsWith(fix.prefix));
-  const obj = new THREE.Object3D();
-  car.getWorldPosition(obj.position);
--  car.position.set(0, 0, 0);
-+  car.position.set(0, fix.y, 0);
-  car.rotation.set(...fix.rot);
-  obj.add(car);
-  scene.add(obj);
-  cars.push(obj);
-}
-
-

その結果です。

-

- -

-

数分間の作業にしては悪くない結果です。

-

最後にやりたいのはシャドウをつける事です。

-

これを行うためにシャドウの記事にある DirectionalLight の例から全てのGUIコードを取得し、最新のコードに貼り付けました。

-

読み込み後に全てのオブジェクトにシャドウをオンにする必要があります。

-
{
-  const gltfLoader = new GLTFLoader();
-  gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
-    const root = gltf.scene;
-    scene.add(root);
-
-+    root.traverse((obj) => {
-+      if (obj.castShadow !== undefined) {
-+        obj.castShadow = true;
-+        obj.receiveShadow = true;
-+      }
-+    });
-
-

シャドウヘルパーが機能していない理由を探るのに4時間近くを費やしました。 -シャドウを有効にするのを忘れていたのが原因でした。

-
renderer.shadowMap.enabled = true;
-
-

😭

-

そのあと、DirectionLight のシャドウカメラがシーン全体を覆うようになるまで値を調整しました。 -これらの設定は私が最終的にたどり着いたものです。

-
{
-  const color = 0xFFFFFF;
-  const intensity = 1;
-  const light = new THREE.DirectionalLight(color, intensity);
-+  light.castShadow = true;
-*  light.position.set(-250, 800, -850);
-*  light.target.position.set(-550, 40, -450);
-
-+  light.shadow.bias = -0.004;
-+  light.shadow.mapSize.width = 2048;
-+  light.shadow.mapSize.height = 2048;
-
-  scene.add(light);
-  scene.add(light.target);
-+  const cam = light.shadow.camera;
-+  cam.near = 1;
-+  cam.far = 2000;
-+  cam.left = -1500;
-+  cam.right = 1500;
-+  cam.top = 1500;
-+  cam.bottom = -1500;
-...
-
-

また、背景色を水色に設定してみました。

-
const scene = new THREE.Scene();
--scene.background = new THREE.Color('black');
-+scene.background = new THREE.Color('#DEFEFF');
-
-

そして、シャドウをつけた結果です。

-

- -

-

このプロジェクトを説明する事で、シーングラフを使ってファイルを読込する際の問題点・解決事例をいくつか示せたと思います。

-

興味深いのはblendファイルとgltfファイルの場合、blendファイルにはいくつかのライトがありますが、シーンに読み込み後はライトがありません。 -GLTFファイルはただのJSONファイルなので簡単に中身を見れます。 -これは複数の配列から成り、配列内の各項目は他の場所にインデックスを付けて参照されます。 -拡張機能がありますが、ほとんど全ての3Dフォーマットの問題を指摘しています。全てのケースをカバーする事はできません。

-

もっとデータが欲しいというニーズは常にあります。 -例えば今回は手動で車のパスをエクスポートしました。 -理想的にはGLTFファイルに情報を格納する事ができますが、独自のエクスポーターを作成しエクスポートしたい方法でノードをマークするか、命名スキームなどを使用してデータを作成するために使用しているツールからアプリにデータを取得する必要があります。

-

その全ては読者の皆様の演習としてお任せします。

- -
-
-
- - - - - - - - diff --git a/manual/ja/load-obj.html b/manual/ja/load-obj.html deleted file mode 100644 index 7a14e6455a244d..00000000000000 --- a/manual/ja/load-obj.html +++ /dev/null @@ -1,575 +0,0 @@ - - - でOBJファイルを読み込む - - - - - - - - - - - - - -
-
-

でOBJファイルを読み込む

-
-
-
-

みんながthree.jsでやりたい事の1つに、3Dモデルをロードして表示があります。 -一般的な3DフォーマットであるOBJファイルを読み込んでみましょう。

-

ネットで検索しahedovさんのCC-BY-NC 3.0 風車3Dモデルを見つけました。

-
- -

blendファイルをダウンロードしBlenderで読み込んでOBJファイルを書き出してみました。

-
- -
-

注意:Blenderを使った事がない人は、Blenderは今まで使ってきた他のプログラムとは異なり驚くかもしれません。また、Blenderの基本的なUI操作を理解する時間が必要かもしれません。

-

一般的な3Dプログラムは、1000以上の機能を持つ巨大なモンスターである事も付け加えておきましょう。Blenderもその中の最も複雑なソフトウェアの1つです。 -私が1996年に3D Studio Maxを初めて知った時、600ページのマニュアルの70%を3週間ほど1日数時間かけて読み通しました。 -数年後にMayaを学んだ時には、3d Studio Maxで学んだ経験がMayaでも生かせました。 -もし本当に3Dソフトウェアを使って3Dアセットを構築したり、既存のものを修正したりできるようになりたいなら、自分のスケジュールと時間を確保していくつかのレッスンを受ける事をお勧めします。

-
-

いずれにしても、私は以下のExportオプションを使用しました。

-
- -

それでは表示してみましょう!

-

ライティングの記事 にあるディレクショナルライティングの例から始めて、半球ライティングの例と組み合わせて HemisphereLightDirectionalLight を1つ作る事にしました。その結果として HemisphereLight は1つ、DirectionalLight は1つになりました。 -また、ライトの調整に関連する全てのGUIを削除しました。シーンに追加していたキューブとスフィアも削除しました。

-

まず最初に OBJLoader のローダーをコードに含める必要があります。

-
import {OBJLoader} from 'three/addons/loaders/OBJLoader.js';
-
-

次にOBJファイルをロードするために OBJLoader のインスタンスを作成し、OBJファイルのURLを渡し、ロードされたモデルをシーンに追加するコールバックを渡します。

-
{
-  const objLoader = new OBJLoader();
-  objLoader.load('resources/models/windmill/windmill.obj', (root) => {
-    scene.add(root);
-  });
-}
-
-

それを実行したらどうなりますか?

-

- -

-

これはやりたい事に近いですが、シーンにマテリアルとOBJファイルにマテリアルのパラメーターがなく、マテリアルのエラーが発生しています。

-

OBJローダーには名前とマテリアルのペアのオブジェクトを渡す事ができます。 -OBJファイルをロードした時に見つけたマテリアル名で、ローダーに設定されたマテリアルのマップ内で対応するマテリアルを探します。 -マテリアル名で一致するものが見つかった場合はそのマテリアルを使用します。 -見つからない場合はローダーのデフォルトマテリアルを使用します。

-

OBJファイルにはマテリアルを定義するMTLファイルが付属している事があります。 -今回はエクスポーターでMTLファイルも作成しました。 -MTL形式はプレーンなASCIIコードなので見やすいです。MTLファイルの中身を見てみると

-
# Blender MTL File: 'windmill_001.blend'
-# Material Count: 2
-
-newmtl Material
-Ns 0.000000
-Ka 1.000000 1.000000 1.000000
-Kd 0.800000 0.800000 0.800000
-Ks 0.000000 0.000000 0.000000
-Ke 0.000000 0.000000 0.000000
-Ni 1.000000
-d 1.000000
-illum 1
-map_Kd windmill_001_lopatky_COL.jpg
-map_Bump windmill_001_lopatky_NOR.jpg
-
-newmtl windmill
-Ns 0.000000
-Ka 1.000000 1.000000 1.000000
-Kd 0.800000 0.800000 0.800000
-Ks 0.000000 0.000000 0.000000
-Ke 0.000000 0.000000 0.000000
-Ni 1.000000
-d 1.000000
-illum 1
-map_Kd windmill_001_base_COL.jpg
-map_Bump windmill_001_base_NOR.jpg
-map_Ns windmill_001_base_SPEC.jpg
-
-

5つのjpgテクスチャを参照しているマテリアルが2つありますが、テクスチャのファイルはどこにあるのでしょうか?

-
- -

存在するのはOBJファイルとMTLファイルだけです。

-

このモデルではテクスチャはダウンロードしたblendファイルに埋め込まれている事が判明しました。 -blenderで File->External Data->Unpack All Into Files を選択し、これらのファイルをエクスポートする事ができます。

-
- -

そして Write Files to Current Directory を選択します。

-
- -

これでテクスチャのファイルはblendファイルと同じフォルダ内の textures というサブフォルダに出力されます。

-
- -

これらのテクスチャをOBJファイルと同じフォルダにコピーしました。

-
- -

テクスチャを利用できるようになったのでMTLファイルをロードします。 -MTLLoader をimportする必要があります。

-
import * as THREE from 'three';
-import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-import {OBJLoader} from 'three/addons/loaders/OBJLoader.js';
-+import {MTLLoader} from 'three/addons/loaders/MTLLoader.js';
-
-

まず、MTLファイルをロードします。 -読込後にロードしたマテリアルを OBJLoaderに設定して、OBJLoader でOBJファイルをロードします。

-
{
-+  const mtlLoader = new MTLLoader();
-+  mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => {
-+    mtl.preload();
-+    objLoader.setMaterials(mtl);
-    objLoader.load('resources/models/windmill/windmill.obj', (root) => {
-      scene.add(root);
-    });
-+  });
-}
-
-

それを試してみると...

-

- -

-

モデルを回転させると風車の布が消える事に注意して下さい。

-
- -

風車の羽根のマテリアルは両面に適用する必要があり、これはマテリアルの記事で説明しました。 -MTLファイルを簡単に修正する方法はありません。 -私の思いつきではこの問題を修正する3つの方法があります。

-
    -
  1. マテリアルの読込後、全てのマテリアルをループさせて両面を適用する

    -
     const mtlLoader = new MTLLoader();
    - mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => {
    -   mtl.preload()
    -   for (const material of Object.values(mtl.materials)) {
    -     material.side = THREE.DoubleSide;
    -   }
    -   ...
    -

    この解決策は動作しますが、理想的には両面描画は片面描画よりも遅く、両面描画が必要なマテリアルだけを両面にしたいです。

    -
  2. -
  3. 特定のマテリアルを手動で設定する

    -

    MTLファイルを見ると2つのマテリアルがあります。 -1つは "winddmill" と呼び、もう1つは "Material" と呼びます。 -試行錯誤の結果、風車の羽根は "Material" というマテリアル名を使う事が分かりました。

    -
     const mtlLoader = new MTLLoader();
    - mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => {
    -   mtl.perload();
    -   mtl.materials.Material.side = THREE.DoubleSide;
    -   ...
    -
  4. -
  5. MTLファイルには制限がある事に気付き、MTLファイルを使わず自前でマテリアルを作成する

    -
     objLoader.load('resources/models/windmill/windmill.obj', (root) => {
    -   const materials = {
    -     Material: new THREE.MeshPhongMaterial({...}),
    -     windmill: new THREE.MeshPhongMaterial({...}),
    -   };
    -   root.traverse(node => {
    -     const material = materials[node.material?.name];
    -     if (material) {
    -       node.material = material;
    -     }
    -   })
    -   scene.add(root);
    - });
    -
  6. -
-

どれを選ぶかはあなた次第です。 -1が1番簡単です。3が最も柔軟です。2はその中間で今回は2を選びます。

-

この変更で背面から見た時にはまだ風車の羽根に布が見えるはずですが、もう1つ問題があります。 -近くで拡大すると濃淡のむらがある事がわかります。

-
- -

これはどうしたんでしょう?

-

テクスチャを見てみると、ノーマルマップにはNORと書かれた2つのテクスチャがあります。 -これはノーマルマップのように見えます。 -ノーマルマップは一般的に紫色ですが、バンプマップは黒と白になっています。 -ノーマルマップはサーフェスの方向を表し、バンプマップはサーフェスの高さを表します。

-
- -

MTLLoaderのソースを見るとノーマルマップのキーワード norm を期待しているのでMTLファイルを編集してみましょう。

-
# Blender MTL File: 'windmill_001.blend'
-# Material Count: 2
-
-newmtl Material
-Ns 0.000000
-Ka 1.000000 1.000000 1.000000
-Kd 0.800000 0.800000 0.800000
-Ks 0.000000 0.000000 0.000000
-Ke 0.000000 0.000000 0.000000
-Ni 1.000000
-d 1.000000
-illum 1
-map_Kd windmill_001_lopatky_COL.jpg
--map_Bump windmill_001_lopatky_NOR.jpg
-+norm windmill_001_lopatky_NOR.jpg
-
-newmtl windmill
-Ns 0.000000
-Ka 1.000000 1.000000 1.000000
-Kd 0.800000 0.800000 0.800000
-Ks 0.000000 0.000000 0.000000
-Ke 0.000000 0.000000 0.000000
-Ni 1.000000
-d 1.000000
-illum 1
-map_Kd windmill_001_base_COL.jpg
--map_Bump windmill_001_base_NOR.jpg
-+norm windmill_001_base_NOR.jpg
-map_Ns windmill_001_base_SPEC.jpg
-
-

これでロードするとノーマルマップとして扱うようになり、風車の羽根の裏が描画されるようになりました。

-

- -

-

別のファイルを読み込んでみましょう。

-

ネットで検索するとRoger Gerzner / GERIZ.3D Artで作られたCC-BY-NCの風車の3Dモデルを見つけました。

-
- -

これにはOBJファイルが既にありました。 -それをロードしてみましょう(ここでMTLローダーを削除した事に注意して下さい)

-
-  objLoader.load('resources/models/windmill/windmill.obj', ...
-+  objLoader.load('resources/models/windmill-2/windmill.obj', ...
-
-

- -

-

うーん、何も出てこない...。何が問題でしょうか? -モデルのサイズはどれくらいなんだろう?

-

Three.jsにモデルのサイズを確認してカメラを自動設定してみます。

-

まず最初にThree.jsに先ほど読み込んだシーンを含むボックスを計算し、そのサイズと中心座標を確認してみましょう。

-
objLoader.load('resources/models/windmill_2/windmill.obj', (root) => {
-  scene.add(root);
-
-+  const box = new THREE.Box3().setFromObject(root);
-+  const boxSize = box.getSize(new THREE.Vector3()).length();
-+  const boxCenter = box.getCenter(new THREE.Vector3());
-+  console.log(boxSize);
-+  console.log(boxCenter);
-
-

JavaScriptコンソールを見ると

-
size 2123.6499788469982
-center p {x: -0.00006103515625, y: 770.0909731090069, z: -3.313507080078125}
-
-

現在カメラは near が0.1、far が100で約100ユニットしか表示されていません。 -地上は40ユニットしかなく、この風車のモデルは2000ユニットと非常に大きく、カメラとその全ての部分が錐台の外にあります。

-
- -

手動で修正することもできますが、シーンを自動でフレーム化する事もできます。

-

それを試してみましょう。 -先ほど計算したボックスを使いシーン全体を表示するためにカメラの設定を調整する事ができます。カメラをどこに置くかは 正解はない 事に注意して下さい。 -どの方向から見てもどの高さでも向き合う事ができるので何かを選ぶしかないですね。

-

カメラの記事で説明したようにカメラは錐台を定義します。 -視野 (fov) と nearfar の設定によって錐台が定義されます。 -カメラが現在持っている視野がどのようなものであっても、シーンが入っているボックスが画面外が永遠に伸びていると仮定して画面外の中に収まるように、カメラはどのくらい離れている必要があるのかを知りたいです。 -つまり near は0.00000001、far は無限大であるとすると near は0.00000001、far は無限大です。

-

ボックスの大きさと視野が分かっているので次のような三角形ができます。

-
- -

左側にカメラがあり、青い錐台が突き出しているのが分かります。 -風車が入っているボックスを計算してみました。 -ボックスが錐台の中に現れるように、カメラがボックスからどのくらい離れているかを計算する必要があります。

-

基本的な 直角三角形 の三角法とSOHCAHTOAを使用します。 -視野とボックスの大きさが分かっていれば 距離 を計算できます。

-
- -

この図に基づいて距離を計算する式は次のようになります。

-
distance = halfSizeToFitOnScreen / tangent(halfFovY)
-
-

コードに変換してみましょう。 -まずは 距離 を計算する関数を作り、ボックスの中心から 距離 単位でカメラを移動させてみましょう。 -次にカメラをボックスの 中心 に向けます。

-
function frameArea(sizeToFitOnScreen, boxSize, boxCenter, camera) {
-  const halfSizeToFitOnScreen = sizeToFitOnScreen * 0.5;
-  const halfFovY = THREE.MathUtils.degToRad(camera.fov * .5);
-  const distance = halfSizeToFitOnScreen / Math.tan(halfFovY);
-
-  // compute a unit vector that points in the direction the camera is now
-  // from the center of the box
-  const direction = (new THREE.Vector3()).subVectors(camera.position, boxCenter).normalize();
-
-  // move the camera to a position distance units way from the center
-  // in whatever direction the camera was from the center already
-  camera.position.copy(direction.multiplyScalar(distance).add(boxCenter));
-
-  // pick some near and far values for the frustum that
-  // will contain the box.
-  camera.near = boxSize / 100;
-  camera.far = boxSize * 100;
-
-  camera.updateProjectionMatrix();
-
-  // point the camera to look at the center of the box
-  camera.lookAt(boxCenter.x, boxCenter.y, boxCenter.z);
-}
-
-

2つのサイズを引数に渡しています。 -boxSizesizeToFitOnScreen の事です。 -boxSize を渡し sizeToFitOnScreen として使用すれば、計算でボックスが錐台の中に完全に収まるようになります。 -上下に少し余分なスペースが欲しいので少し大きめのサイズにします。

-
{
-  const objLoader = new OBJLoader();
-  objLoader.load('resources/models/windmill_2/windmill.obj', (root) => {
-    scene.add(root);
-+    // compute the box that contains all the stuff
-+    // from root and below
-+    const box = new THREE.Box3().setFromObject(root);
-+
-+    const boxSize = box.getSize(new THREE.Vector3()).length();
-+    const boxCenter = box.getCenter(new THREE.Vector3());
-+
-+    // set the camera to frame the box
-+    frameArea(boxSize * 1.2, boxSize, boxCenter, camera);
-+
-+    // update the Trackball controls to handle the new size
-+    controls.maxDistance = boxSize * 10;
-+    controls.target.copy(boxCenter);
-+    controls.update();
-  });
-}
-
-

上記の図のように boxSize * 1.2 を渡しボックスを錐台内に収める際にボックスの上下に20%のスペースを確保する事ができます。 -また OrbitControls を更新し、カメラがシーンの中心を周回するようにしました。

-

それを試してみると...

-

- -

-

これはほぼ動作してますね。 -カメラを回転させるためにマウスを使用し、風車が表示されるはずです。 -問題は風車が大きく、箱の中心が(0,770,0)くらいにあります。 -つまり、カメラをスタート地点(0, 10, 20)から中心から 距離 単位で移動させると、カメラは中心に対して相対的に風車の下をほぼ真下に移動しています。

-
- -

ボックスの中心からカメラのある方向に横に移動するように変更してみましょう。 -そのために必要なのはボックスからカメラまでのベクトルの y をゼロにする事です。 -ベクトルを正規化するとXZ平面に平行なベクトルになります。 -言い換えれば地面と平行になります。

-
-// compute a unit vector that points in the direction the camera is now
--// from the center of the box
--const direction = (new THREE.Vector3()).subVectors(camera.position, boxCenter).normalize();
-+// compute a unit vector that points in the direction the camera is now
-+// in the xz plane from the center of the box
-+const direction = (new THREE.Vector3())
-+    .subVectors(camera.position, boxCenter)
-+    .multiply(new THREE.Vector3(1, 0, 1))
-+    .normalize();
-
-

風車の底を見ると小さな四角いものが見えます。それが地上面です。

-
- -

40 × 40ユニットしかないので風車に比べて小さすぎます。 -風車の大きさが2000ユニットを超えているので、地上面の大きさをもっとピッタリしたものに変えてみましょう。 -また、テクスチャのrepeatを調整する必要があります。 -チェッカーボードはズームインしない限り、見る事さえできないような細かいものになります。

-
-const planeSize = 40;
-+const planeSize = 4000;
-
-const loader = new THREE.TextureLoader();
-const texture = loader.load('resources/images/checker.png');
-texture.wrapS = THREE.RepeatWrapping;
-texture.wrapT = THREE.RepeatWrapping;
-texture.magFilter = THREE.NearestFilter;
--const repeats = planeSize / 2;
-+const repeats = planeSize / 200;
-texture.repeat.set(repeats, repeats);
-
-

これで風車を見る事ができます。

-

- -

-

マテリアルを元に戻してみましょう。 -先ほどと同じようにテクスチャを参照しているMTLファイルがありますが、ファイルを見てみるとすぐに問題点が浮き上がります。

-
 $ ls -l windmill
- -rw-r--r--@ 1 gregg  staff       299 May 20  2009 windmill.mtl
- -rw-r--r--@ 1 gregg  staff    142989 May 20  2009 windmill.obj
- -rw-r--r--@ 1 gregg  staff  12582956 Apr 19  2009 windmill_diffuse.tga
- -rw-r--r--@ 1 gregg  staff  12582956 Apr 20  2009 windmill_normal.tga
- -rw-r--r--@ 1 gregg  staff  12582956 Apr 19  2009 windmill_spec.tga
-
-

TARGA(.tga)ファイルが巨大です!

-

Three.jsにはTGAローダーがありますが、ほとんどのユースケースでそれを使うのは間違いです。 -ネット上で見つけたランダムな3Dファイルを閲覧できるようなビューアを作っているのであれば、TGAファイルを読み込んだ方がいいかもしれません。(*)

-

TGAファイルの問題点は、全てを上手く圧縮できない事です。 -TGAは非常に単純な圧縮しかサポートしておらず、上記を見てみると全てのファイルが同じサイズになる確率が非常に低いため、圧縮されていない事がわかります。

-

さらにそれぞれが12MB!

-

もしTGAのファイルを使った場合、風車を見るために36MBのファイルをダウンロードしなければならないでしょう。

-

TGAのもう1つの問題はブラウザ自体がTGAをサポートしていない事です。 -TGAの読み込みはJPGやPNGのようなサポートされているフォーマットの読込よりも遅くなる可能性が高いです。

-

私はthree.jsで3Dモデルを表示するためには、TGAをJPGに変換する事が最善の選択肢であると確信しています。 -中身を見るとそれぞれ3チャンネル、RGBでアルファチャンネルはありません。 -JPGはダウンロードするためにファイルをロス有り圧縮を行い、少ないサイズのファイルダウンロードを提供します。

-

ファイルを読み込むと2048 x 2048サイズになっていました。 -私には無駄に大きいサイズに思えましたが、もちろん使用ケースによります。 -1024 x 1024にしPhotoshopで50%の画質設定で保存してみました。 -ファイルリストを取得すると

-
 $ ls -l ../threejs.org/manual/examples/resources/models/windmill
- -rw-r--r--@ 1 gregg  staff     299 May 20  2009 windmill.mtl
- -rw-r--r--@ 1 gregg  staff  142989 May 20  2009 windmill.obj
- -rw-r--r--@ 1 gregg  staff  259927 Nov  7 18:37 windmill_diffuse.jpg
- -rw-r--r--@ 1 gregg  staff   98013 Nov  7 18:38 windmill_normal.jpg
- -rw-r--r--@ 1 gregg  staff  191864 Nov  7 18:39 windmill_spec.jpg
-
-

36MEGから0.55MEGまで圧縮できました! -3DCGデザイナーなどのアーティストはこの圧縮に満足していないかもしれませんので、トレードオフについて相談するようにして下さい。

-

さてMTLファイルを使用するには、JPGファイルを参照するように編集する必要があります。 -TGAファイルの代わりにJPGファイルを使用します。 -幸いな事にこれは単純なテキストファイル編集のみで簡単です。

-
newmtl blinn1SG
-Ka 0.10 0.10 0.10
-
-Kd 0.00 0.00 0.00
-Ks 0.00 0.00 0.00
-Ke 0.00 0.00 0.00
-Ns 0.060000
-Ni 1.500000
-d 1.000000
-Tr 0.000000
-Tf 1.000000 1.000000 1.000000
-illum 2
--map_Kd windmill_diffuse.tga
-+map_Kd windmill_diffuse.jpg
-
--map_Ks windmill_spec.tga
-+map_Ks windmill_spec.jpg
-
--map_bump windmill_normal.tga
--bump windmill_normal.tga
-+map_bump windmill_normal.jpg
-+bump windmill_normal.jpg
-
-

MTLファイルが適度なサイズのテクスチャを指しているので、それをロードする必要があります。 -上記で行ったようにまずマテリアルをロードしてから OBJLoader に設定します。

-
{
-+  const mtlLoader = new MTLLoader();
-+  mtlLoader.load('resources/models/windmill_2/windmill-fixed.mtl', (mtl) => {
-+    const objLoader = new OBJLoader();
-+    mtl.preload();
-+    objLoader.setMaterials(mtl);
-    objLoader.load('resources/models/windmill/windmill.obj', (root) => {
-      root.updateMatrixWorld();
-      scene.add(root);
-      // compute the box that contains all the stuff
-      // from root and below
-      const box = new THREE.Box3().setFromObject(root);
-
-      const boxSize = box.getSize(new THREE.Vector3()).length();
-      const boxCenter = box.getCenter(new THREE.Vector3());
-
-      // set the camera to frame the box
-      frameArea(boxSize * 1.2, boxSize, boxCenter, camera);
-
-      // update the Trackball controls to handle the new size
-      controls.maxDistance = boxSize * 10;
-      controls.target.copy(boxCenter);
-      controls.update();
-    });
-+  });
-}
-
-

実際にやってみる前にいくつかの問題にぶつかりました。

-

問題1: 3つの MTLLoader は、マテリアルのディフューズカラーにディフューズテクスチャマップを乗算したマテリアルを作成します。

-

これは便利な機能ですがMTLファイルの上記の行を見ると

-
Kd 0.00 0.00 0.00
-
-

ディフューズ色を0に設定します。 -テクスチャマップ * 0 = 黒です! -風車を作るのに使われたモデリングツールがディフューズテクスチャマップにディフューズカラーを掛けていなかった可能性があります。 -だからこそ、この風車を作った3DCGデザイナーなどのアーティストの3DCG Tool上では動作してました。

-

これを修正するには、次の行を修正する必要があります。

-
Kd 1.00 1.00 1.00
-
-

テクスチャ マップ * 1 = テクスチャマップです。

-

問題点その2:スペキュラカラーも黒である

-

Ks で始まる行はスペキュラカラーを指定します。 -風車を作るのに使われたモデリングソフトはスペキュラマップの色をスペキュラハイライトに使うという点でディフューズマップと似たような事をしていたのでしょう。 -Three.jsではスペキュラカラーをどの程度反射させるかの入力値は、スペキュラマップの赤チャンネルのみを使用しています。 -つまり、スペキュラカラーセットが必要です。

-

上記のようにMTLファイルを以下のように修正する事ができます。

-
-Ks 0.00 0.00 0.00
-+Ks 1.00 1.00 1.00
-
-

問題 #3: windmill_normal.jpg はバンプマップではなくノーマルマップです

-

上記のようにMTLファイルを編集する必要があります。

-
-map_bump windmill_normal.jpg
--bump windmill_normal.jpg
-+norm windmill_normal.jpg
-
-

それを考慮し試してみるとマテリアルが一杯になるはずです。

-

- -

-

モデルをロードするとこのような問題が発生することがよくあります。 -よくある問題には以下のようなものがあります。

-
    -
  • サイズを把握する必要がある

    -

    上記のようにカメラにシーンをフレーミングしようとさせましたが、それは必ずしも適切な事ではありません。 -一般的に最も適切なのは自分でモデルを作るか、モデルをダウンロードし、3Dソフトでロードしてそのスケールを見て必要に応じて調整する事です。

    -
  • -
  • 方向性の違い

    -

    three.jsは一般的にはy = upです。モデリングツールによってはZ = upにデフォルトで設定されているものもあれば、Y = upに設定されているものもあります。 -設定可能なものもあります。 -モデルをロードして横になっているようなケースに遭遇した場合、モデルを回転させるコードをハックすることもできます(推奨されません)。 -また、お気に入りのモデリングツールにモデルを読み込むか、コマンドラインツールを使い、ウェブサイト用の画像をダウンロードしてコードを適用するのではなく、ウェブサイト用の画像を編集するように必要な方向にオブジェクトを回転させる事もできます。

    -
  • -
  • MTLファイルや間違ったマテリアル、互換性のないパラメータがない場合

    -

    上記ではMTLファイルを使用していましたが、マテリアルの読込には問題がありました。 -OBJファイルの中身を見てどんなマテリアルがあるのかを確認したり、three.jsでOBJファイルを読み込んでシーンを歩いて全てのマテリアルをプリントアウトしたりするのも一般的です。

    -
  • -
  • テクスチャが大きすぎる

    -

    3Dモデルの多くは建築用、映画やCM用、ゲーム用のどちらかに作られています。 -建築や映画の場合は誰もがテクスチャのサイズを気にしていません。 -ゲームはメモリが限られていますが、ほとんどのゲームはローカルで実行されているので、ゲーム開発者は気にしています。 -Webページはできるだけ速く読みたいので、テクスチャをできるだけ小さく、かつ見栄えの良いものにする必要があります。 -実際に最初の風車では、間違いなくテクスチャについて何かをするべきでした。現在のサイズ合計は10MBです!

    -

    テクスチャの記事で述べたようにテクスチャはメモリを取ります。 - 4096 x 4096に展開された50kのJPGは高速にダウンロードされますが、大量のメモリを消費する事を覚えておいて下さい。

    -
  • -
-

最後に見せたかったのは風車を回している所です。残念ながらOBJファイルには階層がありません。 -つまり、各風車のパーツは基本的に1つのメッシュとして考えられています。 -ミルの羽根は分離されていないので回転させる事はできません。

-

これがOBJファイルは良いフォーマットではない主な理由の1つです。 -推測するならば、他のフォーマットよりも一般的な理由はシンプルで多くの機能をサポートしていないため、より多くの場合に動作します。 -特に建築物のイメージのような静止したものを作っていて、何かをアニメーション化する必要がない場合はシーンに静的な小道具を入れるのには悪くない方法です。

-

次はgLTFシーンをロードしてみます。gLTFフォーマットは他にも多くの機能をサポートしています。

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/material-table.html b/manual/ja/material-table.html deleted file mode 100644 index bcc345b7d71868..00000000000000 --- a/manual/ja/material-table.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Material Feature Table - - - - - - - - - - - - - -
-
-

Material Feature Table

-
-
-
-

すいません、この記事はまだ翻訳してません. Translations Welcome! 😄

-

ここに元の英語の記事があります.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/materials.html b/manual/ja/materials.html deleted file mode 100644 index 1068b80fc7bdf5..00000000000000 --- a/manual/ja/materials.html +++ /dev/null @@ -1,301 +0,0 @@ - - - マテリアル - - - - - - - - - - - - - -
-
-

マテリアル

-
-
-
-

この記事はthree.jsについてのシリーズ記事の一つです。 -最初の記事はThree.jsの基礎知識です。 -まだ読んでない人は、そちらから先に読んでみるといいかもしれません。

-

Three.jsはいくつかの種類のマテリアルを提供しています。 -これらは、オブジェクトがどのようにシーンに表示されるかを定義します。 -どのマテリアルを使うべきかは、皆さんが何をしたいかによります。

-

マテリアルの属性の設定方法は、だいたい2つです。 -一つは、表示される前の作成時です。

-
const material = new THREE.MeshPhongMaterial({
-  color: 0xFF0000,    // red (can also use a CSS color string here)
-  flatShading: true,
-});
-
-

もう一つは作成後です。

-
const material = new THREE.MeshPhongMaterial();
-material.color.setHSL(0, 1, .5);  // red
-material.flatShading = true;
-
-

THREE.Color型の属性は設定方法が複数あることに注意してください。

-
material.color.set(0x00FFFF);    // same as CSS's #RRGGBB style
-material.color.set(cssString);   // any CSS color, eg 'purple', '#F32',
-                                 // 'rgb(255, 127, 64)',
-                                 // 'hsl(180, 50%, 25%)'
-material.color.set(someColor)    // some other THREE.Color
-material.color.setHSL(h, s, l)   // where h, s, and l are 0 to 1
-material.color.setRGB(r, g, b)   // where r, g, and b are 0 to 1
-
-

作成時に、16進数かCSS文字列を渡すことができます。

-
const m1 = new THREE.MeshBasicMaterial({color: 0xFF0000});         // red
-const m2 = new THREE.MeshBasicMaterial({color: 'red'});            // red
-const m3 = new THREE.MeshBasicMaterial({color: '#F00'});           // red
-const m4 = new THREE.MeshBasicMaterial({color: 'rgb(255,0,0)'});   // red
-const m5 = new THREE.MeshBasicMaterial({color: 'hsl(0,100%,50%)'}); // red
-
-

では、three.jsのマテリアルの設定の説明をしましょう。

-

MeshBasicMaterialは光源の影響を受けません。 -MeshLambertMaterialは頂点でのみ光を計算します。 -一方で、MeshPhongMaterialは全てのピクセルで光を計算します。 -MeshPhongMaterialは、specularによるハイライトもサポートします。

-
-
-
-
Basic
-
-
-
-
Lambert
-
-
-
-
Phong
-
-
-
-
-
-
-
-
-
-
-
-
-
-
同じマテリアルでポリゴン数を変えたモデル
- -

MeshPhongMaterialshininess設定は特定のハイライトの輝きを決めます。デフォルトは30です。

-
-
-
-
shininess: 0
-
-
-
-
shininess: 30
-
-
-
-
shininess: 150
-
-
- -

MeshLambertMaterialMeshPhongMaterialのどちらかで、colorに対してemissive属性を設定し、 -色を黒(phongならshininessを0)に設定すると、ちょうどMeshBasicMaterialのように見えることに注意してください。

-
-
-
-
-
Basic
-
color: 'purple'
-
-
-
-
-
-
Lambert
-
color: 'black'
-
emissive: 'purple'
-
-
-
-
-
-
Phong
-
color: 'black'
-
emissive: 'purple'
-
shininess: 0
-
-
-
- -

MeshPhongMaterialMeshBasicMaterialMeshLambertMaterialと同じようにできるのに、なぜ3種もあるのでしょうか。 -理由は、より洗練されたマテリアルは、描写するのにGPUパワーを必要とするためです。 -携帯電話といった、遅いGPCでは、より簡単なマテリアルを使うことで、描画に必要なGPUパワーを削減できるかもしれません。 -また、余計な機能を必要としないなら、一番シンプルなマテリアルを使用するとよいです。 -光源やspecularによるハイライトが不要なら、MeshBasicMaterialを使うこともできます。

-

MeshToonMaterialMeshPhongMaterialに似ていますが、一点大きな違いがあります。 -連続的にシェーディングするのではなく、グラデーションマップ(X×1のテクスチャ)を使ってシェーディングの方法を決めます。 -デフォルトは明るさの始まりが70%、終わりが100%のグラデーションマップを適用しますが、自分で決めたグラデーションマップを適用することもできます。 -これにより、まるでアニメのようなツートーンになります。

-
-
-
- -

続いて2つの物理ベースレンダリングのマテリアルがあります。 -物理ベースレンダリングはよくPBRと略します。

-

上記のマテリアルは、3Dに見えるマテリアルを簡単な数学で作っていますが、 -これは現実世界で本当に起きている現象にのっとっていません。 -2つのPBRマテリアルはもっと複雑な数学を使い、現実世界に近づいています。

-

一つ目はMeshStandardMaterialです。MeshPhongMaterialMeshStandardMaterialの -最大の違いは、異なるパラメータを使っていることです。 -MeshPhongMaterialshininess設定があります。 -MeshStandardMaterialroughnessmetalnessの2つの設定があります。

-

基本的に、roughnessshininessの逆です。 -野球ボールがほとんど反射しないように、とても粗いのものがある一方で、 -とても光沢があるビリヤード玉のように、粗くないものもあります。 -roughnessは0から1の間をとります。

-

もう一つの設定で、metalnessは、マテリアルの金属っぽさです。 -金属は非金属と異なった振る舞いをします。 -0は非金属で、1は金属です。

-

ここに、MeshStandardMaterialのサンプルがあります。 -右に行くにつれて、roughnessは0から1に変わります。 -下に行くにつれて、metalnessは0から1に変わります。

-
- -

MeshPhysicalMaterialは、MeshStandardMaterialと同様ですが、 -clearcoatパラメータが追加されています。このパラメータは、0から1につれて、 -clearcoat光沢層が適用されます。 -また、clearCoatRoughnessパラメータも追加されていて、これは光沢層の粗さを決定します。

-

ここに、上と同じmetalnessroughnessのグリッドがあります。 -ただし、clearcoatclearCoatRoughnessの設定が付いています。

-
- -

様々な標準のマテリアルのうち、高速なものから低速なものを並べると、 -MeshBasicMaterialMeshLambertMaterialMeshPhongMaterial ➡ -MeshStandardMaterialMeshPhysicalMaterialになります。 -低速なマテリアルは、より現実味のある見た目のシーンを作ることができますが、 -パワーが低いデバイスやモバイル端末では、より高速なマテリアルを使うようにコードを設計する必要があります。

-

続いて、特別な用途に使う3つのマテリアルがあります。 -ShadowMaterialは影から作られたデータを得るのに使われます。 -まだ影については説明していませんでしたね。 -その際には、このマテリアルを使って、シーンの裏で何が起きているのか、のぞいてみたいと思います。

-

MeshDepthMaterialは各ピクセルの深度を描写します。 -カメラの負のnearにあるピクセルは0、負のfarにあるピクセルは1です。 -また別の機会に、特定の特殊効果がこのデータを使うかもしれません。

-
-
-
-
-
- - -

MeshNormalMaterialはジオメトリの法線を表示します。 -法線は、特定の三角形かピクセル表面の方向です。 -MeshNormalMaterialは見えている空間の法線を描画します(法線はカメラに依存します)。

-

xは赤、 -yは緑そして -zは青なので、 -物体の右側はpink、 -左側はaqua、 -上側はlight green、 -下側はpurple、 -そして画面側はlavenderになるでしょう。

-
-
-
-
-
- -

ShaderMaterialは、three.jsのシェーダーシステムを使ったカスタムマテリアルを作るためのものです -RawShaderMaterialは、three.jsの補助なしで、完全に独自シェーダーを作るためのものです。 -これらのトピックはどちらも大きいため、後ほど説明します。

-

全てのマテリアルはMaterialによって決められた設定を共有しています。 -それらはドキュメントを見てください。けれども、最も一般的に使われる2つの属性について説明しましょう。

-

flatShading:物体の面が分割されて見えるか、滑らかに見えるか。デフォルトはfalseです。

-
-
-
-
flatShading: false
-
-
-
-
flatShading: true
-
-
- -

side:三角形の両面を表示するか。デフォルトはTHREE.FrontSideです。 -ほかのオプションは THREE.BackSideTHREE.DoubleSide(両面)です。 -threeで描写されるほとんどの3Dオブジェクトは、たぶん不透明な固体です。 -そのため、裏面(固体の内側を向いている面)は描画する必要はありません。 -sideを設定する最も一般的な理由は、平面やほかの固体ではないオブジェクトのためです。 -これらは、三角形の裏面を見ることが普通だからです。

-

ここに、THREE.FrontSideTHREE.DoubleSideで描画された6つの平面があります。

-
-
-
-
side: THREE.FrontSide
-
-
-
-
side: THREE.DoubleSide
-
-
- -

マテリアルについては、本当にたくさん考えることがあり、実際にはもっとたくさんの説明したいパラメータがあります。 -特に、私たちは多くのオプションの話につながる、テクスチャをほとんど無視していました。 -テクスチャを説明する前に、休憩を取って、開発環境のセットアップを説明する必要があります。

-
-

material.needsUpdate

- -

-このトピックはめったにthree.jsアプリに影響しませんが、単にFYIのつもりで...。 -Three.jsはマテリアルが"使われた"ときに設定を適用します。 -"使われた"は"マテリアルを使って何かが描画される"ということです。 -マテリアルの設定はたった一度だけ適用されます。変更するとthree.jsに多くの仕事が必要になります。 -変更するケースでは、three.jsに変更を伝えるため、material.needsUpdate = trueを設定する必要があります。 -マテリアルを試用した後で、needsUpdateの設定を必要とする一般的な設定はこのようになります: - -

-
    -
  • flatShading
  • -
  • テクスチャの追加や削除 -

    - テクスチャの変更はOKですが、テクスチャを使わない状態から使う状態に変更したり、 - テクスチャを使っている状態から使わない状態に変更したいとすると、 - needsUpdate = trueを設定する必要があります。 -

    -

    テクスチャありからテクスチャなしに変更するケースでは、 - 1x1ピクセルのホワイトテクスチャを使うことがよいです。

    -
  • -
-

この問題は、ほとんどのアプリには関係ありません。 -ほとんどのアプリではフラットシェードありとフラットシェードなしを切り替えません。 -また、ほとんどのアプリは、与えられたマテリアルにテクスチャか固定の色のどちらかを使い、 -めったに一方からもう一方に切り替えたりしません。 -

-
- -

- - - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/multiple-scenes.html b/manual/ja/multiple-scenes.html deleted file mode 100644 index 3ad88efc53720b..00000000000000 --- a/manual/ja/multiple-scenes.html +++ /dev/null @@ -1,644 +0,0 @@ - - - の複数キャンバスと複数シーン - - - - - - - - - - - - - -
-
-

の複数キャンバスと複数シーン

-
-
-
-

よくある質問として、どうやってThree.jsで複数キャンバスを使用するのかがあります。 -ECサイトを作りたい、3Dダイアグラムをたくさん使ったページを作りたいとしましょう。 -一見簡単そうに見えます。 -ダイアグラムが欲しい所にキャンバスを作るだけです。 -それぞれのキャンバスで Renderer を作成します。

-

以下の問題にすぐに気づくでしょう。

-
    -
  1. ブラウザはWebGLコンテキスト数を制限している

    -

    一般的にはコンテキスト数の制限は約8個です。 - 9個目のコンテキストを作成すると、すぐに古いコンテキストが失われます。

    -
  2. -
  3. WebGLリソースはコンテキスト間で共有できない

    -

    10MBの3Dモデルを2つのキャンバスにロードしたいとします。 - 20MBのテクスチャを持ち、10MBの3Dモデルも20MBのテクステャも2回ロードしなければなりません。 - コンテキスト間で共有はできません。 - つまり、初期化もシェーダーコンパイルも2回する必要があります。 - キャンバスが増えると回数が増えさらに悪化します。

    -
  4. -
-

何か解決策はないでしょうか?

-

解決策としては、背景のViewPortを埋める1つのキャンバスと、キャンバス以外のHTML要素で"仮想"のキャンバスを持つ事です。 -仮想キャンバスごとに RendererScene を1つずつ作成します。 -次に仮想キャンバス要素の位置を確認し、その要素が画面上にある場合はシーンの正しい場所に描画するようにします。

-

この解決策はキャンバスが1つしかないため、上記の1と2の問題を解決します。 -1つのコンテキストだけなので、WebGLコンテキストの制限は問題ありません。 -同じ理由で共有の問題もありません。

-

2つのシーンだけの簡単な例から始めましょう。まずはHTMLを作成します。

-
<canvas id="c"></canvas>
-<p>
-  <span id="box" class="diagram left"></span>
-  I love boxes. Presents come in boxes.
-  When I find a new box I'm always excited to find out what's inside.
-</p>
-<p>
-  <span id="pyramid" class="diagram right"></span>
-  When I was a kid I dreamed of going on an expedition inside a pyramid
-  and finding a undiscovered tomb full of mummies and treasure.
-</p>
-
-

次にCSSを次のように設定します。

-
#c {
-  position: fixed;
-  left: 0;
-  top: 0;
-  width: 100%;
-  height: 100%;
-  display: block;
-  z-index: -1;
-}
-.diagram {
-  display: inline-block;
-  width: 5em;
-  height: 3em;
-  border: 1px solid black;
-}
-.left {
-  float: left;
-  margin-right: .25em;
-}
-.right {
-  float: right;
-  margin-left: .25em;
-}
-
-

キャンバスを画面一杯にし、z-index を-1に設定し他のDOM要素よりも後に表示されるようにします。 -仮想キャンバスにはサイズ指定がないため、幅と高さを指定する必要があります。

-

次にライトとカメラをそれぞれ2つのシーンに作成します。 -1つ目のシーンにキューブを追加し、もう1つのシーンにはひし形を追加します。

-
function makeScene(elem) {
-  const scene = new THREE.Scene();
-
-  const fov = 45;
-  const aspect = 2;  // the canvas default
-  const near = 0.1;
-  const far = 5;
-  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-  camera.position.z = 2;
-  camera.position.set(0, 1, 2);
-  camera.lookAt(0, 0, 0);
-
-  {
-    const color = 0xFFFFFF;
-    const intensity = 1;
-    const light = new THREE.DirectionalLight(color, intensity);
-    light.position.set(-1, 2, 4);
-    scene.add(light);
-  }
-
-  return {scene, camera, elem};
-}
-
-function setupScene1() {
-  const sceneInfo = makeScene(document.querySelector('#box'));
-  const geometry = new THREE.BoxGeometry(1, 1, 1);
-  const material = new THREE.MeshPhongMaterial({color: 'red'});
-  const mesh = new THREE.Mesh(geometry, material);
-  sceneInfo.scene.add(mesh);
-  sceneInfo.mesh = mesh;
-  return sceneInfo;
-}
-
-function setupScene2() {
-  const sceneInfo = makeScene(document.querySelector('#pyramid'));
-  const radius = .8;
-  const widthSegments = 4;
-  const heightSegments = 2;
-  const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
-  const material = new THREE.MeshPhongMaterial({
-    color: 'blue',
-    flatShading: true,
-  });
-  const mesh = new THREE.Mesh(geometry, material);
-  sceneInfo.scene.add(mesh);
-  sceneInfo.mesh = mesh;
-  return sceneInfo;
-}
-
-const sceneInfo1 = setupScene1();
-const sceneInfo2 = setupScene2();
-
-

DOM要素が画面上にある場合のみ、各シーンをレンダリングする関数を作成します。 -Renderer.setScissorTestシザー テストを有効にし、キャンバスの一部だけをレンダリングするように指定できます。 -Renderer.setViewportRenderer.setScissor でシザーとビューポートの両方を設定します。

-
function renderSceneInfo(sceneInfo) {
-  const {scene, camera, elem} = sceneInfo;
-
-  // get the viewport relative position of this element
-  const {left, right, top, bottom, width, height} =
-      elem.getBoundingClientRect();
-
-  const isOffscreen =
-      bottom < 0 ||
-      top > renderer.domElement.clientHeight ||
-      right < 0 ||
-      left > renderer.domElement.clientWidth;
-
-  if (isOffscreen) {
-    return;
-  }
-
-  camera.aspect = width / height;
-  camera.updateProjectionMatrix();
-
-  const positiveYUpBottom = canvasRect.height - bottom;
-  renderer.setScissor(left, positiveYUpBottom, width, height);
-  renderer.setViewport(left, positiveYUpBottom, width, height);
-
-  renderer.render(scene, camera);
-}
-
-

render関数で最初に画面をクリア後、各シーンをレンダリングします。

-
function render(time) {
-  time *= 0.001;
-
-  resizeRendererToDisplaySize(renderer);
-
-  renderer.setScissorTest(false);
-  renderer.clear(true, true);
-  renderer.setScissorTest(true);
-
-  sceneInfo1.mesh.rotation.y = time * .1;
-  sceneInfo2.mesh.rotation.y = time * .1;
-
-  renderSceneInfo(sceneInfo1);
-  renderSceneInfo(sceneInfo2);
-
-  requestAnimationFrame(render);
-}
-
-

その結果がこれです。

-

- -

-

最初の <span> が赤いキューブ、2つ目の span が青いひし形です。

-

同期する

-

上記のコードは動作していますが、1つだけ小さな問題があります。 -シーンが複雑だったり、何らかの理由でレンダリングに時間がかかり過ぎる場合、 -キャンバスに描画したシーンの位置が他のページよりも遅れてしまいます。

-

各エリアにborderを与えて

-
.diagram {
-  display: inline-block;
-  width: 5em;
-  height: 3em;
-+  border: 1px solid black;
-}
-
-

各シーンに背景色を設定します。

-
const scene = new THREE.Scene();
-+scene.background = new THREE.Color('red');
-
-

そして、素早く上下にスクロールすると問題が分かります。以下はスクロールが10倍に遅くなった動画です。

-
- -

別のトレードオフになる別の方法に切り替える事もできます。 -キャンバスのCSSを position: fixed から position: absolute に切り替えます。

-
#c {
--  position: fixed;
-+  position: absolute;
-
-

キャンバスの変形を設定し、キャンバス上部が現在のページスクロールしている部分の上部にくるように移動させます。

-
function render(time) {
-  ...
-
-  const transform = `translateY(${window.scrollY}px)`;
-  renderer.domElement.style.transform = transform;
-
-

position: fixed はページの残りの部分がスクロールしている間、キャンバスがスクロールしないようにしていました。 -position: absolute は、キャンバスをページの残りの部分と一緒にスクロールさせます。 -これはレンダリングに時間がかかりすぎても、描画したものがスクロールしてもページに密着します。 -ページがスクロールされた位置に合わせてキャンバスを移動し再レンダリングします。 -ウィンドウの端だけが一瞬レンダリングされていないビットが表示されますが、ページの真ん中にあるものは一致しているのでスライドしません。新しい方法で10倍に遅くなった結果を見てみましょう。

-
- -

もっと汎用的なコードにする

-

複数のシーンが機能したので、もう少し汎用的なコードにしてみましょう。

-

キャンバスを管理するメインのrender関数にDOM要素のリストと関連するrender関数だけ持たせる事ができます。 -各要素に対して画面上に表示されているかチェックし、表示されている場合は対応するrender関数を呼び出します。 -この方法は個々のシーンが小さな空間でレンダリングされている事を意識せず、汎用的なシステムになります。

-

これがメインのrender関数です。

-
const sceneElements = [];
-function addScene(elem, fn) {
-  sceneElements.push({elem, fn});
-}
-
-function render(time) {
-  time *= 0.001;
-
-  resizeRendererToDisplaySize(renderer);
-
-  renderer.setScissorTest(false);
-  renderer.setClearColor(clearColor, 0);
-  renderer.clear(true, true);
-  renderer.setScissorTest(true);
-
-  const transform = `translateY(${window.scrollY}px)`;
-  renderer.domElement.style.transform = transform;
-
-  for (const {elem, fn} of sceneElements) {
-    // get the viewport relative position of this element
-    const rect = elem.getBoundingClientRect();
-    const {left, right, top, bottom, width, height} = rect;
-
-    const isOffscreen =
-        bottom < 0 ||
-        top > renderer.domElement.clientHeight ||
-        right < 0 ||
-        left > renderer.domElement.clientWidth;
-
-    if (!isOffscreen) {
-      const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
-      renderer.setScissor(left, positiveYUpBottom, width, height);
-      renderer.setViewport(left, positiveYUpBottom, width, height);
-
-      fn(time, rect);
-    }
-  }
-
-  requestAnimationFrame(render);
-}
-
-

elemfn プロパティを持つオブジェクトの配列があり、sceneElements でループしているのが分かります。

-

要素が画面上にあるかどうかをチェックします。 -画面上にある場合は fn を呼び出し、引数に現在の時刻と矩形を渡します。

-

これで各シーン設定のコードがシーンのリストに追加されます。

-
{
-  const elem = document.querySelector('#box');
-  const {scene, camera} = makeScene();
-  const geometry = new THREE.BoxGeometry(1, 1, 1);
-  const material = new THREE.MeshPhongMaterial({color: 'red'});
-  const mesh = new THREE.Mesh(geometry, material);
-  scene.add(mesh);
-  addScene(elem, (time, rect) => {
-    camera.aspect = rect.width / rect.height;
-    camera.updateProjectionMatrix();
-    mesh.rotation.y = time * .1;
-    renderer.render(scene, camera);
-  });
-}
-
-{
-  const elem = document.querySelector('#pyramid');
-  const {scene, camera} = makeScene();
-  const radius = .8;
-  const widthSegments = 4;
-  const heightSegments = 2;
-  const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
-  const material = new THREE.MeshPhongMaterial({
-    color: 'blue',
-    flatShading: true,
-  });
-  const mesh = new THREE.Mesh(geometry, material);
-  scene.add(mesh);
-  addScene(elem, (time, rect) => {
-    camera.aspect = rect.width / rect.height;
-    camera.updateProjectionMatrix();
-    mesh.rotation.y = time * .1;
-    renderer.render(scene, camera);
-  });
-}
-
-

sceneInfo1sceneInfo2 が不要になり、メッシュを回転させていたコードがシーンごとに固有になりました。

-

- -

-

HTML Datasetを使う

-

最後にもっと汎用的にするためにHTML datasetを使います。 -これはHTML要素に独自のデータを追加する方法です。 -id="...." の代わりに data-diagram="...." を使います。

-
<canvas id="c"></canvas>
-<p>
--  <span id="box" class="diagram left"></span>
-+  <span data-diagram="box" class="left"></span>
-  I love boxes. Presents come in boxes.
-  When I find a new box I'm always excited to find out what's inside.
-</p>
-<p>
--  <span id="pyramid" class="diagram left"></span>
-+  <span data-diagram="pyramid" class="right"></span>
-  When I was a kid I dreamed of going on an expedition inside a pyramid
-  and finding a undiscovered tomb full of mummies and treasure.
-</p>
-
-

CSSのセレクタを変更し、それを選択するようにします。

-
-.diagram
-+*[data-diagram] {
-  display: inline-block;
-  width: 5em;
-  height: 3em;
-}
-
-

シーン設定のコードを変更して シーン初期化関数 への名前のマップにします。 -そして、シーンのレンダリング関数 を返すようにします。

-
const sceneInitFunctionsByName = {
-  'box': () => {
-    const {scene, camera} = makeScene();
-    const geometry = new THREE.BoxGeometry(1, 1, 1);
-    const material = new THREE.MeshPhongMaterial({color: 'red'});
-    const mesh = new THREE.Mesh(geometry, material);
-    scene.add(mesh);
-    return (time, rect) => {
-      mesh.rotation.y = time * .1;
-      camera.aspect = rect.width / rect.height;
-      camera.updateProjectionMatrix();
-      renderer.render(scene, camera);
-    };
-  },
-  'pyramid': () => {
-    const {scene, camera} = makeScene();
-    const radius = .8;
-    const widthSegments = 4;
-    const heightSegments = 2;
-    const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
-    const material = new THREE.MeshPhongMaterial({
-      color: 'blue',
-      flatShading: true,
-    });
-    const mesh = new THREE.Mesh(geometry, material);
-    scene.add(mesh);
-    return (time, rect) => {
-      mesh.rotation.y = time * .1;
-      camera.aspect = rect.width / rect.height;
-      camera.updateProjectionMatrix();
-      renderer.render(scene, camera);
-    };
-  },
-};
-
-

次に querySelectorAll で全てのdiagramを見つけ、対応するinit関数を呼び出します。

-
document.querySelectorAll('[data-diagram]').forEach((elem) => {
-  const sceneName = elem.dataset.diagram;
-  const sceneInitFunction = sceneInitFunctionsByName[sceneName];
-  const sceneRenderFunction = sceneInitFunction(elem);
-  addScene(elem, sceneRenderFunction);
-});
-
-

見た目の変更はありませんが、コードはさらに汎用的になっています。

-

-

各要素にコントロールを追加する

-

TrackballControls のようなインタラクティブな要素を追加するのは簡単です。 -最初にコントロール用のスクリプトを追加します。

-
import {TrackballControls} from 'three/addons/controls/TrackballControls.js';
-
-

そして TrackballControls を各シーンに追加し、シーンに関連付けられた要素を渡します。

-
-function makeScene() {
-+function makeScene(elem) {
-  const scene = new THREE.Scene();
-
-  const fov = 45;
-  const aspect = 2;  // the canvas default
-  const near = 0.1;
-  const far = 5;
-  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-  camera.position.set(0, 1, 2);
-  camera.lookAt(0, 0, 0);
-+  scene.add(camera);
-
-+  const controls = new TrackballControls(camera, elem);
-+  controls.noZoom = true;
-+  controls.noPan = true;
-
-  {
-    const color = 0xFFFFFF;
-    const intensity = 1;
-    const light = new THREE.DirectionalLight(color, intensity);
-    light.position.set(-1, 2, 4);
--    scene.add(light);
-+    camera.add(light);
-  }
-
--  return {scene, camera};
-+ return {scene, camera, controls};
-}
-
-

シーンにカメラを追加し、カメラにライティングを追加しました。 -これにより、カメラに対する相対的な光を受けます。 -TrackballControls がカメラを動かしているので、これが望んだ形です。 -見ている対象物の側に光を当て続けます。

-

render関数でこれらのコントロールを更新する必要があります。

-
const sceneInitFunctionsByName = {
-- 'box': () => {
--    const {scene, camera} = makeScene();
-+ 'box': (elem) => {
-+    const {scene, camera, controls} = makeScene(elem);
-    const geometry = new THREE.BoxGeometry(1, 1, 1);
-    const material = new THREE.MeshPhongMaterial({color: 'red'});
-    const mesh = new THREE.Mesh(geometry, material);
-    scene.add(mesh);
-    return (time, rect) => {
-      mesh.rotation.y = time * .1;
-      camera.aspect = rect.width / rect.height;
-      camera.updateProjectionMatrix();
-+      controls.handleResize();
-+      controls.update();
-      renderer.render(scene, camera);
-    };
-  },
--  'pyramid': () => {
--    const {scene, camera} = makeScene();
-+  'pyramid': (elem) => {
-+    const {scene, camera, controls} = makeScene(elem);
-    const radius = .8;
-    const widthSegments = 4;
-    const heightSegments = 2;
-    const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
-    const material = new THREE.MeshPhongMaterial({
-      color: 'blue',
-      flatShading: true,
-    });
-    const mesh = new THREE.Mesh(geometry, material);
-    scene.add(mesh);
-    return (time, rect) => {
-      mesh.rotation.y = time * .1;
-      camera.aspect = rect.width / rect.height;
-      camera.updateProjectionMatrix();
-+      controls.handleResize();
-+      controls.update();
-      renderer.render(scene, camera);
-    };
-  },
-};
-
-

オブジェクトをドラッグすると回転するようになりました。

-

- -

-

これらのテクニックはこのサイト自体にも使われています。 -特にプリミティブの記事マテリアルの記事では、このテクニックを使ってページ全体に様々なサンプルを追加しています。

-

もう1つの解決策はオフスクリーンのキャンバスにレンダリングし、各要素で結果を2Dキャンバスにコピーする事です。 -この解決策の利点は、分離した各領域を合成する方法に制限がないです。 -以前の解決策では、背景に単一のキャンバスを使用していました。 -この解決策では通常のHTML要素を使用しています。

-

欠点は、領域ごとにコピーが発生するため遅いという事です。 -どのくらい遅くなるかはブラウザとGPUに依存します。

-

必要な変更は非常に小さいです。

-

まず、ページ内にキャンバスが不要になったのでHTMLを変更します。

-
<body>
--  <canvas id="c"></canvas>
-  ...
-</body>
-
-

CSSも変更します。

-
-#c {
--  position: absolute;
--  left: 0;
--  top: 0;
--  width: 100%;
--  height: 100%;
--  display: block;
--  z-index: -1;
--}
-canvas {
-  width: 100%;
-  height: 100%;
-  display: block;
-}
-*[data-diagram] {
-  display: inline-block;
-  width: 5em;
-  height: 3em;
-}
-

全てのキャンバスは作成し、コンテナとなる変数に格納する形にします。

-

では、JavaScriptを変更してみましょう。 -もはやキャンバスを探す事は不要になりました。 -代わりに私たちは1つのキャンバスを作ります。 -また、最初にシザーテストをONにします。

-
function main() {
--  const canvas = document.querySelector('#c');
-+  const canvas = document.createElement('canvas');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas, alpha: true});
-+  renderer.setScissorTest(true);
-
-  ...
-
-

次に各シーンに対して2Dレンダリングのコンテキストを作成し、そのシーンの要素にキャンバスを追加します。

-
const sceneElements = [];
-function addScene(elem, fn) {
-+  const ctx = document.createElement('canvas').getContext('2d');
-+  elem.appendChild(ctx.canvas);
--  sceneElements.push({elem, fn});
-+  sceneElements.push({elem, ctx, fn});
-}
-
-

レンダリング時に、レンダラーのキャンバスがレンダリングするのに十分な大きさでない場合はサイズを大きくします。 -また、キャンバスのサイズが間違っている場合はそのサイズを変更します。 -最後にシザーとビューポートを設定し、シーンをレンダリングしその結果をキャンバスにコピーします。

-
function render(time) {
-  time *= 0.001;
-
--  resizeRendererToDisplaySize(renderer);
--
--  renderer.setScissorTest(false);
--  renderer.setClearColor(clearColor, 0);
--  renderer.clear(true, true);
--  renderer.setScissorTest(true);
--
--  const transform = `translateY(${window.scrollY}px)`;
--  renderer.domElement.style.transform = transform;
-
--  for (const {elem, fn} of sceneElements) {
-+  for (const {elem, fn, ctx} of sceneElements) {
-    // get the viewport relative position of this element
-    const rect = elem.getBoundingClientRect();
-    const {left, right, top, bottom, width, height} = rect;
-+    const rendererCanvas = renderer.domElement;
-
-    const isOffscreen =
-        bottom < 0 ||
--        top > renderer.domElement.clientHeight ||
-+        top > window.innerHeight ||
-        right < 0 ||
--        left > renderer.domElement.clientWidth;
-+        left > window.innerWidth;
-
-    if (!isOffscreen) {
--      const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
--      renderer.setScissor(left, positiveYUpBottom, width, height);
--      renderer.setViewport(left, positiveYUpBottom, width, height);
-
-+      // make sure the renderer's canvas is big enough
-+      if (rendererCanvas.width < width || rendererCanvas.height < height) {
-+        renderer.setSize(width, height, false);
-+      }
-+
-+      // make sure the canvas for this area is the same size as the area
-+      if (ctx.canvas.width !== width || ctx.canvas.height !== height) {
-+        ctx.canvas.width = width;
-+        ctx.canvas.height = height;
-+      }
-+
-+      renderer.setScissor(0, 0, width, height);
-+      renderer.setViewport(0, 0, width, height);
-
-      fn(time, rect);
-
-+      // copy the rendered scene to this element's canvas
-+      ctx.globalCompositeOperation = 'copy';
-+      ctx.drawImage(
-+          rendererCanvas,
-+          0, rendererCanvas.height - height, width, height,  // src rect
-+          0, 0, width, height);                              // dst rect
-    }
-  }
-
-  requestAnimationFrame(render);
-}
-
-

結果は同じように見えます。

-

- -

-

この解決策のもう1つの利点は、Web workerでレンダリングするためにOffscreenCanvasのテクニックも使用しています。 -残念ながら2020年7月現在、OffscreenCanvas はChromeのみの対応となっています。

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/offscreencanvas.html b/manual/ja/offscreencanvas.html deleted file mode 100644 index cd1d363e2276d2..00000000000000 --- a/manual/ja/offscreencanvas.html +++ /dev/null @@ -1,1040 +0,0 @@ - - - のOffscreenCanvas - - - - - - - - - - - - - -
-
-

のOffscreenCanvas

-
-
-
-

OffscreenCanvasは新しいブラウザの機能で現在はChromeでしか利用できませんが、他のブラウザにも来るようです。 -OffscreenCanvas はWeb Workerでキャンバスにレンダリングできます。 -複雑な3Dシーンのレンダリングなど重い作業をWeb Workerで行い負荷を軽減させ、ブラウザのレスポンスを低下させない方法です。 -また、データが読み込まれWorkerで解析されてるのでページ読み込み中にページ表示の途切れは少ないでしょう。

-

OffscreenCanvasの利用を開始するのは非常に簡単です。 -レスポンシブデザインの記事から3つのキューブを回転させるコードに修正してみましょう。

-

通常はWorkerのコードを別ファイルに分離しますが、このサイトのほとんどのサンプルコードではスクリプトをHTMLファイルに埋め込んでいます。

-

ここでは offscreencanvas-cubes.js というファイルを作成し、レスポンシブデザインの例から全てのJavaScriptをコピーして下さい。 -そして、Workerで実行するために必要な変更を行います。

-

HTMLファイルにはJavaScriptのいくつかの処理が必要です。 -まず最初に行う必要があるのはキャンバスを検索し、canvas.transferControlToOffscreen 呼び出してキャンバスのコントロールをオフスクリーンに転送します。

-
function main() {
-  const canvas = document.querySelector('#c');
-  const offscreen = canvas.transferControlToOffscreen();
-
-  ...
-
-

new Worker(pathToScript, {type: 'module'})でWorkerを起動し、offscreen オブジェクトを渡します。

-
function main() {
-  const canvas = document.querySelector('#c');
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-cubes.js', {type: 'module'});
-  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
-}
-main();
-
-

ここで重要なのはWorkerが DOM にアクセスできない事です。 -HTML要素の参照やマウスイベントやキーボードイベントを受け取る事もできません。 -Workerは、送られたメッセージに返信してWebページにメッセージを送り返す事だけです。

-

Workerにメッセージを送信するにはworker.postMessageを呼び出し、1つまたは2つの引数を渡します。 -1つ目の引数はクローンされるJavaScriptオブジェクトでWorkerに送ります。 -2番目の引数は任意でWorkerに 転送 したい最初のオブジェクトです。 -このオブジェクトはクローンされません。 -その代わりに 転送 され、メインページには存在しなくなります。 -存在しなくなるというのはおそらく間違った説明であり、むしろ取り除かれます。 -クローンではなく、特定のタイプのオブジェクトのみを転送する事ができます。 -転送するオブジェクトには OffscreenCanvas が含まれているので、1度転送した offscreen オブジェクトをメインページに戻しても意味がありません。

-

Workerは onmessage ハンドラからメッセージを受け取ります。 -postMessage に渡したオブジェクトはWorkerの onmessage ハンドラに渡され event.data を更新します。 -上記のコードではWorkerに渡すオブジェクトに type: 'main' を宣言しています。 -このオブジェクトはブラウザには何の意味もありません。Workerで使うためだけのものです。 -type に基づいて、Worker内で別の関数を呼び出すハンドラを作成します。 -あとは必要に応じて関数を追加し、メインページから簡単に呼び出す事ができます。

-
const handlers = {
-  main,
-};
-
-self.onmessage = function(e) {
-  const fn = handlers[e.data.type];
-  if (typeof fn !== 'function') {
-    throw new Error('no handler for type: ' + e.data.type);
-  }
-  fn(e.data);
-};
-
-

上記コードのように type に基づいてハンドラを検索し、メインページから送られてきた data を渡します。 -あとはレスポンシブデザインの記事から offscreencanvas-cubes.js に貼り付けた main を変更するだけです。

-

DOMからキャンバスを探すのではなく、イベントデータからキャンバスを受け取ります。

-
-function main() {
--  const canvas = document.querySelector('#c');
-+function main(data) {
-+  const {canvas} = data;
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-
-  ...
-
-

最初の問題はWorkerからDOMを参照できず、resizeRendererToDisplaySizecanvas.clientWidthcanvas.clientHeight を参照できない事です。 -clientWidthcanvas.clientHeight はDOMの値です。

-

元のコードは以下の通りです。

-
function resizeRendererToDisplaySize(renderer) {
-  const canvas = renderer.domElement;
-  const width = canvas.clientWidth;
-  const height = canvas.clientHeight;
-  const needResize = canvas.width !== width || canvas.height !== height;
-  if (needResize) {
-    renderer.setSize(width, height, false);
-  }
-  return needResize;
-}
-
-

DOMを参照できないため、変更したサイズの値をWorkerに送る必要があります。 -そこでグローバルな状態を追加し、幅と高さを維持するようにしましょう。

-
const state = {
-  width: 300,  // canvas default
-  height: 150,  // canvas default
-};
-
-

これらの値を更新するための 'size' ハンドラを追加してみます。

-
+function size(data) {
-+  state.width = data.width;
-+  state.height = data.height;
-+}
-
-const handlers = {
-  main,
-+  size,
-};
-
-

これで resizeRendererToDisplaySize を変更すると state.widthstate.height が使えるようになりました。

-
function resizeRendererToDisplaySize(renderer) {
-  const canvas = renderer.domElement;
--  const width = canvas.clientWidth;
--  const height = canvas.clientHeight;
-+  const width = state.width;
-+  const height = state.height;
-  const needResize = canvas.width !== width || canvas.height !== height;
-  if (needResize) {
-    renderer.setSize(width, height, false);
-  }
-  return needResize;
-}
-
-

以下も同様の変更が必要です。

-
function render(time) {
-  time *= 0.001;
-
-  if (resizeRendererToDisplaySize(renderer)) {
--    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-+    camera.aspect = state.width / state.height;
-    camera.updateProjectionMatrix();
-  }
-
-  ...
-
-

メインページに戻りページのリサイズの度に size イベントを送信します。

-
const worker = new Worker('offscreencanvas-picking.js', {type: 'module'});
-worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
-
-+function sendSize() {
-+  worker.postMessage({
-+    type: 'size',
-+    width: canvas.clientWidth,
-+    height: canvas.clientHeight,
-+  });
-+}
-+
-+window.addEventListener('resize', sendSize);
-+sendSize();
-
-

初期サイズを送るために1度sendSizeを呼んでいます。

-

ブラウザが OffscreenCanvas を完全にサポートしていると仮定して、これらの変更を行うだけで動作するはずです。 -実行する前にブラウザが OffscreenCanvas を実際にサポートしているか確認し、サポートしていない場合はエラーを表示してみましょう。 -まずはエラーを表示するためのHTMLを追加します。

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="noOffscreenCanvas" style="display:none;">
-+    <div>no OffscreenCanvas support</div>
-+  </div>
-</body>
-
-

そして、CSSを追加します。

-
#noOffscreenCanvas {
-    display: flex;
-    width: 100vw;
-    height: 100vh;
-    align-items: center;
-    justify-content: center;
-    background: red;
-    color: white;
-}
-
-

ブラウザが OffscreenCanvas をサポートしているか確認するためには transferControlToOffscreen を呼びます。

-
function main() {
-  const canvas = document.querySelector('#c');
-+  if (!canvas.transferControlToOffscreen) {
-+    canvas.style.display = 'none';
-+    document.querySelector('#noOffscreenCanvas').style.display = '';
-+    return;
-+  }
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-picking.js', {type: 'module});
-  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
-
-  ...
-
-

ブラウザが OffscreenCanvas をサポートしていれば、このサンプルは動作するはずです。

-

- -

-

これは素晴らしい事ですが、今の所は全てのブラウザが OffscreenCanvas をサポートしている訳ではなく、 -OffscreenCanvas サポートありとサポートなしの両方で動作するコードに変更し、サポートなしの場合はメインページのキャンバスを通常のように表示します。

-
-

余談ですがページをレスポンシブにするためにOffscreenCanvasが必要な場合、フォールバックを持つ意味がよくわかりません。 -メインページで実行するかWorkerで実行するかには、Workerで実行している時にメインページで実行している時よりも多くの事ができるように -調整するかもしれません。何をするかは本当にあなた次第です。

-
-

まず最初にthree.jsのコードとWorkerの固有コードを分離しましょう。 -これでメインページとWorkerの両方で同じコードを使う事ができます。 -つまり、3つのファイルを持つ事になります。

-
    -
  1. htmlファイル

    -

    threejs-offscreencanvas-w-fallback.html

    -
  2. -
  3. three.jsを含むJavaScriptコード

    -

    shared-cubes.js

    -
  4. -
  5. workerをサポートするコード

    -

    offscreencanvas-worker-cubes.js

    -
  6. -
-

shared-cubes.jsoffscreencanvas-worker-cubes.js は前の offscreencanvas-cubes.js ファイルを分割したものです。

-

まず offscreencanvas-cubes.js を全て shared-cube.js にコピーします。 -次にHTMLファイルには既に main があり、initstate をエクスポートする必要があるため main の名前を init に変更します。

-
import * as THREE from 'three';
-
--const state = {
-+export const state = {
-  width: 300,   // canvas default
-  height: 150,  // canvas default
-};
-
--function main(data) {
-+export function init(data) {
-  const {canvas} = data;
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-
-

そして、three.js関連以外の部分だけを切り取ります。

-
-function size(data) {
--  state.width = data.width;
--  state.height = data.height;
--}
--
--const handlers = {
--  main,
--  size,
--};
--
--self.onmessage = function(e) {
--  const fn = handlers[e.data.type];
--  if (typeof fn !== 'function') {
--    throw new Error('no handler for type: ' + e.data.type);
--  }
--  fn(e.data);
--};
-
-

削除した部分を offscreencanvas-worker-cubes.js にコピーして shared-cubes.js をインポートし、main の代わりに init を呼び出します。

-
import {init, state} from './shared-cubes.js';
-
-function size(data) {
-  state.width = data.width;
-  state.height = data.height;
-}
-
-const handlers = {
--  main,
-+  init,
-  size,
-};
-
-self.onmessage = function(e) {
-  const fn = handlers[e.data.type];
-  if (typeof fn !== 'function') {
-    throw new Error('no handler for type: ' + e.data.type);
-  }
-  fn(e.data);
-};
-
-

同様にメインページに shared-cubes.js を含める必要があります。

-
<script type="module">
-+import {init, state} from './shared-cubes.js';
-
-

前に追加したHTMLとCSSを削除します。

-
<body>
-  <canvas id="c"></canvas>
--  <div id="noOffscreenCanvas" style="display:none;">
--    <div>no OffscreenCanvas support</div>
--  </div>
-</body>
-
-

そして、CSSは以下のようになります。

-
-#noOffscreenCanvas {
--    display: flex;
--    width: 100vw;
--    height: 100vh;
--    align-items: center;
--    justify-content: center;
--    background: red;
--    color: white;
--}
-
-

次にブラウザが OffscreenCanvas をサポートありなしに応じて、メインページのコードを変更して起動関数を呼び出すようにしてみましょう。

-
function main() {
-  const canvas = document.querySelector('#c');
--  if (!canvas.transferControlToOffscreen) {
--    canvas.style.display = 'none';
--    document.querySelector('#noOffscreenCanvas').style.display = '';
--    return;
--  }
--  const offscreen = canvas.transferControlToOffscreen();
--  const worker = new Worker('offscreencanvas-picking.js', {type: 'module'});
--  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
-+  if (canvas.transferControlToOffscreen) {
-+    startWorker(canvas);
-+  } else {
-+    startMainPage(canvas);
-+  }
-  ...
-
-

Workerのセットアップコードを全て startWorker の中に移動します。

-
function startWorker(canvas) {
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-worker-cubes.js', {type: 'module'});
-  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
-
-  function sendSize() {
-    worker.postMessage({
-      type: 'size',
-      width: canvas.clientWidth,
-      height: canvas.clientHeight,
-    });
-  }
-
-  window.addEventListener('resize', sendSize);
-  sendSize();
-
-  console.log('using OffscreenCanvas');
-}
-
-

そして main の代わりに init を送信します。

-
-  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
-+  worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);
-
-

メインページで開始するには次のようにします。

-
function startMainPage(canvas) {
-  init({canvas});
-
-  function sendSize() {
-    state.width = canvas.clientWidth;
-    state.height = canvas.clientHeight;
-  }
-  window.addEventListener('resize', sendSize);
-  sendSize();
-
-  console.log('using regular canvas');
-}
-
-

このサンプルコードではOffscreenCanvasで実行、またはメインページで実行されるようにフォールバックしています。

-

- -

-

比較的簡単でした。ピッキングしてみましょう。 -ピッキングの記事にある RayCaster の例からコードをいくつか取り出し、画面外でオフスクリーンが動作するようにします。

-

shared-cube.jsshared-picking.js にコピーし、ピッキング部分を追加してみましょう。 -この例では PickHelper をコピーします。

-
class PickHelper {
-  constructor() {
-    this.raycaster = new THREE.Raycaster();
-    this.pickedObject = null;
-    this.pickedObjectSavedColor = 0;
-  }
-  pick(normalizedPosition, scene, camera, time) {
-    // restore the color if there is a picked object
-    if (this.pickedObject) {
-      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
-      this.pickedObject = undefined;
-    }
-
-    // cast a ray through the frustum
-    this.raycaster.setFromCamera(normalizedPosition, camera);
-    // get the list of objects the ray intersected
-    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
-    if (intersectedObjects.length) {
-      // pick the first object. It's the closest one
-      this.pickedObject = intersectedObjects[0].object;
-      // save its color
-      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
-      // set its emissive color to flashing red/yellow
-      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
-    }
-  }
-}
-
-const pickPosition = {x: 0, y: 0};
-const pickHelper = new PickHelper();
-
-

マウスの pickPosition を以下のように更新しました。

-
function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function setPickPosition(event) {
-  const pos = getCanvasRelativePosition(event);
-  pickPosition.x = (pos.x / canvas.width ) *  2 - 1;
-  pickPosition.y = (pos.y / canvas.height) * -2 + 1;  // note we flip Y
-}
-window.addEventListener('mousemove', setPickPosition);
-
-

Workerではマウスの位置を直接読み取れないので、サイズのコードと同じようにマウスの位置を指定してメッセージを送信してみましょう。 -サイズのコードと同様にマウスの位置を送信して pickPosition を更新します。

-
function size(data) {
-  state.width = data.width;
-  state.height = data.height;
-}
-
-+function mouse(data) {
-+  pickPosition.x = data.x;
-+  pickPosition.y = data.y;
-+}
-
-const handlers = {
-  init,
-+  mouse,
-  size,
-};
-
-self.onmessage = function(e) {
-  const fn = handlers[e.data.type];
-  if (typeof fn !== 'function') {
-    throw new Error('no handler for type: ' + e.data.type);
-  }
-  fn(e.data);
-};
-
-

メインページに戻ってマウスをWorkerやメインページに渡すコードを追加します。

-
+let sendMouse;
-
-function startWorker(canvas) {
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-worker-picking.js', {type: 'module'});
-  worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);
-
-+  sendMouse = (x, y) => {
-+    worker.postMessage({
-+      type: 'mouse',
-+      x,
-+      y,
-+    });
-+  };
-
-  function sendSize() {
-    worker.postMessage({
-      type: 'size',
-      width: canvas.clientWidth,
-      height: canvas.clientHeight,
-    });
-  }
-
-  window.addEventListener('resize', sendSize);
-  sendSize();
-
-  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
-}
-
-function startMainPage(canvas) {
-  init({canvas});
-
-+  sendMouse = (x, y) => {
-+    pickPosition.x = x;
-+    pickPosition.y = y;
-+  };
-
-  function sendSize() {
-    state.width = canvas.clientWidth;
-    state.height = canvas.clientHeight;
-  }
-  window.addEventListener('resize', sendSize);
-  sendSize();
-
-  console.log('using regular canvas');  /* eslint-disable-line no-console */
-}
-
-

全てのマウス操作コードをメインページにコピーし、sendMouse を使用するようにマイナーチェンジを加えます。

-
function setPickPosition(event) {
-  const pos = getCanvasRelativePosition(event);
--  pickPosition.x = (pos.x / canvas.clientWidth ) *  2 - 1;
--  pickPosition.y = (pos.y / canvas.clientHeight) * -2 + 1;  // note we flip Y
-+  sendMouse(
-+      (pos.x / canvas.clientWidth ) *  2 - 1,
-+      (pos.y / canvas.clientHeight) * -2 + 1);  // note we flip Y
-}
-
-function clearPickPosition() {
-  // unlike the mouse which always has a position
-  // if the user stops touching the screen we want
-  // to stop picking. For now we just pick a value
-  // unlikely to pick something
--  pickPosition.x = -100000;
--  pickPosition.y = -100000;
-+  sendMouse(-100000, -100000);
-}
-window.addEventListener('mousemove', setPickPosition);
-window.addEventListener('mouseout', clearPickPosition);
-window.addEventListener('mouseleave', clearPickPosition);
-
-window.addEventListener('touchstart', (event) => {
-  // prevent the window from scrolling
-  event.preventDefault();
-  setPickPosition(event.touches[0]);
-}, {passive: false});
-
-window.addEventListener('touchmove', (event) => {
-  setPickPosition(event.touches[0]);
-});
-
-window.addEventListener('touchend', clearPickPosition);
-
-

これでこのピッキングは OffscreenCanvas で動作するはずです。

-

- -

-

もう1歩踏み込んで OrbitControls を追加してみましょう。 -これはもう少し複雑です。 -OrbitControls はマウス、タッチイベント、キーボードなどDOMをかなり広範囲にチェックしています。

-

これまでのコードとは異なり、グローバルな state オブジェクトを使う事はできません。 -これを使用して動作するようにOrbitControlsのコードを全て書き換える必要はありません。 -OrbitControlsは HTMLElement を取り、それに使用するDOMイベントのほとんどをアタッチします。 -OrbitControlsが必要とする機能をサポートする必要があります。

-

OrbitControlsのソースコードを掘り下げてみると、次のイベントを処理する必要があるように見えます。

-
    -
  • contextmenu
  • -
  • pointerdown
  • -
  • pointermove
  • -
  • pointerup
  • -
  • touchstart
  • -
  • touchmove
  • -
  • touchend
  • -
  • wheel
  • -
  • keydown
  • -
-

マウスイベントには ctrlKeymetaKeyshiftKeybuttonpointerTypeclientXclientYpageXpageY プロパティが必要です。

-

キーダウンイベントには ctrlKey, metaKey, shiftKey, keyCode プロパティが必要です。

-

ホイールイベントに必要なのは deltaY プロパティだけです。

-

また、タッチイベントに必要なのは touches プロパティの pageXpageY だけです。

-

そこでproxyオブジェクトのペアを作ってみましょう。 -ある時はメインページで実行され、全てのイベント、関連するプロパティ値をWorkerに渡します。 -また、ある時はWorkerで実行され、全てのイベント、DOMイベントと同じ構造をもつイベントをメインページに渡すので、OrbitControlsは違いを見分けられません。

-

ここにWorker部分のコードがあります。

-
import {EventDispatcher} from 'three';
-
-class ElementProxyReceiver extends EventDispatcher {
-  constructor() {
-    super();
-  }
-  handleEvent(data) {
-    this.dispatchEvent(data);
-  }
-}
-
-

メッセージを受信した場合にdataを送信するだけです。 -これは EventDispatcher を継承しており、DOM要素のように addEventListenerremoveEventListener のようなメソッドを提供しているので、OrbitControlsに渡せば動作するはずです。

-

ElementProxyReceiver は1つの要素を扱います。 -私たちの場合は1つの頭しか必要ありませんが、頭で考えるのがベストです。 -つまり、マネージャーを作って複数のElementProxyReceiverを管理するようにしましょう。

-
class ProxyManager {
-  constructor() {
-    this.targets = {};
-    this.handleEvent = this.handleEvent.bind(this);
-  }
-  makeProxy(data) {
-    const {id} = data;
-    const proxy = new ElementProxyReceiver();
-    this.targets[id] = proxy;
-  }
-  getProxy(id) {
-    return this.targets[id];
-  }
-  handleEvent(data) {
-    this.targets[data.id].handleEvent(data.data);
-  }
-}
-
-

ProxyManagerのインスタンスを作成し makeProxy メソッドにidを指定して呼び出す事で、そのidを持つメッセージに応答する ElementProxyReceiver を作成できます。

-

Workerのメッセージハンドラに接続してみましょう。

-
const proxyManager = new ProxyManager();
-
-function start(data) {
-  const proxy = proxyManager.getProxy(data.canvasId);
-  init({
-    canvas: data.canvas,
-    inputElement: proxy,
-  });
-}
-
-function makeProxy(data) {
-  proxyManager.makeProxy(data);
-}
-
-...
-
-const handlers = {
--  init,
--  mouse,
-+  start,
-+  makeProxy,
-+  event: proxyManager.handleEvent,
-   size,
-};
-
-self.onmessage = function(e) {
-  const fn = handlers[e.data.type];
-  if (typeof fn !== 'function') {
-    throw new Error('no handler for type: ' + e.data.type);
-  }
-  fn(e.data);
-};
-
-

共有のthree.jsコードでは OrbitControls をインポートして設定する必要があります。

-
import * as THREE from 'three';
-+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-
-export function init(data) {
--  const {canvas} = data;
-+  const {canvas, inputElement} = data;
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-
-+  const controls = new OrbitControls(camera, inputElement);
-+  controls.target.set(0, 0, 0);
-+  controls.update();
-
-

OffscreenCanvas以外のサンプルコード例のようにキャンバスを渡すのではなく、 -inputElement を介してOrbitControlsをProxyに渡している事に注目して下さい。

-

次に canvasinputElement に変更し、HTMLファイルから全てのピッキングイベントのコードを共有のthree.jsコードに移動させます。

-
function getCanvasRelativePosition(event) {
--  const rect = canvas.getBoundingClientRect();
-+  const rect = inputElement.getBoundingClientRect();
-  return {
-    x: event.clientX - rect.left,
-    y: event.clientY - rect.top,
-  };
-}
-
-function setPickPosition(event) {
-  const pos = getCanvasRelativePosition(event);
--  sendMouse(
--      (pos.x / canvas.clientWidth ) *  2 - 1,
--      (pos.y / canvas.clientHeight) * -2 + 1);  // note we flip Y
-+  pickPosition.x = (pos.x / inputElement.clientWidth ) *  2 - 1;
-+  pickPosition.y = (pos.y / inputElement.clientHeight) * -2 + 1;  // note we flip Y
-}
-
-function clearPickPosition() {
-  // unlike the mouse which always has a position
-  // if the user stops touching the screen we want
-  // to stop picking. For now we just pick a value
-  // unlikely to pick something
--  sendMouse(-100000, -100000);
-+  pickPosition.x = -100000;
-+  pickPosition.y = -100000;
-}
-
-*inputElement.addEventListener('mousemove', setPickPosition);
-*inputElement.addEventListener('mouseout', clearPickPosition);
-*inputElement.addEventListener('mouseleave', clearPickPosition);
-
-*inputElement.addEventListener('touchstart', (event) => {
-  // prevent the window from scrolling
-  event.preventDefault();
-  setPickPosition(event.touches[0]);
-}, {passive: false});
-
-*inputElement.addEventListener('touchmove', (event) => {
-  setPickPosition(event.touches[0]);
-});
-
-*inputElement.addEventListener('touchend', clearPickPosition);
-
-

メインページに戻り、上記で列挙した全てのイベントにメッセージを送信するコードが必要です。

-
let nextProxyId = 0;
-class ElementProxy {
-  constructor(element, worker, eventHandlers) {
-    this.id = nextProxyId++;
-    this.worker = worker;
-    const sendEvent = (data) => {
-      this.worker.postMessage({
-        type: 'event',
-        id: this.id,
-        data,
-      });
-    };
-
-    // register an id
-    worker.postMessage({
-      type: 'makeProxy',
-      id: this.id,
-    });
-    for (const [eventName, handler] of Object.entries(eventHandlers)) {
-      element.addEventListener(eventName, function(event) {
-        handler(event, sendEvent);
-      });
-    }
-  }
-}
-
-

ElementProxy はProxyしたいイベントの要素を受け取ります。 -次にWorkerにidを登録し、先ほど設定した makeProxy メッセージを使って送信します。 -Workerは ElementProxyReceiver を作成しそのidに登録します。

-

そして登録するイベントハンドラのオブジェクトを用意します。 -このようにして、Workerに転送したいイベントにハンドラを渡す事ができます。

-

Workerを起動する時はまずProxyを作成しイベントハンドラを渡します。

-
function startWorker(canvas) {
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-worker-orbitcontrols.js', {type: 'module'});
-
-+  const eventHandlers = {
-+    contextmenu: preventDefaultHandler,
-+    mousedown: mouseEventHandler,
-+    mousemove: mouseEventHandler,
-+    mouseup: mouseEventHandler,
-+    pointerdown: mouseEventHandler,
-+    pointermove: mouseEventHandler,
-+    pointerup: mouseEventHandler,
-+    touchstart: touchEventHandler,
-+    touchmove: touchEventHandler,
-+    touchend: touchEventHandler,
-+    wheel: wheelEventHandler,
-+    keydown: filteredKeydownEventHandler,
-+  };
-+  const proxy = new ElementProxy(canvas, worker, eventHandlers);
-  worker.postMessage({
-    type: 'start',
-    canvas: offscreen,
-+    canvasId: proxy.id,
-  }, [offscreen]);
-  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
-}
-
-

以下はイベントハンドラです。 -受信したイベントからプロパティのリストをコピーするだけです。 -sendEvent 関数に渡され作成したデータを渡します。 -この関数は正しいidを追加してWorkerに送信します。

-
const mouseEventHandler = makeSendPropertiesHandler([
-  'ctrlKey',
-  'metaKey',
-  'shiftKey',
-  'button',
-  'pointerType',
-  'clientX',
-  'clientY',
-  'pointerId',
-  'pageX',
-  'pageY',
-]);
-const wheelEventHandlerImpl = makeSendPropertiesHandler([
-  'deltaX',
-  'deltaY',
-]);
-const keydownEventHandler = makeSendPropertiesHandler([
-  'ctrlKey',
-  'metaKey',
-  'shiftKey',
-  'keyCode',
-]);
-
-function wheelEventHandler(event, sendFn) {
-  event.preventDefault();
-  wheelEventHandlerImpl(event, sendFn);
-}
-
-function preventDefaultHandler(event) {
-  event.preventDefault();
-}
-
-function copyProperties(src, properties, dst) {
-  for (const name of properties) {
-      dst[name] = src[name];
-  }
-}
-
-function makeSendPropertiesHandler(properties) {
-  return function sendProperties(event, sendFn) {
-    const data = {type: event.type};
-    copyProperties(event, properties, data);
-    sendFn(data);
-  };
-}
-
-function touchEventHandler(event, sendFn) {
-  // preventDefault() fixes mousemove, mouseup and mousedown 
-  // firing when doing a simple touchup touchdown
-  // Happens only at offscreen canvas
-  event.preventDefault(); 
-  const touches = [];
-  const data = {type: event.type, touches};
-  for (let i = 0; i < event.touches.length; ++i) {
-    const touch = event.touches[i];
-    touches.push({
-      pageX: touch.pageX,
-      pageY: touch.pageY,
-      clientX: touch.clientX,
-      clientY: touch.clientY,
-    });
-  }
-  sendFn(data);
-}
-
-// The four arrow keys
-const orbitKeys = {
-  '37': true,  // left
-  '38': true,  // up
-  '39': true,  // right
-  '40': true,  // down
-};
-function filteredKeydownEventHandler(event, sendFn) {
-  const {keyCode} = event;
-  if (orbitKeys[keyCode]) {
-    event.preventDefault();
-    keydownEventHandler(event, sendFn);
-  }
-}
-
-

これで動くと思われるが、実際に試してみると OrbitControls がもう少し必要なものがあると分かります。

-

1つは element.focus です。Workerには必要ないのでStubを追加しておきましょう。

-
class ElementProxyReceiver extends THREE.EventDispatcher {
-  constructor() {
-    super();
-  }
-  handleEvent(data) {
-    this.dispatchEvent(data);
-  }
-+  focus() {
-+    // no-op
-+  }
-}
-
-

もう1つは event.preventDefaultevent.stopPropagation を呼び出す事です。 -メインページでは既に対応してるのでそれらも不要になります。

-
+function noop() {
-+}
-
-class ElementProxyReceiver extends THREE.EventDispatcher {
-  constructor() {
-    super();
-  }
-  handleEvent(data) {
-+    data.preventDefault = noop;
-+    data.stopPropagation = noop;
-    this.dispatchEvent(data);
-  }
-  focus() {
-    // no-op
-  }
-}
-
-

もう1つは clientWidthclientHeight を見る事です。 -以前はサイズを渡してましたが、Proxyペアを更新してそれも渡すようにします。

-

Workerの中では

-
class ElementProxyReceiver extends THREE.EventDispatcher {
-  constructor() {
-    super();
-  }
-+  get clientWidth() {
-+    return this.width;
-+  }
-+  get clientHeight() {
-+    return this.height;
-+  }
-+  getBoundingClientRect() {
-+    return {
-+      left: this.left,
-+      top: this.top,
-+      width: this.width,
-+      height: this.height,
-+      right: this.left + this.width,
-+      bottom: this.top + this.height,
-+    };
-+  }
-  handleEvent(data) {
-+    if (data.type === 'size') {
-+      this.left = data.left;
-+      this.top = data.top;
-+      this.width = data.width;
-+      this.height = data.height;
-+      return;
-+    }
-    data.preventDefault = noop;
-    data.stopPropagation = noop;
-    this.dispatchEvent(data);
-  }
-  focus() {
-    // no-op
-  }
-}
-
-

メインページに戻るにはサイズと左と上の位置も送信する必要があります。 -このままではキャンバスを移動しても処理されず、サイズを変更しても処理されないです。 -移動を処理したい場合は何かがキャンバスを移動する度に sendSize を呼び出す必要があります。

-
class ElementProxy {
-  constructor(element, worker, eventHandlers) {
-    this.id = nextProxyId++;
-    this.worker = worker;
-    const sendEvent = (data) => {
-      this.worker.postMessage({
-        type: 'event',
-        id: this.id,
-        data,
-      });
-    };
-
-    // register an id
-    worker.postMessage({
-      type: 'makeProxy',
-      id: this.id,
-    });
-+    sendSize();
-    for (const [eventName, handler] of Object.entries(eventHandlers)) {
-      element.addEventListener(eventName, function(event) {
-        handler(event, sendEvent);
-      });
-    }
-
-+    function sendSize() {
-+      const rect = element.getBoundingClientRect();
-+      sendEvent({
-+        type: 'size',
-+        left: rect.left,
-+        top: rect.top,
-+        width: element.clientWidth,
-+        height: element.clientHeight,
-+      });
-+    }
-+
-+    window.addEventListener('resize', sendSize);
-  }
-}
-
-

そして共有のthree.jsコードでは state は不要になりました。

-
-export const state = {
--  width: 300,   // canvas default
--  height: 150,  // canvas default
--};
-
-...
-
-function resizeRendererToDisplaySize(renderer) {
-  const canvas = renderer.domElement;
--  const width = state.width;
--  const height = state.height;
-+  const width = inputElement.clientWidth;
-+  const height = inputElement.clientHeight;
-  const needResize = canvas.width !== width || canvas.height !== height;
-  if (needResize) {
-    renderer.setSize(width, height, false);
-  }
-  return needResize;
-}
-
-function render(time) {
-  time *= 0.001;
-
-  if (resizeRendererToDisplaySize(renderer)) {
--    camera.aspect = state.width / state.height;
-+    camera.aspect = inputElement.clientWidth / inputElement.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-  ...
-
-

他にもいくつかのハックがあります。 -OrbitControlsは pointermovepointerup イベントをマウスキャプチャ(マウスがウィンドウの外に出た時)を処理するための要素の ownerDocument です。

-

さらにコードはグローバルな document を参照していますが、Workerにはグローバルなdocumentはありません。

-

これは2つの簡単なハックで全て解決できます。 -Workerコードでは両方の問題に対してProxyを再利用します。

-
function start(data) {
-  const proxy = proxyManager.getProxy(data.canvasId);
-+  proxy.ownerDocument = proxy; // HACK!
-+  self.document = {} // HACK!
-  init({
-    canvas: data.canvas,
-    inputElement: proxy,
-  });
-}
-
-

これで OrbitControls が期待に沿った検査を行うための機能を提供します。

-

難しいのは分かっていますが手短に言うと:

-

ElementProxy はメインページ上で動作し、DOMイベントを転送します。 -Worker内の ElementProxyReceiver は一緒に使うことができる HTMLElement を装っています。 -OrbitControls と独自のコードを使用しています。

-

最後にOffscreenCanvasを使用していない時のフォールバックです。 -必要なのはcanvas自体を inputElement として渡す事です。

-
function startMainPage(canvas) {
--  init({canvas});
-+  init({canvas, inputElement: canvas});
-  console.log('using regular canvas');
-}
-
-

これでOrbitControlsがOffscreenCanvasで動作するようになりました。

-

- -

-

これはおそらくこのサイトで最も複雑な例です。 -各サンプルには3つのファイルが含まれているので少しわかりにくいです。 -HTMLファイル、Workerファイル、共有のthree.jsコードなどです。

-

理解する事が難し過ぎず、少しでも参考になれば幸いです。 -three.js、OffscreenCanvas、Web Workerを使った動作の便利な例を紹介しました。

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/optimize-lots-of-objects-animated.html b/manual/ja/optimize-lots-of-objects-animated.html deleted file mode 100644 index d8f7a490d29a7e..00000000000000 --- a/manual/ja/optimize-lots-of-objects-animated.html +++ /dev/null @@ -1,452 +0,0 @@ - - - でアニメーションする多くのオブジェクトを最適化 - - - - - - - - - - - - - -
-
-

でアニメーションする多くのオブジェクトを最適化

-
-
-
-

この記事は多くのオブジェクトを最適化の続きです。まだ読んでいない場合は先に読んでみて下さい。

-

前回の記事では約19000個のキューブを単体のジオメトリにマージしました。 -19000個のキューブの描画を最適化する利点がありましたが、個々のキューブを動かすのが難しくなる欠点がありました。

-

何を達成するかによって様々な解決策があります。 -今回は複数のデータセットをグラフ化し、そのデータセットでクロスフェードアニメーションさせてみましょう。

-

まず、複数のデータセットを取得する必要があります。 -オフラインでデータの前処理をするのが理想的ですが、今回は2つのデータセットをロードしてさらに2つのデータを生成してみましょう。

-

以下は古いデータロードのコードです。

-
loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
-  .then(parseData)
-  .then(addBoxes)
-  .then(render);
-
-

このような感じに変更してみましょう。

-
async function loadData(info) {
-  const text = await loadFile(info.url);
-  info.file = parseData(text);
-}
-
-async function loadAll() {
-  const fileInfos = [
-    {name: 'men',   hueRange: [0.7, 0.3], url: 'resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc' },
-    {name: 'women', hueRange: [0.9, 1.1], url: 'resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014ft_2010_cntm_1_deg.asc' },
-  ];
-
-  await Promise.all(fileInfos.map(loadData));
-
-  ...
-}
-loadAll();
-
-

上記のコードでは fileInfos 内の各オブジェクトがローティングされたファイルを file プロパティに持ち、Promise.allで全てのファイルをロードします。 -namehueRange プロパティはあとで使います。name はUIフィールドです。hueRange は色相の範囲をマップし選択するために使います。

-

上記2ファイルは2010年時点でのエリア別の男性数と女性数を示しています。

-

注:このデータが正しいかわかりませんが、それは重要ではありません。 -重要なのは異なるデータセットを示す事です。

-

さらに2つのデータセットを生成してみましょう。 -1つは女性数よりも男性数が多い場所、逆にもう1つは男性数より女性数が多い場所です。

-

まず先ほどのデータで新しい2次元配列をマップする前に、2次元配列を生成する関数を書いてみましょう。

-
function mapValues(data, fn) {
-  return data.map((row, rowNdx) => {
-    return row.map((value, colNdx) => {
-      return fn(value, rowNdx, colNdx);
-    });
-  });
-}
-
-

通常の Array.map 関数と同様に mapValues 関数は配列の各値に対して関数 fn を呼び出します。 -fnには値と行と列のインデックスを渡します。

-

2つのファイルを比較した新しいファイルを生成するコードを作成します。

-
function makeDiffFile(baseFile, otherFile, compareFn) {
-  let min;
-  let max;
-  const baseData = baseFile.data;
-  const otherData = otherFile.data;
-  const data = mapValues(baseData, (base, rowNdx, colNdx) => {
-    const other = otherData[rowNdx][colNdx];
-      if (base === undefined || other === undefined) {
-        return undefined;
-      }
-      const value = compareFn(base, other);
-      min = Math.min(min === undefined ? value : min, value);
-      max = Math.max(max === undefined ? value : max, value);
-      return value;
-  });
-  // make a copy of baseFile and replace min, max, and data
-  // with the new data
-  return {...baseFile, min, max, data};
-}
-
-

上記のコードは compareFn 関数で比較された値を元に mapValues 関数で新しいデータセットを生成しています。また minmax の比較結果も持っています。 -最後のreturnで新しく minmaxdata を追加した以外は baseFile と同じプロパティを持つ新しいファイルを作成します。

-

それを使って2つの新しいデータセットを作りましょう。

-
{
-  const menInfo = fileInfos[0];
-  const womenInfo = fileInfos[1];
-  const menFile = menInfo.file;
-  const womenFile = womenInfo.file;
-
-  function amountGreaterThan(a, b) {
-    return Math.max(a - b, 0);
-  }
-  fileInfos.push({
-    name: '>50%men',
-    hueRange: [0.6, 1.1],
-    file: makeDiffFile(menFile, womenFile, (men, women) => {
-      return amountGreaterThan(men, women);
-    }),
-  });
-  fileInfos.push({
-    name: '>50% women',
-    hueRange: [0.0, 0.4],
-    file: makeDiffFile(womenFile, menFile, (women, men) => {
-      return amountGreaterThan(women, men);
-    }),
-  });
-}
-
-

これらのデータセットを選択するUIを生成しましょう。まず、いくつかのhtmlのUIが必要です。

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="ui"></div>
-</body>
-
-

次に左上のエリアに表示するためにCSSを追加しました。

-
#ui {
-  position: absolute;
-  left: 1em;
-  top: 1em;
-}
-#ui>div {
-  font-size: 20pt;
-  padding: 1em;
-  display: inline-block;
-}
-#ui>div.selected {
-  color: red;
-}
-
-

各ファイルを調べてデータセットごとにマージされたボックスのセットを生成します。 -これでラベル上にマウスカーソルを置くとそのデータセットを表示し、他の全てのデータセットを非表示にするラベルUIを生成できます。

-
// show the selected data, hide the rest
-function showFileInfo(fileInfos, fileInfo) {
-  fileInfos.forEach((info) => {
-    const visible = fileInfo === info;
-    info.root.visible = visible;
-    info.elem.className = visible ? 'selected' : '';
-  });
-  requestRenderIfNotRequested();
-}
-
-const uiElem = document.querySelector('#ui');
-fileInfos.forEach((info) => {
-  const boxes = addBoxes(info.file, info.hueRange);
-  info.root = boxes;
-  const div = document.createElement('div');
-  info.elem = div;
-  div.textContent = info.name;
-  uiElem.appendChild(div);
-  div.addEventListener('mouseover', () => {
-    showFileInfo(fileInfos, info);
-  });
-});
-// show the first set of data
-showFileInfo(fileInfos, fileInfos[0]);
-
-

もう1つ変更が必要で addBoxes の引数に hueRange があります。

-
-function addBoxes(file) {
-+function addBoxes(file, hueRange) {
-
-  ...
-
-    // compute a color
--    const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
-+    const hue = THREE.MathUtils.lerp(...hueRange, amount);
-
-  ...
-
-

これで4つのデータセットを表示できるようになるはずです。ラベルの上にマウスを置いたり、タッチしてデータセットを切り替える事ができます。

-

- -

-

注意してほしいのは突出したいくつかの奇妙なデータポイントがあります。

-

これは何が起きてるのでしょう!?

-

いずれにしてもこの4つのデータセットをラベルから切り替えた際にクロスフェードアニメーションさせるにはどうすればいいのでしょうか。

-

たくさんのアイデアがあります。

-
    -
  • Material.opacity でクロスフェードアニメーションする

    -

    この解決策の問題点はキューブが完全に重なっているため、Z軸の戦いの問題を意味します。 -depth関数とブレンディングを使い修正できる可能性があります。調べてみた方が良さそうですね。

    -
  • -
  • 見たいデータセットをスケールアップして他のデータセットをスケールダウンする

    -

    全てのボックスは惑星の中心に位置しているので、1.0以下に縮小すると惑星の中に沈んでしまいます。 -最初は良いアイデアのように聞こえますが、高さの低いボックスはほとんどすぐに消えてしまい、新しいデータセットが1.0までスケールアップするまで置き換えできません。 -このため、アニメーション遷移があまり気持ち良くありません。派手なカスタムシェーダーで修正できるかもしれません。

    -
  • -
  • モーフターゲットを使用する

    -

    モーフターゲットはジオメトリ内の各頂点に複数の値を与え、それらの中間を モーフ または lerp (線形補間) する方法です。 -モーフターゲットは3Dキャラクターの表情アニメーションに最も一般的に使用されていますがそれだけではありません。

    -
  • -
-

モーフターゲットを使ってみましょう。

-

これまで通りにデータセットごとにジオメトリを作成しますが、それぞれのデータから position を抜き出してモーフターゲットとして使用します。

-

まず addBoxes を変更してマージされたジオメトリを返すだけに変更してみましょう。

-
-function addBoxes(file, hueRange) {
-+function makeBoxes(file, hueRange) {
-  const {min, max, data} = file;
-  const range = max - min;
-
-  ...
-
--  const mergedGeometry = BufferGeometryUtils.mergeGeometries(
--      geometries, false);
--  const material = new THREE.MeshBasicMaterial({
--    vertexColors: true,
--  });
--  const mesh = new THREE.Mesh(mergedGeometry, material);
--  scene.add(mesh);
--  return mesh;
-+  return BufferGeometryUtils.mergeGeometries(
-+     geometries, false);
-}
-
-

ここでもう1つやるべき事があります。モーフターゲットは全ての頂点数が全く同じである必要があります。 -あるターゲットの頂点#123は、他の全てのターゲットに対応する頂点#123を持つ必要があります。 -しかし、異なるデータセットにはデータのないデータポイントがあるかもしれないので、 -そのポイントに対してはボックスが生成されず、別のデータセットに対応する頂点も生成されません。

-

そこで全てのデータセットをチェックし、どのセットにもデータがある場合は常に何かを生成するか、 -またはどのセットにもデータがない場合は何も生成しないかのどちらかを選択する必要があります。後者をやってみましょう。

-
+function dataMissingInAnySet(fileInfos, latNdx, lonNdx) {
-+  for (const fileInfo of fileInfos) {
-+    if (fileInfo.file.data[latNdx][lonNdx] === undefined) {
-+      return true;
-+    }
-+  }
-+  return false;
-+}
-
--function makeBoxes(file, hueRange) {
-+function makeBoxes(file, hueRange, fileInfos) {
-  const {min, max, data} = file;
-  const range = max - min;
-
-  ...
-
-  const geometries = [];
-  data.forEach((row, latNdx) => {
-    row.forEach((value, lonNdx) => {
-+      if (dataMissingInAnySet(fileInfos, latNdx, lonNdx)) {
-+        return;
-+      }
-      const amount = (value - min) / range;
-
-  ...
-
-

addBoxes を呼び出していたコードを makeBoxes に変更し、モーフターゲットを設定します。

-
+// make geometry for each data set
-+const geometries = fileInfos.map((info) => {
-+  return makeBoxes(info.file, info.hueRange, fileInfos);
-+});
-+
-+// use the first geometry as the base
-+// and add all the geometries as morphtargets
-+const baseGeometry = geometries[0];
-+baseGeometry.morphAttributes.position = geometries.map((geometry, ndx) => {
-+  const attribute = geometry.getAttribute('position');
-+  const name = `target${ndx}`;
-+  attribute.name = name;
-+  return attribute;
-+});
-+baseGeometry.morphAttributes.color = geometries.map((geometry, ndx) => {
-+  const attribute = geometry.getAttribute('color');
-+  const name = `target${ndx}`;
-+  attribute.name = name;
-+  return attribute;
-+});
-+const material = new THREE.MeshBasicMaterial({
-+  vertexColors: true,
-+});
-+const mesh = new THREE.Mesh(baseGeometry, material);
-+scene.add(mesh);
-
-const uiElem = document.querySelector('#ui');
-fileInfos.forEach((info) => {
--  const boxes = addBoxes(info.file, info.hueRange);
--  info.root = boxes;
-  const div = document.createElement('div');
-  info.elem = div;
-  div.textContent = info.name;
-  uiElem.appendChild(div);
-  function show() {
-    showFileInfo(fileInfos, info);
-  }
-  div.addEventListener('mouseover', show);
-  div.addEventListener('touchstart', show);
-});
-// show the first set of data
-showFileInfo(fileInfos, fileInfos[0]);
-
-

上記では最初のデータセットをベースとしたジオメトリを作成し、各ジオメトリから position を取得し、 -それを position のベースジオメトリにモーフターゲットとして追加します。 -あとはデータセットの表示・非表示の仕方を変える必要があります。 -メッシュを表示・非表示するのではなく、モーフターゲットの影響を変える必要があります。 -見たいデータセットは1の影響を持つ必要があり、見たくないデータセットは0の影響を持つ必要があります。

-

直接0か1にすれば良いのですがそうするとクロスフェードアニメーションが見られなくなり、すでに持っている値に変更がなくスナップします。 -または簡単にカスタムアニメーションのコードを書く事ができますが、 -オリジナルのwebgl globeではアニメーションライブラリを使っているので合わせましょう。

-

アニメーションライブラリをimportする必要があります。

-
import * as THREE from 'three';
-import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
-import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-+import TWEEN from 'three/addons/libs/tween.module.js';
-
-

そして、影響を与えるアニメーションの Tween を作成します。

-
// show the selected data, hide the rest
-function showFileInfo(fileInfos, fileInfo) {
-  fileInfos.forEach((info) => {
-    const visible = fileInfo === info;
--    info.root.visible = visible;
-    info.elem.className = visible ? 'selected' : '';
-+    const targets = {};
-+    fileInfos.forEach((info, i) => {
-+      targets[i] = info === fileInfo ? 1 : 0;
-+    });
-+    const durationInMs = 1000;
-+    new TWEEN.Tween(mesh.morphTargetInfluences)
-+      .to(targets, durationInMs)
-+      .start();
-  });
-  requestRenderIfNotRequested();
-}
-
-

レンダリングループ内でフレームごとに TWEEN.update を呼び出しますが問題があります。 -"tween.js"は連続的なレンダリング用に設計されていますが、ここでは要求されたレンダリングをしています。 -連続的なレンダリングに切り替えれますが、何も起きていない時にはレンダリングコストを下げた方が良いため、要求されたレンダリングだけにするのもいいかもしれません。 -これを助けるために TweenManager を作ります。 -TweenManagerは update メソッドを持ち、再度呼び出す必要がある場合は true を返し、全てのアニメーションが終了した場合は false を返します。

-
class TweenManger {
-  constructor() {
-    this.numTweensRunning = 0;
-  }
-  _handleComplete() {
-    --this.numTweensRunning;
-    console.assert(this.numTweensRunning >= 0);
-  }
-  createTween(targetObject) {
-    const self = this;
-    ++this.numTweensRunning;
-    let userCompleteFn = () => {};
-    // create a new tween and install our own onComplete callback
-    const tween = new TWEEN.Tween(targetObject).onComplete(function(...args) {
-      self._handleComplete();
-      userCompleteFn.call(this, ...args);
-    });
-    // replace the tween's onComplete function with our own
-    // so we can call the user's callback if they supply one.
-    tween.onComplete = (fn) => {
-      userCompleteFn = fn;
-      return tween;
-    };
-    return tween;
-  }
-  update() {
-    TWEEN.update();
-    return this.numTweensRunning > 0;
-  }
-}
-
-

TweenMangerを使用するために次のようなコードにします。

-
function main() {
-  const canvas = document.querySelector('#c');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+  const tweenManager = new TweenManger();
-
-  ...
-
-

TweenMangerを使って Tween を作成します。

-
// show the selected data, hide the rest
-function showFileInfo(fileInfos, fileInfo) {
-  fileInfos.forEach((info) => {
-    const visible = fileInfo === info;
-    info.elem.className = visible ? 'selected' : '';
-    const targets = {};
-    fileInfos.forEach((info, i) => {
-      targets[i] = info === fileInfo ? 1 : 0;
-    });
-    const durationInMs = 1000;
--    new TWEEN.Tween(mesh.morphTargetInfluences)
-+    tweenManager.createTween(mesh.morphTargetInfluences)
-      .to(targets, durationInMs)
-      .start();
-  });
-  requestRenderIfNotRequested();
-}
-
-

次にtweenManagerを更新するためにレンダーループを修正し、アニメーションが実行されている場合はレンダリングを継続します。

-
function render() {
-  renderRequested = false;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-+  if (tweenManager.update()) {
-+    requestRenderIfNotRequested();
-+  }
-
-  controls.update();
-  renderer.render(scene, camera);
-}
-render();
-
-

そして、データセットでクロスフェードアニメーションを行う必要があります。

-

- -

-

これがお役に立てれば幸いです。 -three.jsが提供するサービスを利用するか、カスタムシェーダーを使ってモーフターゲットを使うのは多くのオブジェクトを移動させるための一般的なテクニックです。 -例として全てのキューブに別の目標を設定し、そこから地球上での最初の位置へと変化します。 -地球儀を紹介するにはかっこいいかもしれません。

-

次はHTML要素を3Dに整列させるで説明している地球儀にラベルを追加します。

-

注: 男性や女性の割合、または正の差をグラフ化する事もできますが、情報を表示する方法に基づいて地表から成長するキューブはほとんどのキューブが低い方が良いでしょう。 -これらの他の比較を使用した場合、ほとんどのキューブは最大高さの約1/2の大きさになり可視化として良くありません。 -amountGreaterThan を変えたように感じますが、このような場合は Math.max(a - b, 0)(a - b) "正の差" や a / (a +b) "パーセント" のようなものに変えると何を言っているのかわかるでしょう。

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/optimize-lots-of-objects.html b/manual/ja/optimize-lots-of-objects.html deleted file mode 100644 index 6f64fb6046286e..00000000000000 --- a/manual/ja/optimize-lots-of-objects.html +++ /dev/null @@ -1,467 +0,0 @@ - - - で多くのオブジェクトを最適化 - - - - - - - - - - - - - -
-
-

で多くのオブジェクトを最適化

-
-
-
-

この記事はthree.jsの連載記事の1つです。最初の記事はThree.jsの基礎知識です。まだ読んでいない場合はそこから始めて下さい。

-

three.jsには最適化する方法は多々あります。1つの方法はジオメトリのマージと呼ばれています。メッシュを作成すると描画リクエストを1つ以上行った事を表します。 -2つのメッシュを描画すると結果が同じでも1つのメッシュを描画するよりもオーバーヘッドが大きく、最適化する1つの方法がメッシュのマージです。

-

これが問題解決になるか、例を示してみます。 -WebGL Globeを作り直してみましょう。

-

まずデータが必要です。WebGL Globeで使用しているデータはSEDACです。 -サイトをチェックすると人口統計データのグリッド形式があります。 -私は60分間の解像度データをダウンロードしました。データを見てみると以下のような感じになっています。

-
 ncols         360
- nrows         145
- xllcorner     -180
- yllcorner     -60
- cellsize      0.99999999999994
- NODATA_value  -9999
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- 9.241768 8.790958 2.095345 -9999 0.05114867 -9999 -9999 -9999 -9999 -999...
- 1.287993 0.4395509 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
-
-

キーと値のペアのような行がいくつかあり、その行に続くグリッドポイントごとの値の行があり、データポイントごとに1行ずつ並んでいます。 -データをもっと理解するために2Dでプロットしてみましょう。

-

最初にテキストファイルをロードするコードです。

-
async function loadFile(url) {
-  const res = await fetch(url);
-  return res.text();
-}
-
-

上記のコードは Promise を返しファイルの内容は url にあります。 -次にファイルを解析するコードが必要です。

-
function parseData(text) {
-  const data = [];
-  const settings = {data};
-  let max;
-  let min;
-  // split into lines
-  text.split('\n').forEach((line) => {
-    // split the line by whitespace
-    const parts = line.trim().split(/\s+/);
-    if (parts.length === 2) {
-      // only 2 parts, must be a key/value pair
-      settings[parts[0]] = parseFloat(parts[1]);
-    } else if (parts.length > 2) {
-      // more than 2 parts, must be data
-      const values = parts.map((v) => {
-        const value = parseFloat(v);
-        if (value === settings.NODATA_value) {
-          return undefined;
-        }
-        max = Math.max(max === undefined ? value : max, value);
-        min = Math.min(min === undefined ? value : min, value);
-        return value;
-      });
-      data.push(values);
-    }
-  });
-  return Object.assign(settings, {min, max});
-}
-
-

上記のコードはファイルから全てのキーと値のペアを持つオブジェクトを返します。またdata プロパティには全てのデータを1つの大きな配列にまとめて、データに含まれる minmax の値を返します。

-

次にデータの描画コードが必要です。

-
function drawData(file) {
-  const {min, max, data} = file;
-  const range = max - min;
-  const ctx = document.querySelector('canvas').getContext('2d');
-  // make the canvas the same size as the data
-  ctx.canvas.width = ncols;
-  ctx.canvas.height = nrows;
-  // but display it double size so it's not too small
-  ctx.canvas.style.width = px(ncols * 2);
-  ctx.canvas.style.height = px(nrows * 2);
-  // fill the canvas to dark gray
-  ctx.fillStyle = '#444';
-  ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
-  // draw each data point
-  data.forEach((row, latNdx) => {
-    row.forEach((value, lonNdx) => {
-      if (value === undefined) {
-        return;
-      }
-      const amount = (value - min) / range;
-      const hue = 1;
-      const saturation = 1;
-      const lightness = amount;
-      ctx.fillStyle = hsl(hue, saturation, lightness);
-      ctx.fillRect(lonNdx, latNdx, 1, 1);
-    });
-  });
-}
-
-function px(v) {
-  return `${v | 0}px`;
-}
-
-function hsl(h, s, l) {
-  return `hsl(${h * 360 | 0},${s * 100 | 0}%,${l * 100 | 0}%)`;
-}
-
-

最後にすべてを統合します。

-
loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
-  .then(parseData)
-  .then(drawData);
-
-

そして、以下のような結果が得られました。

-

- -

-

どうやら上手くいったようです。

-

これを3Dでやってみましょう。 -要求されたレンダリングのコードから始めてファイル内のデータごとに1つのボックスを作ります。

-

まずは世界地図テクスチャで簡単な球体を作ってみましょう。テクスチャはこんな感じです。

-
- -

テクスチャをセットするコードです。

-
{
-  const loader = new THREE.TextureLoader();
-  const texture = loader.load('resources/images/world.jpg', render);
-  const geometry = new THREE.SphereGeometry(1, 64, 32);
-  const material = new THREE.MeshBasicMaterial({map: texture});
-  scene.add(new THREE.Mesh(geometry, material));
-}
-
-

テクスチャ読込後に render を呼び出している部分に注目して下さい。 -renderが必要なのは連続的なレンダリングでなく、要求されたレンダリングなのでテクスチャ読込後に一度レンダリングする必要があるからです。

-

次に上記のデータポイントごとにドット描画するコードをデータポイントごとにボックスを作成するコードに変更する必要があります。

-
function addBoxes(file) {
-  const {min, max, data} = file;
-  const range = max - min;
-
-  // make one box geometry
-  const boxWidth = 1;
-  const boxHeight = 1;
-  const boxDepth = 1;
-  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-  // make it so it scales away from the positive Z axis
-  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));
-
-  // these helpers will make it easy to position the boxes
-  // We can rotate the lon helper on its Y axis to the longitude
-  const lonHelper = new THREE.Object3D();
-  scene.add(lonHelper);
-  // We rotate the latHelper on its X axis to the latitude
-  const latHelper = new THREE.Object3D();
-  lonHelper.add(latHelper);
-  // The position helper moves the object to the edge of the sphere
-  const positionHelper = new THREE.Object3D();
-  positionHelper.position.z = 1;
-  latHelper.add(positionHelper);
-
-  const lonFudge = Math.PI * .5;
-  const latFudge = Math.PI * -0.135;
-  data.forEach((row, latNdx) => {
-    row.forEach((value, lonNdx) => {
-      if (value === undefined) {
-        return;
-      }
-      const amount = (value - min) / range;
-      const material = new THREE.MeshBasicMaterial();
-      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
-      const saturation = 1;
-      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
-      material.color.setHSL(hue, saturation, lightness);
-      const mesh = new THREE.Mesh(geometry, material);
-      scene.add(mesh);
-
-      // adjust the helpers to point to the latitude and longitude
-      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
-      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
-
-      // use the world matrix of the position helper to
-      // position this mesh.
-      positionHelper.updateWorldMatrix(true, false);
-      mesh.applyMatrix4(positionHelper.matrixWorld);
-
-      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
-    });
-  });
-}
-
-

2Dドットの描画コードからほとんどの部分が単純明快なものになっています。

-

1つのボックスを作り、ボックスの中心が正のZから離れるように調整します。これをしないと中心からのスケールになってしまうため、原点から離れるようになってほしいからです。

-
-
-
-
default
-
-
-
-
adjusted
-
-
- -

シーングラフで説明したように、多くの THREE.Object3D オブジェクトでボックスを親にして解決できますが、シーングラフにノードを追加すればするほど遅くなってしまいます。

-

また lonHelperlatHelperpositionHelperという小さなノード階層を設定します。 -これらのオブジェクトを使用し、ボックスを配置する球体の周りの位置を計算します。

-
-
-
- -

上記のgreen barlonHelperlongHelper を表し、赤道上の経度方向に回転させるために使用します。 -blue barlatHelper を表し、赤道上や赤道下の緯度を回転させるために使用します。 -red spherepositionHelper が提供するオフセットを表します。

-

地球儀上の位置の計算を手動で行う事もできますが、ほとんどの計算をライブラリ自体に任せてしまうので処理をする必要がありません。

-

各データポイントに対して MeshBasicMaterialMesh を作成し、positionHelper のワールド座標を求めてそれを新しい Mesh に適用します。 -最後にメッシュを新しい位置で拡大縮小します。

-

上記のように新しいボックスを作成する度に latHelperlonHelperpositionHelper を作成できましたが、それではさらに時間がかかります。

-

これから作るボックスは360 × 145まであります。最大で52000個のボックスです。 -いくつかのデータポイントが "NO_DATA "とマークされているので、実際に作成するボックスの数は約19000個です。 -1ボックスごとに3つのヘルパーオブジェクトを追加した場合、シーングラフのノードが80000個近くになり、THREE.jsはその位置を計算しなければなりません。 -ヘルパーの1つのセットを使用して単にメッシュを使用する事で約60000回の操作を削減できます。

-

また lonFudgelatFudge には注意点があります。lonFudge はπ/2で1/4回転です。 -これは理にかなっています。テクスチャやテクスチャ座標が地球の周りの異なるオフセットから始まる事を意味しているだけです。 -一方、latFudgeについてはなぜπ * -0.135にする必要があるのか私にはわかりません。

-

最後に行う必要があるのはローダーの呼出です。

-
loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
-  .then(parseData)
--  .then(drawData)
-+  .then(addBoxes)
-+  .then(render);
-

データ読込と解析が終わったら、少なくとも一度は要求されたレンダリングをする必要があります。

-

- -

-

上記のサンプルをドラッグして回転させようとすると遅い事に気づくでしょう。

-

devtoolsを開いてFPS meterをオンにする事でFPSを確認できます。

-
- -

私のマシンでは20fps以下のFPSが表示されています。

-
- -

FPSの遅延はあまり好ましくなく、多くの人々がさらにFPSが遅くなるマシンを持っているのではないでしょうか。最適化を検討した方がいいですね。

-

このFPS遅延の問題解決では、全てのボックスを1つのジオメトリに統合する事ができます。 -現在は19000個前後のボックスを描画してます。1つのジオメトリに統合する事で18999個の操作を削除する事ができます。

-

以下はボックスを1つのジオメトリに統合する新しいコードです。

-
function addBoxes(file) {
-  const {min, max, data} = file;
-  const range = max - min;
-
--  // make one box geometry
--  const boxWidth = 1;
--  const boxHeight = 1;
--  const boxDepth = 1;
--  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
--  // make it so it scales away from the positive Z axis
--  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));
-
-  // these helpers will make it easy to position the boxes
-  // We can rotate the lon helper on its Y axis to the longitude
-  const lonHelper = new THREE.Object3D();
-  scene.add(lonHelper);
-  // We rotate the latHelper on its X axis to the latitude
-  const latHelper = new THREE.Object3D();
-  lonHelper.add(latHelper);
-  // The position helper moves the object to the edge of the sphere
-  const positionHelper = new THREE.Object3D();
-  positionHelper.position.z = 1;
-  latHelper.add(positionHelper);
-+  // Used to move the center of the box so it scales from the position Z axis
-+  const originHelper = new THREE.Object3D();
-+  originHelper.position.z = 0.5;
-+  positionHelper.add(originHelper);
-
-  const lonFudge = Math.PI * .5;
-  const latFudge = Math.PI * -0.135;
-+  const geometries = [];
-  data.forEach((row, latNdx) => {
-    row.forEach((value, lonNdx) => {
-      if (value === undefined) {
-        return;
-      }
-      const amount = (value - min) / range;
-
--      const material = new THREE.MeshBasicMaterial();
--      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
--      const saturation = 1;
--      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
--      material.color.setHSL(hue, saturation, lightness);
--      const mesh = new THREE.Mesh(geometry, material);
--      scene.add(mesh);
-
-+      const boxWidth = 1;
-+      const boxHeight = 1;
-+      const boxDepth = 1;
-+      const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-
-      // adjust the helpers to point to the latitude and longitude
-      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
-      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
-
--      // use the world matrix of the position helper to
--      // position this mesh.
--      positionHelper.updateWorldMatrix(true, false);
--      mesh.applyMatrix4(positionHelper.matrixWorld);
--
--      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
-
-+      // use the world matrix of the origin helper to
-+      // position this geometry
-+      positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
-+      originHelper.updateWorldMatrix(true, false);
-+      geometry.applyMatrix4(originHelper.matrixWorld);
-+
-+      geometries.push(geometry);
-    });
-  });
-
-+  const mergedGeometry = BufferGeometryUtils.mergeGeometries(
-+      geometries, false);
-+  const material = new THREE.MeshBasicMaterial({color:'red'});
-+  const mesh = new THREE.Mesh(mergedGeometry, material);
-+  scene.add(mesh);
-
-}
-
-

上記ではボックスジオメトリの中心点を変更していたコードを削除し、代わりに originHelper を追加しています。 -前は同じジオメトリを19000回も使っていました。 -今回は1つ1つのジオメトリを新しく作成し、各ボックスジオメトリの頂点を移動するために applyMatrix を使用するので、2回ではなく1回にした方が良いかもしれません。

-

最後に全てのジオメトリの配列を BufferGeometryUtils.mergeGeometries に渡します。 -また BufferGeometryUtils も含める必要があります。

-
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
-
-

少なくとも私のマシンでは毎秒60フレームになりました。

-

- -

-

これで上手くいったのですが、以前はそれぞれのボックスに異なる色がありましたが、1つのメッシュなので1つのマテリアルとなり1つの色だけになります。 -これは頂点カラーを使い修正できます。

-

頂点カラーは頂点ごとに色を追加します。各ボックスの各頂点の全ての色を特定の色に設定する事で、全てのボックスが異なる色を持つようになります。

-
+const color = new THREE.Color();
-
-const lonFudge = Math.PI * .5;
-const latFudge = Math.PI * -0.135;
-const geometries = [];
-data.forEach((row, latNdx) => {
-  row.forEach((value, lonNdx) => {
-    if (value === undefined) {
-      return;
-    }
-    const amount = (value - min) / range;
-
-    const boxWidth = 1;
-    const boxHeight = 1;
-    const boxDepth = 1;
-    const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-
-    // adjust the helpers to point to the latitude and longitude
-    lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
-    latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
-
-    // use the world matrix of the origin helper to
-    // position this geometry
-    positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
-    originHelper.updateWorldMatrix(true, false);
-    geometry.applyMatrix4(originHelper.matrixWorld);
-
-+    // compute a color
-+    const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
-+    const saturation = 1;
-+    const lightness = THREE.MathUtils.lerp(0.4, 1.0, amount);
-+    color.setHSL(hue, saturation, lightness);
-+    // get the colors as an array of values from 0 to 255
-+    const rgb = color.toArray().map(v => v * 255);
-+
-+    // make an array to store colors for each vertex
-+    const numVerts = geometry.getAttribute('position').count;
-+    const itemSize = 3;  // r, g, b
-+    const colors = new Uint8Array(itemSize * numVerts);
-+
-+    // copy the color into the colors array for each vertex
-+    colors.forEach((v, ndx) => {
-+      colors[ndx] = rgb[ndx % 3];
-+    });
-+
-+    const normalized = true;
-+    const colorAttrib = new THREE.BufferAttribute(colors, itemSize, normalized);
-+    geometry.setAttribute('color', colorAttrib);
-
-    geometries.push(geometry);
-  });
-});
-
-

上記のコードではジオメトリから position を取得して必要な頂点の数を調べています。 -次に色を入れるための変数を Uint8Array で作成します。その後に geometry.setAttribute を呼び出して属性として追加します。

-

最後に頂点カラーを扱うようにthree.jsで指定する必要があります。

-
const mergedGeometry = BufferGeometryUtils.mergeGeometries(
-    geometries, false);
--const material = new THREE.MeshBasicMaterial({color:'red'});
-+const material = new THREE.MeshBasicMaterial({
-+  vertexColors: true,
-+});
-const mesh = new THREE.Mesh(mergedGeometry, material);
-scene.add(mesh);
-
-

これで色を取り戻す事ができました。

-

- -

-

ジオメトリのマージは一般的な最適化手法です。 -例えば100本の木を1つのジオメトリに統合したり、個々の岩の山を1つの岩のジオメトリに統合したり、個々の杭から1つの柵メッシュに統合したりする事ができます。 -別の例としてマインクラフトではそれぞれのキューブを個別に描画するのではなく、マージされたキューブのグループを作成したり描画されない面は選択的に削除したりします。

-

全てを1つのメッシュにする問題点としては、以前は分離されていた部分を移動する事が容易ではなくなった事です。 -ユースケースに応じて創造的なソリューションがあります。 -1つは別の記事で紹介します。

-

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/picking.html b/manual/ja/picking.html deleted file mode 100644 index e329a1a038262b..00000000000000 --- a/manual/ja/picking.html +++ /dev/null @@ -1,430 +0,0 @@ - - - でピッキング - - - - - - - - - - - - - -
-
-

でピッキング

-
-
-
-

ピッキングとはユーザーがどのオブジェクトをクリックしたか、またはタッチしたかを把握するプロセスの事です。 -ピッキングする方法のそれぞれにトレードオフがありますが、実装の仕方はたくさんあります。最も一般的な2つの方法を見てみましょう。

-

おそらく、最も一般的なピッキングはレイキャスティングでマウスからシーンの錐台を通して光線をキャストし、その光線が交差するオブジェクトを計算する方法です。 -概念的には非常にシンプルです。

-

まずはマウスの位置を決めます。 -それをカメラの投影と向きをワールド座標に変換します。 -カメラの錐台の近くの面から遠くの面までの光線を計算します。 -そして、シーン内の全ての三角形オブジェクトで光線と交差するかチェックします。 -もしシーンに1000個のオブジェクトがあり、各オブジェクトに1000個の三角形がある場合、100万個の三角形をチェックする必要があります。

-

光線がオブジェクトのバウンディングスフィアやバウンディングボックス、つまりオブジェクト全体を含む球やボックスと交差するかを最初にチェックする事は最適化になります。 -もし光線がオブジェクトと交差しなければ、そのオブジェクトの三角形をチェックする必要はありません。

-

Three.jsにはこれを行う RayCaster クラスを提供しています。

-

100個のオブジェクトがあるシーンを作ってピッキングしてみましょう。 -レスポンシブデザインの記事のコード例から始めてみます。

-

いくつかの変更点

-

カメラを別のオブジェクトの親にして、そのオブジェクトを回転させるとカメラが自撮り棒のようにシーンの周りを動き回るようになります。

-
*const fov = 60;
-const aspect = 2;  // the canvas default
-const near = 0.1;
-*const far = 200;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-*camera.position.z = 30;
-
-const scene = new THREE.Scene();
-+scene.background = new THREE.Color('white');
-
-+// put the camera on a pole (parent it to an object)
-+// so we can spin the pole to move the camera around the scene
-+const cameraPole = new THREE.Object3D();
-+scene.add(cameraPole);
-+cameraPole.add(camera);
-
-

そして、render 関数でcameraPoleを回転させます。

-
cameraPole.rotation.y = time * .1;
-
-

カメラにライトを追加し、ライトが動くようにしましょう。

-
-scene.add(light);
-+camera.add(light);
-
-

ランダムな色、位置、向き、スケールの100個のキューブを生成してみましょう。

-
const boxWidth = 1;
-const boxHeight = 1;
-const boxDepth = 1;
-const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-
-function rand(min, max) {
-  if (max === undefined) {
-    max = min;
-    min = 0;
-  }
-  return min + (max - min) * Math.random();
-}
-
-function randomColor() {
-  return `hsl(${rand(360) | 0}, ${rand(50, 100) | 0}%, 50%)`;
-}
-
-const numObjects = 100;
-for (let i = 0; i < numObjects; ++i) {
-  const material = new THREE.MeshPhongMaterial({
-    color: randomColor(),
-  });
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  cube.position.set(rand(-20, 20), rand(-20, 20), rand(-20, 20));
-  cube.rotation.set(rand(Math.PI), rand(Math.PI), 0);
-  cube.scale.set(rand(3, 6), rand(3, 6), rand(3, 6));
-}
-
-

最後にピックします。

-

ピッキングを管理する簡単なクラスを作ってみましょう。

-
class PickHelper {
-  constructor() {
-    this.raycaster = new THREE.Raycaster();
-    this.pickedObject = null;
-    this.pickedObjectSavedColor = 0;
-  }
-  pick(normalizedPosition, scene, camera, time) {
-    // restore the color if there is a picked object
-    if (this.pickedObject) {
-      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
-      this.pickedObject = undefined;
-    }
-
-    // cast a ray through the frustum
-    this.raycaster.setFromCamera(normalizedPosition, camera);
-    // get the list of objects the ray intersected
-    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
-    if (intersectedObjects.length) {
-      // pick the first object. It's the closest one
-      this.pickedObject = intersectedObjects[0].object;
-      // save its color
-      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
-      // set its emissive color to flashing red/yellow
-      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
-    }
-  }
-}
-
-

RayCaster を作成し、pick 関数を呼び出してシーンに光線をキャストできます。 -光線が何かに当たった場合、最初に当たったオブジェクトの色を変更します。

-

もちろん、マウスを down した時だけこの関数を呼べますが、今回は全てのフレームでマウスの下に何かあるかピックします。 -そのためにはまずマウスがどこにあるかを追跡する必要があります。

-
const pickPosition = {x: 0, y: 0};
-clearPickPosition();
-
-...
-
-function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function setPickPosition(event) {
-  const pos = getCanvasRelativePosition(event);
-  pickPosition.x = (pos.x / canvas.width ) *  2 - 1;
-  pickPosition.y = (pos.y / canvas.height) * -2 + 1;  // note we flip Y
-}
-
-function clearPickPosition() {
-  // unlike the mouse which always has a position
-  // if the user stops touching the screen we want
-  // to stop picking. For now we just pick a value
-  // unlikely to pick something
-  pickPosition.x = -100000;
-  pickPosition.y = -100000;
-}
-
-window.addEventListener('mousemove', setPickPosition);
-window.addEventListener('mouseout', clearPickPosition);
-window.addEventListener('mouseleave', clearPickPosition);
-
-

正規化されたマウスの位置を記録している事に注意して下さい。 -キャンバスの大きさに関わらず、leftの-1からrightの+1までの値が必要です。 -同様にbottomが-1からtopが+1になるような値が必要です。

-

これでモバイル端末もサポートできます。

-
window.addEventListener('touchstart', (event) => {
-  // prevent the window from scrolling
-  event.preventDefault();
-  setPickPosition(event.touches[0]);
-}, {passive: false});
-
-window.addEventListener('touchmove', (event) => {
-  setPickPosition(event.touches[0]);
-});
-
-window.addEventListener('touchend', clearPickPosition);
-
-

最後に render 関数で PickHelperpick 関数を呼び出します。

-
+const pickHelper = new PickHelper();
-
-function render(time) {
-  time *= 0.001;  // convert to seconds;
-
-  ...
-
-+  pickHelper.pick(pickPosition, scene, camera, time);
-
-  renderer.render(scene, camera);
-
-  ...
-
-

その結果がこれです。

-

- -

-

望んだ動作でおそらく多くのユースケースに対応できますが、いくつか問題点があります。

-
    -
  1. CPUベース

    -

    JavaScriptは各オブジェクトを通過し、光線がそのオブジェクトのバウンディングボックス、またはバウンディングスフィアと交差するかをチェックしています。 -そのオブジェクト内の各三角形を通過し、光線が三角形と交差するかチェックしなければなりません。

    -

    これの良い点はJavaScriptが簡単に光線が三角形と交差する場所を正確に計算し、その結果が分かります。 -例えば交差する点があった場所に目印をつけたい場合などです。

    -

    悪い点はCPUの負担が大きい所ですね。 -三角形がたくさんあるオブジェクトを持っている場合は遅いかもしれません。

    -
  2. -
  3. 変なシェーダーやズレを処理しません

    -

    ジオメトリを変形またはモーフィングするシェーダーがある場合、JavaScriptはその変形を認識できなく、間違った答えを返してしまいます。 -例えばAFAIKのように、メソッドはスキニングされたオブジェクトでは使えません。

    -
  4. -
  5. 透明な穴には対応しません

    -
  6. -
-

例として、このテクスチャをキューブに適用してみましょう。

-
- -

以下のように変更します。

-
+const loader = new THREE.TextureLoader();
-+const texture = loader.load('resources/images/frame.png');
-
-const numObjects = 100;
-for (let i = 0; i < numObjects; ++i) {
-  const material = new THREE.MeshPhongMaterial({
-    color: randomColor(),
-    +map: texture,
-    +transparent: true,
-    +side: THREE.DoubleSide,
-    +alphaTest: 0.1,
-  });
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  ...
-
-

以下の例で動作確認をすると、すぐにこの問題が分かると思います。

-

- -

-

ボックスの穴の部分でピックしてみて下さい。

-
- -

この原因は、JavaScriptがテクスチャやマテリアルを調べて、オブジェクトの一部が本当に透明か判断できないからです。

-

これらの問題を全て解決するのが、GPUベースのピッキングです。 -概念的には簡単ですが、残念ながら上記のレイキャスティングよりも使用方法が複雑になっています。

-

GPUピッキングを行うには、各オブジェクトをオフスクリーンでユニークな色でレンダリングします。 -次にマウスの位置に対応するピクセルの色を調べます。 -色でどのオブジェクトが選ばれたか分かります。

-

これで上記の2と3の問題を解決できます。問題1は速度に依存しています。 -全てのオブジェクトは2回描画されなければなりません。 -1回は見るための描画、もう1回はピッキングの描画です。 -洗練された解決策は、その両方を同時に行う事ですが今回はそれを試しません。

-

1つのやり方としては、1つのピクセルしか読まないのでそのピクセルだけ描画されるようにカメラを設定します。 -これを行うには PerspectiveCamera.setViewOffset を使用します。 -これでカメラを計算できます。 -これで時間を節約できるはずです。

-

現時点ではこのピッキングを行うには、2つのシーンを作成する必要があります。 -1つは通常のメッシュで埋めます。 -もう1つはピッキングマテリアルを使用したメッシュで埋めます。

-

そこでまず2つ目のシーンを作り黒でクリアします。

-
const scene = new THREE.Scene();
-scene.background = new THREE.Color('white');
-const pickingScene = new THREE.Scene();
-pickingScene.background = new THREE.Color(0);
-
-

次にメインシーンに配置する各キューブでオリジナルのキューブと同じ位置に対応する"ピッキングキューブ"を作成し、pickingScene に配置します。 -オブジェクトのidを色として描画するようにマテリアルを設定します。 -また、idとオブジェクトのマップがあるので、後でidを調べた時に対応するオブジェクトにマップを戻す事ができます。

-
const idToObject = {};
-+const numObjects = 100;
-for (let i = 0; i < numObjects; ++i) {
-+  const id = i + 1;
-  const material = new THREE.MeshPhongMaterial({
-    color: randomColor(),
-    map: texture,
-    transparent: true,
-    side: THREE.DoubleSide,
-    alphaTest: 0.1,
-  });
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-+  idToObject[id] = cube;
-
-  cube.position.set(rand(-20, 20), rand(-20, 20), rand(-20, 20));
-  cube.rotation.set(rand(Math.PI), rand(Math.PI), 0);
-  cube.scale.set(rand(3, 6), rand(3, 6), rand(3, 6));
-
-+  const pickingMaterial = new THREE.MeshPhongMaterial({
-+    emissive: new THREE.Color().setHex(id, THREE.NoColorSpace),
-+    color: new THREE.Color(0, 0, 0),
-+    specular: new THREE.Color(0, 0, 0),
-+    map: texture,
-+    transparent: true,
-+    side: THREE.DoubleSide,
-+    alphaTest: 0.5,
-+    blending: THREE.NoBlending,
-+  });
-+  const pickingCube = new THREE.Mesh(geometry, pickingMaterial);
-+  pickingScene.add(pickingCube);
-+  pickingCube.position.copy(cube.position);
-+  pickingCube.rotation.copy(cube.rotation);
-+  pickingCube.scale.copy(cube.scale);
-}
-
-

ここでは MeshPhongMaterial を悪用している事に注意して下さい。 -emissive をidにし、colorspecular を0に設定しテクスチャのアルファ値が alphaTest よりも大きい場合にのみ、idをレンダリングできます。 -また、blendingNoBlending に設定し、idにアルファが乗算しないようにする必要があります。

-

MeshPhongMaterialを悪用する事は、最良の解決策でない事に注意して下さい。 -さらに最適化された解決策は、テクスチャのアルファ値が alphaTest よりも大きい場合にidを書き込むカスタムシェーダーを作成する事です。

-

レイキャスティングではなくピクセルからピックしているので、ピック位置を設定するコードを変更してピクセルだけを使用できます。

-
function setPickPosition(event) {
-  const pos = getCanvasRelativePosition(event);
--  pickPosition.x = (pos.x / canvas.clientWidth ) *  2 - 1;
--  pickPosition.y = (pos.y / canvas.clientHeight) * -2 + 1;  // note we flip Y
-+  pickPosition.x = pos.x;
-+  pickPosition.y = pos.y;
-}
-
-

PickHelperGPUPickHelper に変更してみましょう。 -レンダーターゲットの記事で説明したように WebGLRenderTarget を使います。 -ここでのレンダーターゲットは1 x 1の1ピクセルのサイズしかありません。

-
-class PickHelper {
-+class GPUPickHelper {
-  constructor() {
--    this.raycaster = new THREE.Raycaster();
-+    // create a 1x1 pixel render target
-+    this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
-+    this.pixelBuffer = new Uint8Array(4);
-    this.pickedObject = null;
-    this.pickedObjectSavedColor = 0;
-  }
-  pick(cssPosition, scene, camera, time) {
-+    const {pickingTexture, pixelBuffer} = this;
-
-    // restore the color if there is a picked object
-    if (this.pickedObject) {
-      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
-      this.pickedObject = undefined;
-    }
-
-+    // set the view offset to represent just a single pixel under the mouse
-+    const pixelRatio = renderer.getPixelRatio();
-+    camera.setViewOffset(
-+        renderer.getContext().drawingBufferWidth,   // full width
-+        renderer.getContext().drawingBufferHeight,  // full top
-+        cssPosition.x * pixelRatio | 0,             // rect x
-+        cssPosition.y * pixelRatio | 0,             // rect y
-+        1,                                          // rect width
-+        1,                                          // rect height
-+    );
-+    // render the scene
-+    renderer.setRenderTarget(pickingTexture)
-+    renderer.render(scene, camera);
-+    renderer.setRenderTarget(null);
-+
-+    // clear the view offset so rendering returns to normal
-+    camera.clearViewOffset();
-+    //read the pixel
-+    renderer.readRenderTargetPixels(
-+        pickingTexture,
-+        0,   // x
-+        0,   // y
-+        1,   // width
-+        1,   // height
-+        pixelBuffer);
-+
-+    const id =
-+        (pixelBuffer[0] << 16) |
-+        (pixelBuffer[1] <<  8) |
-+        (pixelBuffer[2]      );
-
--    // cast a ray through the frustum
--    this.raycaster.setFromCamera(normalizedPosition, camera);
--    // get the list of objects the ray intersected
--    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
--    if (intersectedObjects.length) {
--      // pick the first object. It's the closest one
--      this.pickedObject = intersectedObjects[0].object;
-
-+    const intersectedObject = idToObject[id];
-+    if (intersectedObject) {
-+      // pick the first object. It's the closest one
-+      this.pickedObject = intersectedObject;
-      // save its color
-      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
-      // set its emissive color to flashing red/yellow
-      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
-    }
-  }
-}
-
-

それならば、それを使えばいいだけです。

-
-const pickHelper = new PickHelper();
-+const pickHelper = new GPUPickHelper();
-
-

これを指定し scene の代わりに pickScene を渡します。

-
-  pickHelper.pick(pickPosition, scene, camera, time);
-+  pickHelper.pick(pickPosition, pickScene, camera, time);
-
-

そして、透明な部分を選択するようにする必要があります。

-

- -

-

ピッキングを実装する方法のアイデアをいくつか得られたと思います。 -今後の記事ではマウスを使ってオブジェクトを操作する方法を取り上げるかもしれません。

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/post-processing.html b/manual/ja/post-processing.html deleted file mode 100644 index 5c236810a3c649..00000000000000 --- a/manual/ja/post-processing.html +++ /dev/null @@ -1,254 +0,0 @@ - - - のポストプロセス - - - - - - - - - - - - - -
-
-

のポストプロセス

-
-
-
-

ポストプロセスとは、一般的には2D画像に何らかのエフェクトやフィルターを適用する事です。 -Three.jsの場合、たくさんのメッシュが入ったシーンがあり、そのシーンを2D画像にレンダリングします。 -通常はその2D画像はキャンバスに直接レンダリングしブラウザに表示されますが、 -代わりにレンダーターゲットにレンダリングし、キャンバス描画前にポストプロセスエフェクトを適用できます。 -メインシーンのレンダリング後に行われるため、ポストプロセスと呼ばれています。

-

ポストプロセスの例としては、InstagramやPhotoshopのフィルターなどがあります。

-

Three.jsには、ポストプロセスのパイプラインを設定するサンプルクラスがいくつかあります。 -今回は最初に EffectComposer を作成し、複数の Pass オブジェクトを追加します。 -次に EffectComposer.render を呼び出し、シーンを レンダーターゲットにレンダリングしてそれぞれの Pass を適用します。

-

それぞれの Pass には、ビネットの追加、ブラーやブルームの適用、フィルムグレインの適用、色相、彩度、コントラストの調整などのポストプロセスを適用できます。 -最後のレンダリングでポストプロセス結果をキャンバスにレンダリングします。

-

EffectComposer 関数がどのようなものか理解するのは少し重要です。 -ここでは2つのレンダーターゲットを作成します。 -これをrtArtBと呼ぶ事にしましょう。

-

次に EffectComposer.addPass を呼び出し、それぞれのPassに適用したい順番で追加します。 -Passは次の図のように適用されます。

-
- -

RenderPassに渡されたシーンは、まずrtAにレンダリングされrtAは次のPassに渡されます。 -このPassはrtAを入力として使用し、rtBに結果を書き込みます。 -その後にrtBは次のPassに渡され、rtBを入力として使用しrtAに書き戻します。 -これは全てのPassを通ります。

-

それぞれの Pass には4つの基本的なオプションがあります。

-

enabled

-

このPassを使用するかどうか

-

needsSwap

-

このPass終了後に rtArtB を入れ替えるかどうか

-

clear

-

このPassをレンダリングする前にクリアするかどうか

-

renderToScreen

-

現在の出力先のレンダーターゲットではなく、キャンバスにレンダリングするかどうか。 -通常は EffectComposer に追加する最後のPassでtrueに設定する必要があります。

-

基本的な例をまとめてみましょう。 -まずはレスポンシブデザインの記事から例を挙げてみます。

-

そのためにまず EffectComposer を作成します。

-
const composer = new EffectComposer(renderer);
-
-

次に最初のPassとして RenderPass を追加し、最初のレンダーターゲットにカメラを使ってシーンをレンダリングします。

-
composer.addPass(new RenderPass(scene, camera));
-
-

次に BloomPass を追加します。 -BloomPass は一般的には入力を小さなレンダーターゲットにレンダリングし、結果にブラーをかけます。 -そして、元の入力の上にブラーされた結果を追加します。 -これでシーンに ブルーム をかけます。

-
const bloomPass = new BloomPass(
-    1,    // strength
-    25,   // kernel size
-    4,    // sigma ?
-    256,  // blur render target resolution
-);
-composer.addPass(bloomPass);
-
-

最終的には、元の入力の上にノイズとスキャンラインを描画する FilmPass ができました。

-
const filmPass = new FilmPass(
-    0.5,   // intensity
-    false,  // grayscale
-);
-composer.addPass(filmPass);
-
-

これらのクラスを使用するには、以下をインポートする必要があります。

-
import {EffectComposer} from 'three/addons/postprocessing/EffectComposer.js';
-import {RenderPass} from 'three/addons/postprocessing/RenderPass.js';
-import {BloomPass} from 'three/addons/postprocessing/BloomPass.js';
-import {FilmPass} from 'three/addons/postprocessing/FilmPass.js';
-import {OutputPass} from 'three/addons/postprocessing/OutputPass.js';
-
-

ほとんどのポストプロセスには EffectComposer.jsRenderPass.js が必須です。

-

最後に WebGLRenderer.render の代わりに EffectComposer.render を使用し、EffectComposer にキャンバスのサイズを合わせます。

-
-function render(now) {
--  time *= 0.001;
-+let then = 0;
-+function render(now) {
-+  now *= 0.001;  // convert to seconds
-+  const deltaTime = now - then;
-+  then = now;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-+    composer.setSize(canvas.width, canvas.height);
-  }
-
-  cubes.forEach((cube, ndx) => {
-    const speed = 1 + ndx * .1;
--    const rot = time * speed;
-+    const rot = now * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
--  renderer.render(scene, camera);
-+  composer.render(deltaTime);
-
-  requestAnimationFrame(render);
-}
-
-

EffectComposer.renderdeltaTime で最後のフレームのレンダリング後からの時間を秒単位で受け取ります。 -deltaTimeをアニメーションしてる様々なエフェクトに渡します。 -今回は FilmPass がアニメーションしています。

-

- -

-

実行時にエフェクトパラメーターを変更するには、uniformの値を設定する必要があります。 -パラメータを調整するためのGUIを追加してみましょう。 -どの値を調整できるか把握するには、以下のコードを調べる必要があります。

-

BloomPass.jsの中でこの行を見つけました。

-
this.copyUniforms[ "opacity" ].value = strength;
-
-

strengthを設定できます。

-
bloomPass.copyUniforms.opacity.value = someValue;
-
-

同様にFilmPass.jsでこの行を見つけました。

-
this.uniforms.intensity.value = intensity;
-this.uniforms.grayscale.value = grayscale;
-
-

これでどのように設定するか、かなり明確になりました。

-

これらの値を設定する簡単なGUIを作ってみましょう。

-
import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
-
-

そして

-
const gui = new GUI();
-{
-  const folder = gui.addFolder('BloomPass');
-  folder.add(bloomPass.copyUniforms.opacity, 'value', 0, 2).name('strength');
-  folder.open();
-}
-{
-  const folder = gui.addFolder('FilmPass');
-  folder.add(filmPass.uniforms.grayscale, 'value').name('grayscale');
-  folder.add(filmPass.uniforms.intensity, 'value', 0, 1).name('intensity');
-  folder.open();
-}
-
-

これで設定を調整できるようになりました。

-

- -

-

これはあなた自身のエフェクトを作る小さな1歩です。

-

ポストプロセスエフェクトではシェーダーを使用します。 -シェーダーはGLSL (Graphics Library Shading Language)と呼ばれる言語で書かれています。 -この記事では、GLSL言語全体を解説するのはあまりにも大きなトピックです。 -この記事このシェーダーの本を参考にしてみて下さい。

-

サンプルがあると便利だと思うので、簡単なGLSLのポストプロセスのシェーダーを作ってみましょう。 -画像に色を乗算したものを作ります。

-

Three.jsではポストプロセス用に ShaderPass という便利なヘルパーを提供しています。 -頂点シェーダー、フラグメントシェーダー、デフォルト入力を定義した情報を持つオブジェクトを取得します。 -前のPassの結果を得るためにどのテクスチャから読み込むか、EffectComposer のどこにレンダリングするかを設定します。

-

前のPassの結果に色を乗算するシンプルなポストプロセスシェーダーです。

-
const colorShader = {
-  uniforms: {
-    tDiffuse: { value: null },
-    color:    { value: new THREE.Color(0x88CCFF) },
-  },
-  vertexShader: `
-    varying vec2 vUv;
-    void main() {
-      vUv = uv;
-      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1);
-    }
-  `,
-  fragmentShader: `
-    varying vec2 vUv;
-    uniform sampler2D tDiffuse;
-    uniform vec3 color;
-    void main() {
-      vec4 previousPassColor = texture2D(tDiffuse, vUv);
-      gl_FragColor = vec4(
-          previousPassColor.rgb * color,
-          previousPassColor.a);
-    }
-  `,
-};
-
-

上記の tDiffuseShaderPass が前のPassの結果テクスチャを渡す名前です。 -color を Three.jsの Color として宣言します。

-

次に頂点シェーダーが必要です。 -ポストプロセスでは上記コードの頂点シェーダーは標準的なものであり、ほとんど変更する必要はありません。 -あまり詳しく説明しませんが(上記のリンク先の記事を参照してください)、 -変数 uv, projectionMatrix, modelViewMatrix, position は全てThree.jsによって魔法のように追加されています。

-

最後にフラグメントシェーダーを作成します。この中で前のPassのピクセルカラーを次の行で取得します。

-
vec4 previousPassColor = texture2D(tDiffuse, vUv);
-
-

これに色を掛けて gl_FragColor を設定します。

-
gl_FragColor = vec4(
-    previousPassColor.rgb * color,
-    previousPassColor.a);
-
-

3つ色の設定用に簡単なGUIを追加します。

-
const gui = new GUI();
-gui.add(colorPass.uniforms.color.value, 'r', 0, 4).name('red');
-gui.add(colorPass.uniforms.color.value, 'g', 0, 4).name('green');
-gui.add(colorPass.uniforms.color.value, 'b', 0, 4).name('blue');
-
-

色で乗算するシンプルなポストプロセスエフェクトができました。

-

- -

-

GLSLやカスタムシェーダーの詳細は、ネット上にたくさんの記事があります。 -WebGL自体がどのように動作するかを知りたいならば、これらの記事をチェックしてみて下さい。 -もう1つの素晴らしいリソースは、THREE.jsレポートの既存ポストプロセスシェーダーを読み解く事です。 -複雑なものもいくつかありますが、小さいものから始めるとどのように動作するかのアイデアを得る事ができます。

-

残念ながらThree.jsレポートにあるほとんどのポストプロセスエフェクトは文書化されていないので、使用するにはこの例か -エフェクト自体のコードを読んで下さい。 -これらのシンプルな例とレンダーターゲットの記事がポストプロセスを始めるのに十分な知識を提供してくれると思います。

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/prerequisites.html b/manual/ja/prerequisites.html deleted file mode 100644 index dda13665f6bca6..00000000000000 --- a/manual/ja/prerequisites.html +++ /dev/null @@ -1,267 +0,0 @@ - - - の前提条件 - - - - - - - - - - - - - -
-
-

の前提条件

-
-
-
-

これらの記事は、three.jsの使用方法を学習するためものです。 -JavaScriptのプログラミング方法を把握してる事を前提とします。 -DOMとは何か、HTMLの記述方法、JavaScriptでDOMの作成方法を把握してる事を前提とします。 -es6 modules のimportや <script type="module"> タグを把握してる事を前提とします。 -CSSや CSSセレクター が何か把握してる事を前提とします。 -ES5、ES6、およびES7を把握してる事を前提とします。 -イベントとコールバックでJavaScripが実行されるのを把握してる事を前提とします。 -クロージャとは何かを知っている事を前提とします。

-

また、以下に簡単な復習と注意事項があります。

-

es6モジュール

-

es6モジュールはスクリプトの中で import キーワード、または <script type="module"> タグを使用してインラインでロードできます。 -以下に両方の使用​​例があります。

-
<script type="module">
-import * as THREE from 'three';
-
-...
-
-</script>
-
-

パスは絶対パス、または相対パスでなければなりません。相対パスは常に ./ または ../ で始まり <img><a> などの他のタグやcss参照とは異なります。

-

詳細はこの記事の最後に記載しています。

-

document.querySelectordocument.querySelectorAll

-

document.querySelector を使用し、CSSセレクターに一致する最初の要素を選択できます。 -document.querySelectorAll は、CSSセレクターに一致するすべての要素を返します。

-

onbody は必要ありません

-

20年前の古いWebページでは、以下のようなHTMLが多く使われてました。

-
<body onload="somefunction()">
-

このスタイルは非推奨です。スクリプトをページの下部に配置します。

-
<html>
-  <head>
-    ...
-  </head>
-  <body>
-     ...
-  </body>
-  <script>
-    // inline javascript
-  </script>
-</html>
-
-

または、deferプロパティを使用します

-

クロージャーの仕組みを知る

-
function a(v) {
-  const foo = v;
-  return function() {
-     return foo;
-  };
-}
-
-const f = a(123);
-const g = a(456);
-console.log(f());  // prints 123
-console.log(g());  // prints 456
-
-

上記のコードでは、新しい関数が作成される度に関数 a を呼び出します。 -その関数は変数 foo閉じ込めます 。 -ここに詳細な情報 があります。

-

this がどのように機能するか知る

-

this は魔法ではありません。 -引数が関数に渡されるのと同じように、関数に自動的に渡される実質的な変数です。 -簡単に説明すると、次のような関数を直接呼び出す場合です。

-
somefunction(a, b, c);
-

thisnull(strictモードまたはモジュールの場合)になりますが、ドット演算子を使用して関数を呼び出す場合と同様です。

-
someobject.somefunction(a, b, c);
-

thissomeobject がセットされます。

-

この部分はみなさんが混乱するコールバックです。

-
 const callback = someobject.somefunction;
- loader.load(callback);
-

これは経験の浅い人が期待するように動作しません。 -なぜなら loader.load がコールバックを呼び出す時、. 演算子で this を呼び出していないため、デフォルトではnullになります(ローダーが明示的に何かを設定しない限り) -コールバックが発生した時に thissomeobject したい場合は、関数をバインドする必要があります。

-
 const callback = someobject.somefunction.bind(someobject);
- loader.load(callback);
-

この記事は this を理解するのに役立つかもしれません.

-

ES5/ES6/ES7 stuff

-

var は非推奨です。 constlet を使って下さい

-

var は使用する理由がありません。varを使用するのは悪い習慣と見なされます。ほとんどの場合、変数の値を変えない場合は const を使用します。 -値が変更される場合は let を使用します。これにより大量のバグを回避できます。

-

for of を使用し for in は使用しない

-

for of は新しい書き方で、 for in は古い書き方です。 for in で解決できない問題を for of が解決しています。 -解決した一例として、オブジェクトのすべてのkey/valueのペアを反復処理ができます。

-
for (const [key, value] of Object.entries(someObject)) {
-  console.log(key, value);
-}
-
-

forEachmapfilter は役に立ちます

-

配列の関数である forEach や -map 、 -filter - はモダンなJavaScriptで広く使われています。

-

分割代入を使う

-

const dims = {width: 300, height: 150} のObjectがあるとします。

-

古いコードの場合

-
 const width = dims.width;
- const height = dims.height;
-

新しいコードの場合

-
 const {width, height} = dims;
-

オブジェクト宣言のショートカットを使う

-

古いコードの場合

-
 const width = 300;
- const height = 150;
- const obj = {
-   width: width,
-   height: height,
-   area: function() {
-     return this.width * this.height
-   },
- };
-
-

新しいコードの場合

-
 const width = 300;
- const height = 150;
- const obj = {
-   width,
-   height,
-   area() {
-     return this.width * this.height;
-   },
- };
-
-

スプレット演算子 ... を使う

-

スプレット演算子にはたくさんの使い方があります。例えば

-
 function log(className, ...args) {
-   const elem = document.createElement('div');
-   elem.className = className;
-   elem.textContent = [...args].join(' ');
-   document.body.appendChild(elem);
- }
-
-

もう1つの例

-
const position = [1, 2, 3];
-somemesh.position.set(...position);
-
-

class を使う

-

ES5より以前のオブジェクトのようなクラス構文は、ほとんどのプログラマーにはなじみがありませんでした。 -ES5以降では、C ++やC#、Javaのスタイルに近いclass キーワードを使用 -できるようになりました。

-

gettersとsettersを理解する

-

Getters と -setters は -ほとんどのモダンなプログラミン言語でよく使われます。 -ES5のクラス構文により、ES5以前よりもはるかに簡単に使えます。

-

必要に応じてアロー関数を使います

-

アロー関数はcallbackとPromiseで特に役立ちます。

-
loader.load((texture) => {
-  // use texture
-});
-
-

アロー関数は this をバインドします。

-
const foo = (args) => {/* code */};
-
-

ショートカットで書くなら

-
const foo = (function(args) {/* code */}).bind(this));
-
-

Promiseはasync/awaitと同様です

-

Promisesは非同期な処理を助ます。Async/awaitはpromiseを助けます。

-

ここで扱うには大きな話題になるため、promiseのドキュメントを読んで下さい。 -また、async/awaitもドキュメントを読んで下さい

-

テンプレートリテラルを使用する

-

テンプレートリテラルは、引用符("", '')の代わりにバックティック文字( )を使います。

-
const foo = `this is a template literal`;
-

テンプレートリテラルには基本的に2つの機能があります。1つは複数行にかけます。

-
const foo = `this
-is
-a
-template
-literal`;
-const bar = "this\nis\na\ntemplate\nliteral";
-
-

上記の foobar は同様の意味になります.

-

もう1つは、文字モードの中に ${javascript-expression} のようにJavaScriptのスニペッドを挿入できます。 -これはテンプレートの一部です。例えば

-
const r = 192;
-const g = 255;
-const b = 64;
-const rgbCSSColor = `rgb(${r},${g},${b})`;
-
-

または

-
const color = [192, 255, 64];
-const rgbCSSColor = `rgb(${color.join(',')})`;
-
-

または

-
const aWidth = 10;
-const bWidth = 20;
-someElement.style.width = `${aWidth + bWidth}px`;
-
-

JavaScriptのコーディング規則を学びましょう

-

自由にコードフォーマットする事ができますが、少なくとも1つの規則に注意する必要があります。 -JavaScriptの変数名や関数名、メソッド名はすべてローワーキャメルケースです。 -コンストラクターやクラスの名前はアッパーキャメルケースです。 -このルールに従うなら、他のほとんどのJavaScriptコードと一致します。

-

多くの リンター] やコード内の明らかなエラーをチェックするプログラムは、間違ったケースを使用するとエラーを指摘します。 -上記の規則に従うことで、エラーが間違っている事がわかるからです。

-
const v = new vector(); // clearly an error if all classes start with a capital letter
-const v = Vector();     // clearly an error if all functions start with a lowercase latter.
-
-

Visual Studio Codeの使用を検討する

-

もちろんあなたが望むエディタが良いですが、もし望むエディタがなければ Visual Studio Code を使う事を検討してみて下さい。 -インストールし eslintをセットアップ します。 -セットアップには数分かかる場合がありますが、バグを見つけるのに非常に役に立ちます。

-

いくつかの例

-

no-undef ルール を有効にすると、VSCodeのEsLintで多くの未定義の変数について警告します。

-
- -

上記は doTheThing のスペルを doThing と間違えている事がわかります。 -doThing の下に赤い波線があり、その上をホバリングすると定義されていない事がわかります。 -1つのエラーが回避されました。 -THREE を使用して警告が表示された場合、eslintに THREE が存在する事を伝えるため、JavaScriptファイルの先頭に /* global THREE */ を追加します。

-
- -

上記では、eslintは アッパーキャメルケース がコンストラクターであるというルールを知っているため、 new を使用する必要があります。 -他のエラーをキャッチして避けます。これはnew-cap ルール です。

-

数百のEslintルールをオン・オフにカスタム できます。 -上記の例では var でなく constlet を使用するルールを適用しました。 -コードでは var を使用しましたが、let または const を使用する必要があると警告されました。

-
- -

ここでは let を使用しましたが、値を変更しない事がわかったため、 const を使用することが提案されました。

-
- -

もちろん、 var を使い続けたい場合は、そのルールをオフにすることができます。 -上記で記述したように var よりも constlet を使用することを好みます。 -それらはうまく機能し、バグを防ぎます。

-

ルールをオーバーライドする必要がある場合、1行のコードまたはコードセクションに無効にするコメントを追加できます

-

レガシーブラウザをサポートする必要がある場合は、トランスパイラーを使用して下さい

-

ほとんどのモダンなブラウザは自動更新されるため、これらすべての機能を使用すると便利です。生産性を高め、バグを回避できます。 -あなたのプロジェクトで古いブラウザをサポートする必要があれば、ES5/ES6/ES7コードをES5のJavascriptにトランスパイラーするツール を使用して下さい。

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/primitives.html b/manual/ja/primitives.html deleted file mode 100644 index 116023a58c67fd..00000000000000 --- a/manual/ja/primitives.html +++ /dev/null @@ -1,322 +0,0 @@ - - - のプリミティブ - - - - - - - - - - - - - -
-
-

のプリミティブ

-
-
-
-

この記事はthree.jsについてのシリーズ記事の一つです。 -最初の記事はThree.jsの基礎知識です。 -まだ読んでない人は、そちらから先に読んでみるといいかもしれません。

-

Three.jsは多くのプリミティブがあります。 -プリミティブは、3D形状のことで、一般的に、実行時に複数のパラメータを指定して生成されます。

-

地球儀の球体や、3Dグラフを描くための箱の集まりのようなものに、プリミティブはよく使われます。 -特に、プリミティブを使って実験して、3Dを始めるのが一般的です。 -3Dアプリケーションの多くは、3DモデルをBlenderや -Mayaや -Cinema 4Dといった -3Dモデリングプログラムを使って、アーティストに作ってもらう方が一般的です。 -このシリーズの後半では、いくつかの3Dモデリングプログラムからデータを作って -読み込む方法もカバーするつもりです。 -では、three.jsで利用できるプリミティブについて説明しましょう。

-

以下のプリミティブの多くは、一部または全てのパラメータにデフォルト値が設定されています。 -そのため、必要に応じて、上手く使い分けることができます。

-
立方体
-
2次元の円
-
円錐
-
円筒
-
十二面体(12面のもの)
-
-押し出しでできた2次元形状、ベベルオプション付き。 -これはTextGeometryTextGeometryのそれぞれの基礎になることに注意してください。
-
二十面体(20面のもの)
-
線を回転させてできる形状。例としてはこんなところでしょうか:ランプやボーリングのピン、ろうそく、ろうそく立て、ワイングラス、ドリンクグラス、などなど...。点の連続として2次元の輪郭を与え、その輪郭を軸の周りで回転させる際に、どのくらい細分化するかthree.jsに指示することができます。
-
八面体(8面)
-
関数を与えることでできる表面。この関数は、グリッド上2次元の点を引数に取り、対応する3次元の点を返す。
-
2次元の四角形
-
三角形を点の周りに集めて球体にする
-
真ん中に穴のあいた円盤
-
三角形分割された2次元の輪郭
-
球体
-
四面体(4面のもの)
-
3Dフォントと文字列からできた、3Dテキスト
-
円環(ドーナツ)
-
円環(結び目)
-
経路をなぞらせた管
-
異なるジオメトリを入力として、その面同士の角度が閾値以上なら角を作り出す、補助オブジェクト。例えば、記事の最初の方で紹介した立方体を見てみると、それぞれの面に、立方体を作っている全ての三角形の線が表示されています。EdgesGeometryを代わりに使うことで、面内の線は全て除去されます。下記のthresholdAngleを調整してみてください。閾値以下の角が消えて見えるでしょう。
-
1つの角ごとに1つの線分(2点)を持つジオメトリを生成する。WebGLは線分を作るのに2点が必要なので、この機能がないと、しばしば角を忘れたり、余分な角を作ってしまうでしょう。例えば、たった3点しかない1つの三角形あるとします。wireframe: trueのマテリアルを使ってそれを描こうとした場合、1本の線分しか得られません。WireframeGeometryにその三角形のジオメトリを渡すと、6点からなる3つの線分を持った新しいジオメトリを生成します。
- -

別の記事で、カスタムジオメトリの作成について説明します。 -今はそれぞれの種類のプリミティブを作成する例を作ってみます。 -以前の記事を例に始めましょう。

-

最初の方で、背景色を指定します。

-
const scene = new THREE.Scene();
-+scene.background = new THREE.Color(0xAAAAAA);
-
-

これでthree.jsに、透明からライトグレーに変えるように伝えます。

-

全てのオブジェクトを見られるよう、カメラの位置も変える必要があります。

-
-const fov = 75;
-+const fov = 40;
-const aspect = 2;  // the canvas default
-const near = 0.1;
--const far = 5;
-+const far = 1000;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
--camera.position.z = 2;
-+camera.position.z = 120;
-
-

addObject関数を加えましょう。これはx座標とy座標とObject3Dを取り、シーンにオブジェクトを追加します。

-
const objects = [];
-const spread = 15;
-
-function addObject(x, y, obj) {
-  obj.position.x = x * spread;
-  obj.position.y = y * spread;
-
-  scene.add(obj);
-  objects.push(obj);
-}
-
-

ランダムに色付けされたマテリアルを作る関数も作成してみましょう。 -色相、彩度、輝度に基づいて色を設定できる、Colorの機能を使ってみます。

-

hueは色相環を0から1まで変化します。赤は0、緑は.33、青は.66です。 -saturationは0から1まで変化します。0 は無色で、1は最も彩度の高いです。 -luminanceは0から1まで変化します。0は黒、1は白、0.5が色の最大量になります。 -言い換えると、luminanceが0.0から0.5に変化するにつれて、色は黒からhueに -変わります。0.5から1.0に変化するにつれて、hueから白に変化します。

-
function createMaterial() {
-  const material = new THREE.MeshPhongMaterial({
-    side: THREE.DoubleSide,
-  });
-
-  const hue = Math.random();
-  const saturation = 1;
-  const luminance = .5;
-  material.color.setHSL(hue, saturation, luminance);
-
-  return material;
-}
-
-

私たちはside: THREE.DoubleSideもマテリアルに渡しました。 -これはthreeに形状を作るときに三角形の両面を描くように指示します。 -球体や立方体のような立体形状には、形状の内側を向いている裏側を描く -理由はありません。 -しかしこの例だと、2次元で裏側が存在しないPlaneGeometryShapeGeometryのようなものも描こうとしています。 -side: THREE.DoubleSideを設定しないと、裏側を見たときに消えてしまうことでしょう。

-

side: THREE.DoubleSidenotが設定された方が、描画が速くなります。 -そのため、理想的には本当に必要なときだけ設定するのが良いことを注記しておきます。 -しかしこの例だと、そんなにたくさん描画しないので心配ありません。

-

addSolidGeometry関数を作りましょう。ジオメトリを渡すとcreateMaterialによってランダムに色が付いたマテリアルを作り、addObjectによってシーンに追加してくれます。

-
function addSolidGeometry(x, y, geometry) {
-  const mesh = new THREE.Mesh(geometry, createMaterial());
-  addObject(x, y, mesh);
-}
-
-

これで私たちの作るプリミティブの大多数に、この関数が使用できます。 -例えば、立方体を作ってみます。

-
{
-  const width = 8;
-  const height = 8;
-  const depth = 8;
-  addSolidGeometry(-2, -2, new THREE.BoxGeometry(width, height, depth));
-}
-
-

下記のコードを覗いてみると、それぞれの種類のジオメトリに対して、同じような箇所があります。

-

結果はこのようになります:

-

- -

-

上記のパターンには、2つの特筆すべき例外があります。 -一番大きなものは、たぶんTextGeometryです。テキストのメッシュを作るときは、事前に3Dフォントデータを読み込む必要があります。このデータの読み込みは非同期的に行われるので、ジオメトリを作ろうとする前に、読み込みを待つ必要があります。フォントの読み込みにpromiseを使うと、もっと速く読み込むことができます。 -FontLoaderを作成し、読み込みが完了するとフォントを提供してくれるpromiseを返すloadFont関数を作ります。 -次に、doit と呼ばれるasync関数を作り、awaitを使ってフォントを読み込みます。 -最後に、ジオメトリを作り、addObjectを呼んでシーンに追加します。

-
{
-  const loader = new FontLoader();
-  // promisify font loading
-  function loadFont(url) {
-    return new Promise((resolve, reject) => {
-      loader.load(url, resolve, undefined, reject);
-    });
-  }
-
-  async function doit() {
-    const font = await loadFont('resources/threejs/fonts/helvetiker_regular.typeface.json');  /* threejs.org: url */
-    const geometry = new TextGeometry('three.js', {
-      font: font,
-      size: 3.0,
-      depth: .2,
-      curveSegments: 12,
-      bevelEnabled: true,
-      bevelThickness: 0.15,
-      bevelSize: .3,
-      bevelSegments: 5,
-    });
-    const mesh = new THREE.Mesh(geometry, createMaterial());
-    geometry.computeBoundingBox();
-    geometry.boundingBox.getCenter(mesh.position).multiplyScalar(-1);
-
-    const parent = new THREE.Object3D();
-    parent.add(mesh);
-
-    addObject(-1, -1, parent);
-  }
-  doit();
-}
-
-

また、もう一つ違いがあります。私たちはテキストを、自身の中心の周りで回転させたかったのですが、 -three.jsはデフォルトで、テキストを左端中心に回転するよう作成します。 -これを回避するため、three.jsにジオメトリのバウンディングボックスの計算をさせることができます。 -バウンディングボックスのgetCenterメソッドを呼ぶことができるので、それにメッシュの位置オブジェクトに渡します。 -すると、getCenterが箱の中心をその位置にコピーします。このとき、位置オブジェクトも返すので、回転の中心が物体の中心になるように、オブジェクト全体の位置に対してmultiplyScalar(-1)を呼ぶことができます。

-

これだと、もし先の例のようにaddSolidGeometryを呼ぶと、 -再び位置が設定されてしまいますが、それはよくありませんよね。 -そのためこの例では、three.jsのシーングラフの標準的なノードであるObject3Dを作ります。 -Meshは同様にObject3Dを継承しています。 -別の記事でどのようにシーングラフが働くかカバーします。 -今はとりあえず、DOMノードのように、子ノードは親ノードと関連して描画されると知っていれば十分です。 -Object3Dを作成し、メッシュをその子にすることで、どこにでもObject3Dに配置し、 -先ほど設定した中心のオフセットを維持したままにできます。

-

こうしないと、テキストが中央からずれて回ってしまうことになります。

-

- -

-

左側のものは自身の中心の周りを回転していませんが、右側のものはそうなっていることに -注意してください。

-

もう一つの例外は、EdgesGeometryWireframeGeometryの、2つの直線に基づいた例です。 -addSolidGeometryを呼ぶ代わりに、このようにaddLineGeometryを呼んでいます。

-
function addLineGeometry(x, y, geometry) {
-  const material = new THREE.LineBasicMaterial({color: 0x000000});
-  const mesh = new THREE.LineSegments(geometry, material);
-  addObject(x, y, mesh);
-}
-
-

黒色のLineBasicMaterialを作り、次にLineSegmentsオブジェクトを作成しています。 -これはMeshのラッパーで、あなたが線分(線分あたり2点)を描画しようとしていることを -threeが知る手助けをします。

-

プリミティブのそれぞれは、作成時に渡すことができる複数のパラメーターを持っていて、 -ここで繰り返し説明するよりもこのドキュメントを覗いてもらうのが最善です。 -また、各形状の横にある上記のリンクをクリックすると、その形状のドキュメントに直接案内されます。

-

上記パターンに全然当てはまらないクラスの組があります。 -それはPointsMaterialPointsクラスです。PointsLineSegmentsに似ていて、 -GeometryBufferGeometryを引数に取ります。しかし、線の代わりに各頂点の点を描画します。 -使うためには、PointsMaterialも渡す必要があります。 -これは、点をどれくらい大きくするか決めるためsize を引数に取ります。

-
const radius = 7;
-const widthSegments = 12;
-const heightSegments = 8;
-const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
-const material = new THREE.PointsMaterial({
-    color: 'red',
-    size: 0.2,     // in world units
-});
-const points = new THREE.Points(geometry, material);
-scene.add(points);
-
-
-
-
- -

カメラからの距離に関わらず点の大きさを同じにしたいなら、sizeAttenuation をfalseにすることで、サイズ変更を止めることができます。

-
const material = new THREE.PointsMaterial({
-    color: 'red',
-+    sizeAttenuation: false,
-+    size: 3,       // in pixels
--    size: 0.2,     // in world units
-});
-...
-
-
-
-
- -

もう一つ説明が必要な大切なことは、ほとんど全部の形状が、 -どのくらい細分化するか決めるための設定を持っていることです。 -球体のジオメトリが良い例かもしれません。 -球体は周囲と上下にどのくらい分割するかのパラメータがあります。 -例えば、

-
-
-
-
-
- -

最初の球体は、15セグメントまたは30個の三角形になる、周囲に5セグメント、高さ3です。 -二つ目の球体は、240セグメントまたは480個の三角形になる、周囲に24セグメント、高さ10です。です。 -最後の球体は、2500セグメントまたは5000個の三角形になる、周囲に50セグメント、高さ50です。

-

どのくらい分割が必要かは、みなさんが決めることです。 -多くのセグメントが必要なように見えるかもしれませんが、線を除去して、 -影をならすことで、このようになります。

-
-
-
-
-
- -

5000個の三角形からできる右側の球体が、たった480個の三角形からできる真ん中の球体よりも良いかは、明らかではありません。 -地球の地図のために1個の地球儀を作るときのように、もし少ない数の球体を描くだけなら、 -10000個の三角形の球体でも悪い選択ではありません。 -一方で、1000個の球体を書こうとしているなら、1000個の球体におのおの10000個の三角形が -かかり、一千万個の三角形になります。 -滑らかに動かすにはブラウザが一秒間に60フレーム描画する必要があるため、 -ブラウザは1秒間に6億個の三角形を描画する必要があります。 -それは計算が多すぎます。

-

選ぶのが簡単なときもあります。例えば、平面の細分化を選ぶこともできます。

-
-
-
-
- -

左側の四角形は2個の三角形からできています。右側の四角形は200個の三角形からできています。 -球体のときと異なり、四角形の場合だと、質的なトレードオフは全くありません。 -いくつかの用途で、たいてい四角形を改造したり歪めたりしたいと思っているときに、細分化するだけで良いでしょう。 -立方体も同様です。

-

みなさんの状況にふさわしいものを選びましょう。 -物体は、選んだ細分化が小さいほど、より滑らかに動いて、省メモリになることでしょう。 -あなたの特定の状況にふさわしい、正しいトレードオフは何か、決めなければいけません。

-

みなさんの用途に適した形状がないなら、例えば、.obj file -や.gltf fileからジオメトリを読み込むことができます。 -カスタムBufferGeometryを作ることもできます。

-

次は、threeのシーングラフの動き方と使い方を説明します。

-

- - - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/rendering-on-demand.html b/manual/ja/rendering-on-demand.html deleted file mode 100644 index 9bf2b820b3c88f..00000000000000 --- a/manual/ja/rendering-on-demand.html +++ /dev/null @@ -1,213 +0,0 @@ - - - で要求されたレンダリング - - - - - - - - - - - - - -
-
-

で要求されたレンダリング

-
-
-
-

多くの人にとって当然かもしれませんが、ほとんどのThree.js exampleでは連続したレンダリングをします。 -言い換えると requestAnimationFrame ループ、または"rAF loop"ループは以下のようになります。

-
function render() {
-  ...
-  requestAnimationFrame(render);
-}
-requestAnimationFrame(render);
-
-

アニメーションする時は意味がありますがしない時はどうでしょう? -連続したレンダリングはデバイスの電力浪費になり、ポータブルデバイスを使用している場合はバッテリーを浪費します。

-

これを解決する最も明確な方法は、最初に一度レンダリングして何か変更された時だけレンダリングする事です。 -変更にはテクスチャやモデルの読込完了、外部ソースからのデータ受取、ユーザーによる設定やカメラ調整などその他の関連する入力などが含まれます。

-

レスポンシブデザインの記事を例に要求に応じてレンダリングするように修正してみましょう。

-

最初に OrbitControls を追加します。これで何かの変更を反映してレンダリングする事ができます。

-
import * as THREE from 'three';
-+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-
-

次に以下のように設定します。

-
const fov = 75;
-const aspect = 2;  // the canvas default
-const near = 0.1;
-const far = 5;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-camera.position.z = 2;
-
-+const controls = new OrbitControls(camera, canvas);
-+controls.target.set(0, 0, 0);
-+controls.update();
-
-

cubesのアニメーションは必要がないのでトラッキングは必要はありません。

-
-const cubes = [
--  makeInstance(geometry, 0x44aa88,  0),
--  makeInstance(geometry, 0x8844aa, -2),
--  makeInstance(geometry, 0xaa8844,  2),
--];
-+makeInstance(geometry, 0x44aa88,  0);
-+makeInstance(geometry, 0x8844aa, -2);
-+makeInstance(geometry, 0xaa8844,  2);
-
-

cubesをアニメーションさせるコードと requestAnimationFrame の呼出を削除する事ができます。

-
-function render(time) {
--  time *= 0.001;
-+function render() {
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
--  cubes.forEach((cube, ndx) => {
--    const speed = 1 + ndx * .1;
--    const rot = time * speed;
--    cube.rotation.x = rot;
--    cube.rotation.y = rot;
--  });
-
-  renderer.render(scene, camera);
-
--  requestAnimationFrame(render);
-}
-
--requestAnimationFrame(render);
-
-

そして、もう一度レンダリングする必要があります。

-
render();
-
-

OrbitControls がカメラ設定を変更する時はレンダリングする必要があります。 -幸いな事に OrbitControls は何か変更された時に change イベントをdispatchします。

-
controls.addEventListener('change', render);
-
-

ウィンドウのリサイズ時の対応も必要です。 -前は連続したレンダリングで自動的な処理でしたが、ウィンドウのリサイズ時にレンダリングする必要があります。

-
window.addEventListener('resize', render);
-
-

これで要求されたらレンダリングする事ができます。

-

- -

-

OrbitControls には慣性のようなものを追加して動きを滑らかにするオプションがあります。 -これを有効にするには enableDamping プロパティをtrueに設定します。

-
controls.enableDamping = true;
-
-

enableDamping をオンにした状態で、render関数内で controls.update を呼び出す必要があります。 -これで動きを滑らかにする新しいカメラ設定を OrbitControls に与えてくれます。 -この設定は動きを滑らかにしてくれますが、無限ループになってしまうので change イベントから直接 render を呼び出す事はできません。 -controlsは change イベントを送信し render を呼び出します。 rendercontrols.update を呼び出します。 -controltrols.update は別の change イベントを送信します。

-

この問題は requestAnimationFrame を使い render を呼び出す事で解決できます。 -まだ新しいフレームが要求されていない場合、新しいフレームを要求するようにしなければなりません。

-
+let renderRequested = false;
-
-function render() {
-+  renderRequested = false;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-  renderer.render(scene, camera);
-}
-render();
-
-+function requestRenderIfNotRequested() {
-+  if (!renderRequested) {
-+    renderRequested = true;
-+    requestAnimationFrame(render);
-+  }
-+}
-
--controls.addEventListener('change', render);
-+controls.addEventListener('change', requestRenderIfNotRequested);
-
-

リサイズにも requestRenderIfNotRequested を使うべきでしょう。

-
-window.addEventListener('resize', render);
-+window.addEventListener('resize', requestRenderIfNotRequested);
-
-

違いがわかりにくいかもしれません。以下のサンプルで矢印キーを使って移動したりドラッグして回転させてみて下さい。 -次にこのページの一番上のサンプルで同じ事をしてみて下さい。 -一番上のサンプルでは矢印キーを押したりドラッグしたりするとスナップし、以下のサンプルではスライドします。

-

- -

-

シンプルなlil-guiを追加し、GUIで値の変更時にレンダリングを要求してみましょう。

-
import * as THREE from 'three';
-import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-+import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
-
-

各キューブの色と×スケールを設定できるようにしましょう。 -色を設定するには照明の記事で作成した ColorGUIHelper を使います。

-

まずはGUIを作成する必要があります。

-
const gui = new GUI();
-
-

次に各キューブに対してフォルダを作成し、2つのコントロールを追加します。 -1つは material.color、もう1つは cube.scale.xです。

-
function makeInstance(geometry, color, x) {
-  const material = new THREE.MeshPhongMaterial({color});
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  cube.position.x = x;
-
-+  const folder = gui.addFolder(`Cube${x}`);
-+  folder.addColor(new ColorGUIHelper(material, 'color'), 'value')
-+      .name('color')
-+      .onChange(requestRenderIfNotRequested);
-+  folder.add(cube.scale, 'x', .1, 1.5)
-+      .name('scale x')
-+      .onChange(requestRenderIfNotRequested);
-+  folder.open();
-
-  return cube;
-}
-
-

lil-guiには onChange メソッドがあり、GUIで値を変更時にコールバックを渡す事ができます。今回は requestRenderIfNotRequested をコールバックするだけです。 -folder.open でフォルダ展開できます。

-

- -

-

three.jsを連続したレンダリングでなく、要求に応じてレンダリングさせる方法のヒントになれば幸いです。 -three.jsを要求に応じてレンダリングするアプリ/ページはあまり一般的ではありませんが、three.jsを使用しているページの多くはゲームや3Dアニメーション、エディタ、3Dグラフ生成、商品カタログなどのアートです。

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/rendertargets.html b/manual/ja/rendertargets.html deleted file mode 100644 index 105cf4c38d2456..00000000000000 --- a/manual/ja/rendertargets.html +++ /dev/null @@ -1,160 +0,0 @@ - - - のレンダーターゲット - - - - - - - - - - - - - -
-
-

のレンダーターゲット

-
-
-
-

レンダーターゲットとはレンダリングする先のテクスチャです。一度レンダリングすればテクスチャのように使うことができます。

-

簡単な例を作ってみましょう。レスポンシブデザインの記事にある例を試してみましょう。

-

レンダーターゲットへのレンダリングは通常のレンダリングとほぼ同じです。まずWebGLRenderTargetを作ります。

-
const rtWidth = 512;
-const rtHeight = 512;
-const renderTarget = new THREE.WebGLRenderTarget(rtWidth, rtHeight);
-
-

CameraSceneを作ります。

-
const rtFov = 75;
-const rtAspect = rtWidth / rtHeight;
-const rtNear = 0.1;
-const rtFar = 5;
-const rtCamera = new THREE.PerspectiveCamera(rtFov, rtAspect, rtNear, rtFar);
-rtCamera.position.z = 2;
-
-const rtScene = new THREE.Scene();
-rtScene.background = new THREE.Color('red');
-
-

アスペクトをキャンバスのアスペクトではなくレンダーターゲットのアスペクトに設定したことに注意してください。レンダリング先のアスペクトに合わせるのが正解です。この例では立方体の側面につかうテクスチャにレンダリング先にします。立方体の側面ば正方形なのでアスペクトは1.0です。

-

いろいろ入れてみましょう。この例ではライトと3つの立方体をこの前の記事から使います。

-
{
-  const color = 0xFFFFFF;
-  const intensity = 1;
-  const light = new THREE.DirectionalLight(color, intensity);
-  light.position.set(-1, 2, 4);
-*  rtScene.add(light);
-}
-
-const boxWidth = 1;
-const boxHeight = 1;
-const boxDepth = 1;
-const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-
-function makeInstance(geometry, color, x) {
-  const material = new THREE.MeshPhongMaterial({color});
-
-  const cube = new THREE.Mesh(geometry, material);
-*  rtScene.add(cube);
-
-  cube.position.x = x;
-
-  return cube;
-}
-
-*const rtCubes = [
-  makeInstance(geometry, 0x44aa88,  0),
-  makeInstance(geometry, 0x8844aa, -2),
-  makeInstance(geometry, 0xaa8844,  2),
-];
-
-

前の記事のSceneCameraはそのままにしてあります。これらをキャンバスにレンダリングするために使います。空の空間をレンダリングしても意味がないのでとりあえず入れてあります。

-

レンダーターゲットとなるテクスチャを貼る立方体をシーンに追加します。

-
const material = new THREE.MeshPhongMaterial({
-  map: renderTarget.texture,
-});
-const cube = new THREE.Mesh(geometry, material);
-scene.add(cube);
-
-

このシーンをレンダーターゲットにレンダリングします。

-
function render(time) {
-  time *= 0.001;
-
-  ...
-
-  // rotate all the cubes in the render target scene
-  rtCubes.forEach((cube, ndx) => {
-    const speed = 1 + ndx * .1;
-    const rot = time * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
-  // draw render target scene to render target
-  renderer.setRenderTarget(renderTarget);
-  renderer.render(rtScene, rtCamera);
-  renderer.setRenderTarget(null);
-
-

次にレンダーターゲットのテクスチャが貼られている立方体を追加したシーンをキャンバスにレンダリングします。

-
  // rotate the cube in the scene
-  cube.rotation.x = time;
-  cube.rotation.y = time * 1.1;
-
-  // render the scene to the canvas
-  renderer.render(scene, camera);
-
-

見てください!

-

- -

-

立方体が赤いのはレンダーターゲットのテクスチャとしてレンダリングしたrtScenebackgroundを赤でクリアーしたからです。

-

レンダーターゲットは色々な使い方ができます。シャドウもレンダーターゲットを使います。ピッキングもレンダーターゲットを使います。ポストプロセッシング効果もレンダーターゲットを使います。車のミラーを再現するために車の背後をレンダリングしたテクスチャを利用できますし、3Dゲームに出てくる監視モニターにも使うことができます。

-

WebGLRenderTargetを使う時にはいくつか注意点があります。

-
    -
  • デフォルトでWebGLRenderTargetは2つのテクスチャを作ります。

    -

    color textureとdepth (stencil) textureです。もし後者のテクスチャが必要なければオプションでオフにできます。

    -
      const rt = new THREE.WebGLRenderTarget(width, height, {
    -    depthBuffer: false,
    -    stencilBuffer: false,
    -  });
    -
    -
  • -
  • レンダーテクスチャのサイズを変更する必要があるかもしれません

    -

    上の例では512 x 512のサイズのレンダーテクスチャを作りました。通常はポストプロセッシングにおいてキャンバスと同じサイズのレンダーターゲットが必要になります。つまりキャンバスのサイズを変えたら同時にレンダーターゲットとレンダーターゲットに使うカメラのサイズも一緒に変える必要があります。

    -
    function render(time) {
    -  time *= 0.001;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -
    -+    renderTarget.setSize(canvas.width, canvas.height);
    -+    rtCamera.aspect = camera.aspect;
    -+    rtCamera.updateProjectionMatrix();
    -}
    -
  • -
- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/responsive.html b/manual/ja/responsive.html deleted file mode 100644 index 933ebbd4088ea2..00000000000000 --- a/manual/ja/responsive.html +++ /dev/null @@ -1,204 +0,0 @@ - - - のレスポンシブデザイン - - - - - - - - - - - - - -
-
-

のレスポンシブデザイン

-
-
-
-

これはthree.jsの2番目の連載記事です。 -最初の記事は Three.jsの基礎知識 でした。 -まだ読んでいない場合はそこから始めて下さい。

-

この記事はthree.jsアプリをどんな状況にもレスポンシブにする方法を説明します。 -一般的なレスポンシブ対応のWebページはデスクトップやタブレット、スマートフォンなど異なったディスプレイサイズに対応します。

-

three.jsの場合、さらに考慮すべき状況があります。例えば3Dエディターで左・右・上・下に何かを制御したい場合です。このドキュメントの真ん中にあるコードが一つの例です。 -最後のサンプルコードはCSSでサイズ指定なしのcanvasを使ってます。

-
<canvas id="c"></canvas>
-
-

このcanvasのデフォルトサイズは300 x 150です。 -Web上ではCSSでサイズ指定する事が推奨されています。 -CSSを追加しcanvasをWebページ一杯にしましょう。

-
<style>
-html, body {
-   margin: 0;
-   height: 100%;
-}
-#c {
-   width: 100%;
-   height: 100%;
-   display: block;
-}
-</style>
-
-

bodyのmarginはデフォルトで5ピクセルのためマージンを0にします。 -htmlとbodyの高さは100%にしウィンドウ一杯に設定します。 -そうしないとhtmlとbodyはbody内のコンテンツと同じぐらいのサイズにしかなりません。

-

次にbodyのコンテナーである id=c のelementが100%のサイズになるようにします。

-

最後にそのコンテナーの displayblock に設定します。canvasのdisplayのデフォルトは inline です。インライン要素は表示されているものに空白を追加してしまう事があります。このような場合はcanvasを block に設定するとこの問題は解消されます。

-

その結果がこちらにあります。

-

- -

-

canvasがページを埋め尽くすようになりましたが、2つ問題があります。 -1つはキューブが伸びています。キューブは立方体でなく箱のようなものです。高すぎて広がりすぎています。サンプルを開いてブラウザのウィンドウサイズをリサイズすると、キューブが伸びていて高すぎるのがわかります。

-

-

2つ目の問題は解像度が低い、または濃淡にムラがありぼやけて見える事です。ウィンドウを大きく引き伸ばすとこの問題がわかります。

-

-

まず引き伸びている問題を解決しましょう。そのためにはカメラのアスペクトをcanvasの表示サイズのアスペクトに設定する必要があります。canvasの clientWidthclientHeight を参照する事で設定を行う事ができます。

-

レンダーのループ処理を次のように更新します。

-
function render(time) {
-  time *= 0.001;
-
-+  const canvas = renderer.domElement;
-+  camera.aspect = canvas.clientWidth / canvas.clientHeight;
-+  camera.updateProjectionMatrix();
-
-  ...
-
-

これでキューブが歪むのを止められます。

-

- -

-

サンプルを別ウィンドウで開きウィンドウのサイズを変更すると、キューブが縦にも横にも伸びていない事がわかるはずです。ウィンドウの大きさに関係なく、正しいアスペクトを保っています。

-

-

次はブロックノイズを修正していきましょう。

-

キャンバス要素には2つのサイズがあります。1つ目のサイズは、キャンバスがページに表示されるサイズです。それはCSSで設定しています。2つ目のサイズはキャンバス自体のピクセル数です。これは画像と何ら変わりありません。 -例えば、128 x 64ピクセルの画像を持っていて、CSSを使って400 x 200ピクセルで表示する事ができるかもしれません。

-
<img src="some128x64image.jpg" style="width:400px; height:200px">
-
-

キャンバス内部のサイズ、その解像度は描画バッファサイズと呼ばれます。 -three.jsでは renderer.setSize を呼び出す事でキャンバスの描画バッファサイズを設定する事ができます。 -どのサイズを選ぶべきでしょうか?一番わかりやすい答えは"キャンバスが表示されているサイズと同じ"です。 -もう一度キャンバスの clientWidthclientHeight を見てみましょう。

-

レンダラーのキャンバスが表示されているサイズになっていないかどうかを確認し、表示されている場合はサイズを設定する関数を書いてみましょう。

-
function resizeRendererToDisplaySize(renderer) {
-  const canvas = renderer.domElement;
-  const width = canvas.clientWidth;
-  const height = canvas.clientHeight;
-  const needResize = canvas.width !== width || canvas.height !== height;
-  if (needResize) {
-    renderer.setSize(width, height, false);
-  }
-  return needResize;
-}
-
-

キャンバスのサイズを変更する必要があるかどうかをチェックしています。キャンバスのサイズを変更する事は、キャンバスの仕様の興味深い部分であり、すでに必要なサイズになっている場合は同じサイズを設定しない方が良いでしょう。

-

サイズを変更する必要があるかどうかわかったら、次に renderer.setSize を呼び出して新しい幅と高さを渡します。最後に false を渡す事が重要です。

-

デフォルトでは renderer.setSize はキャンバスのCSSサイズを設定しますが、これは私たちが望んでいるものではありません。ブラウザは他の全ての要素に対して、CSSを使用して要素の表示サイズを決定するという方法で動作し続けてほしいのです。3つの要素で使用されるキャンバスが他の要素と異なるのは避けたいのです。

-

この関数はキャンバスのサイズが変更された場合、trueを返す事に注意して下さい。この関数を使って他にも更新すべき事があるかどうかをチェックする事ができます。この関数を使ってレンダーのループ処理を修正してみましょう。

-
function render(time) {
-  time *= 0.001;
-
-+  if (resizeRendererToDisplaySize(renderer)) {
-+    const canvas = renderer.domElement;
-+    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-+    camera.updateProjectionMatrix();
-+  }
-
-  ...
-
-

キャンバスの表示サイズが変更されて resizeRendererToDisplaySizetrue を返した場合のみ、カメラのアスペクトを設定します。

-

- -

-

これでキャンバスの表示サイズに合った解像度でレンダリングされるようになりました。

-

CSSにリサイズ処理を任せた場合のポイントを明確にするために、このコードを 分離した .js ファイル に入れてみましょう。 -ここではCSSがサイズを選択するいくつかのサンプルがあります。 -それらが動作するようにゼロからコードを変更しなければならなかった事に気づくでしょう。

-

文章の段落の真ん中にキューブを置いてみましょう。

-

- -

-

エディタスタイルのレイアウトで右側のコントロールエリアのサイズを変更できるようにしたのと同じコードです。

-

- -

-

注目すべき重要な部分はコードが変更されていない事です。HTMLとCSSだけが変更されました。

-

HD-DPIディスプレイの取り扱い

-

HD-DPIとは高解像度ディスプレイの略です。 -最近ではほとんどのスマートフォンと同じくらい、Macや多くのWindowsマシンで採用されています。

-

ブラウザでの動作方法は、CSSを使用してディスプレイの高解像度に関係なく同じサイズを設定する事です。ブラウザはテキストをさらに詳細にレンダリングしますが、物理的なサイズは同じです。

-

three.jsでHD-DPIを扱う方法は色々あります。

-

1つ目の方法は特に何もしない事です。これは間違いなく最も一般的な方法です。3DグラフィックのレンダリングにはたくさんのGPUの処理パワーが必要です。モバイルのGPUは少なくとも2018年時点ではデスクトップよりも電力が少ないが、それでも携帯電話は非常に高解像度のディスプレイを搭載している事が多いです。現在の上位機種はHD-DPI比が3倍という事は、非HD-DPIディスプレイの1ピクセルごとに9ピクセルを持っている事を意味します。つまり、9倍のレンダリングをしなければならないという事です。

-

9倍のピクセルを計算するのは大変な作業なので、コードをそのままにしておくと1倍のピクセルを計算して、ブラウザは3倍のサイズ(3x x 3x = 9xピクセル)で描画します。

-

重いthree.jsアプリの場合はこれが必要でしょう。そうしないとフレームレートが遅くなる可能性があります。

-

デバイスの解像度でレンダリングしたい場合、three.jsにはいくつかのデバイスを変更する方法があります。

-

1つは renderer.setPixelRatio でthree.jsに解像度の乗数を伝える事です。 -CSSピクセルからデバイスピクセルへの乗数をブラウザに伝え、それをthree.jsに渡します。

-
 renderer.setPixelRatio(window.devicePixelRatio);
-

renderer.setSize を呼び出し後、要求されたサイズに渡されたピクセル比を乗算したものが使用されます。これは強く非推奨です。以下を参照して下さい。

-

もう1つの方法は、キャンバスのサイズを変更する時に自分で設定する事です。

-
    function resizeRendererToDisplaySize(renderer) {
-      const canvas = renderer.domElement;
-      const pixelRatio = window.devicePixelRatio;
-      const width  = Math.floor( canvas.clientWidth  * pixelRatio );
-      const height = Math.floor( canvas.clientHeight * pixelRatio );
-      const needResize = canvas.width !== width || canvas.height !== height;
-      if (needResize) {
-        renderer.setSize(width, height, false);
-      }
-      return needResize;
-    }
-
-

この2つ目の方法の方が客観的には優れています。なぜかと言うと私が求めるものを手に入れる事ができるからです。

-

three.jsを使っていると実際のキャンバスの描画バッファのサイズを指定します。例えば、後処理フィルタを作成する場合などです。 -または gl_FragCoord にアクセスするシェーダを作成している場合、あるいは2Dキャンバスに描画するためのスクリーンショット、またはGPUピッキング用のピクセルを読み込んだ場合などに使用する事ができます。

-

setPixelRatio を使うと要求したサイズよりも実際のサイズが違ってしまう事が多々あります。いつ要求したサイズが使えるか、いつThree.jsの実際のサイズが使えるか推測しなければなりません。 -これを自分で行う事で使用されているサイズが要求したサイズである事を常に知る事ができます。 -裏で魔法がかかっているという特殊ケースではありません。

-

上のコードを使った例です。

-

- -

-

違いがわかりにくいかもしれませんが、HD-DPIディスプレイをお持ちの方はこのサンプルを上のサンプルと比較してみて下さい。エッジがより鮮明になっている事がわかると思います。

-

基礎な内容ですがこの記事ではとても基本的な所を取り上げました。次はthree.jsが提供する基本的なプリミティブについて簡単に説明します。

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/scenegraph.html b/manual/ja/scenegraph.html deleted file mode 100644 index e5ca1faedc0a56..00000000000000 --- a/manual/ja/scenegraph.html +++ /dev/null @@ -1,449 +0,0 @@ - - - のシーングラフ - - - - - - - - - - - - - -
-
-

のシーングラフ

-
-
-
-

この記事はthree.jsについてのシリーズ記事の一つです。 -最初の記事はThree.jsの基礎です。 -まだ読んでない人は、そちらから先に読んでみるといいかもしれません。

-

Three.jsの核心は間違いなくシーングラフです。 -3Dエンジンのシーングラフは、各ノードがローカルな空間を表現している、グラフ内のノードの階層です。

-

-

抽象的なので、例をいくつか挙げてみましょう。

-

例の一つは太陽系、太陽・地球・月でしょうか。

-

-

地球は太陽を回っています。月は地球を回っています。 -月は地球の周りを円を描いて移動しています。月から見ると、地球の"ローカルな空間"を回っていることになります。 -太陽との相対的な動きは、月の視点から見るとクレイジーな螺旋のような曲線に見えますが、単に地球のローカルな空間を周回していると捉える必要があります。

-

- -
- -

-

別の考え方をしてみます。地球が地軸の周りを自転していることも、太陽の周りを公転していることも、 -地球に住んでいるあなたが考える必要はありません。 -皆さんは全くもって地球が動きも回りもしていないかのように、 -歩いたり、ドライブしたり、泳いだり、走ったりするだけです。 -地球の"ローカルな空間"で歩いたり、ドライブしたり、泳いだり、走ったり、そして生活したりしていても、みなさんは太陽と相対的に、地球の上で1,600km/hの速さで回転し、太陽の周りを107,200km/hの速度で回っています。 -太陽系上のみなさんの位置は、前述した月と同じようなものですが、気にする必要はありません。 -みなさんは地球の"ローカルな空間"で、地球との相対的な位置だけを心配していればいいのです。

-

一歩進みましょう。私たちは太陽と地球と月の図を作りたいと想像してみてください。 -まず、太陽から始めましょう。ただ球体を作り原点に置くだけです。 -シーングラフを使う方法の演習として、太陽、地球、月を使うことを、気に留めておいてください。 -もちろん、現実の太陽、地球、月は物理学に従いますが、演習目的なので、シーングラフで代用します。

-
// an array of objects whose rotation to update
-const objects = [];
-
-// use just one sphere for everything
-const radius = 1;
-const widthSegments = 6;
-const heightSegments = 6;
-const sphereGeometry = new THREE.SphereGeometry(
-    radius, widthSegments, heightSegments);
-
-const sunMaterial = new THREE.MeshPhongMaterial({emissive: 0xFFFF00});
-const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
-sunMesh.scale.set(5, 5, 5);  // make the sun large
-scene.add(sunMesh);
-objects.push(sunMesh);
-
-

とても少ないポリゴンからできた球体を使います。緯度方向にたった6分割です。 -これで、回転していることが見やすくなります。

-

同じ球体を全ての球体に使いまわすつもりなので、太陽のメッシュの大きさを5倍にしておきます。

-

また、phong materialのemissive属性を黄色に設定します。 -phong materialのemissive属性は、基本的に、光が当たっていない表面に描かれる色です。 -光源はその色に付け加えられます。

-

次に、シーンの真ん中に1つ点光源を置きましょう。後ほど、より詳細に点光源について説明しますが、 -一点から発せられる明かりというのが、とりあえずの簡単な説明です。

-
{
-  const color = 0xFFFFFF;
-  const intensity = 500;
-  const light = new THREE.PointLight(color, intensity);
-  scene.add(light);
-}
-
-

見やすくするために、直接原点を見下ろすようにカメラを置きましょう。 -最も簡単な方法は lookAt関数を使うことです。 -lookAt関数は、引数に渡した位置を「見る」ようにカメラを向けます。 -その前に、カメラの上部がどの方向を向いているか、もしくは、 -カメラにとってどの方向が"上"なのかを、カメラに伝える必要があります。 -ほとんどの場合、Y軸の正が上で十分ですが、 -今は見下ろしているので、Z軸の正が上だとカメラに伝える必要があります。

-
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-camera.position.set(0, 50, 0);
-camera.up.set(0, 0, 1);
-camera.lookAt(0, 0, 0);
-
-

レンダリングループの中で、前の例を参考にして、以下のコードで、objects配列内の全てのオブジェクトを回転させています。

-
objects.forEach((obj) => {
-  obj.rotation.y = time;
-});
-
-

sunMeshobjects配列に追加したので、回転します。

-

- -

-

さて、地球を追加してみましょう。

-
const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
-const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
-earthMesh.position.x = 10;
-scene.add(earthMesh);
-objects.push(earthMesh);
-
-

青いマテリアルを作っていますが、黒背景に対して目立つよう、 -emissiveに少し青色を設定します。

-

earthMeshを作るため、新しく作った青色のearthMaterialと、先と同じsphereGeometryを使います。 -それを太陽の10ユニット左側に置き、シーンに追加します。 -これはobjects配列にそれを追加されたので、同様に回転します。

-

- -

-

太陽と地球の両方が回転して見えますが、地球は太陽の周りを公転していません。 -地球を太陽の子要素にしてみましょう。

-
-scene.add(earthMesh);
-+sunMesh.add(earthMesh);
-
-

そして...

-

- -

-

なにが起きましたか?なぜ地球が太陽と同じ大きさで、こんなに離れているのでしょうか。 -地球を見るためには、実際のところ、カメラを50ユニット上から、150ユニット上に動かす必要がありました。

-

earthMeshsunMeshの子要素としました。 -sunMeshsunMesh.scale.set(5, 5, 5)によって5倍に大きさを設定しています。 -よって、sunMeshのローカルな空間は5倍大きくなりました。 -その空間におかれるあらゆるものは5倍されるのです。 -つまり、地球が5倍大きくなり、太陽からの距離も5倍(earthMesh.position.x = 10)になったのです。

-

シーングラフは、このようになります。

-

-

これを修正するため、シーングラフに空のノードを追加しましょう。 -そして、太陽と地球の両方をそのノードの子要素にしましょう。

-
+const solarSystem = new THREE.Object3D();
-+scene.add(solarSystem);
-+objects.push(solarSystem);
-
-const sunMaterial = new THREE.MeshPhongMaterial({emissive: 0xFFFF00});
-const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
-sunMesh.scale.set(5, 5, 5);
--scene.add(sunMesh);
-+solarSystem.add(sunMesh);
-objects.push(sunMesh);
-
-const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
-const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
-earthMesh.position.x = 10;
--sunMesh.add(earthMesh);
-+solarSystem.add(earthMesh);
-objects.push(earthMesh);
-
-

ここでObject3Dを作りました。Meshのように、シーングラフのノードですが、Meshとは異なり、マテリアルやジオメトリを持ちません。 -ただローカルな空間を表現するだけです。

-

新しいシーングラフは、このようになります。

-

-

sunMeshearthMeshは共にsolarSystemの子要素です。3つ全部が回転していますが、 -いまearthMeshsunMeshの子要素ではないので、5倍に拡大されません。

-

- -

-

とてもよくなりました。地球は太陽よりも小さく、太陽の周りを公転しつつ、自転しています。

-

続けて、同様の方法で月を追加してみましょう。

-
+const earthOrbit = new THREE.Object3D();
-+earthOrbit.position.x = 10;
-+solarSystem.add(earthOrbit);
-+objects.push(earthOrbit);
-
-const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
-const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
--solarSystem.add(earthMesh);
-+earthOrbit.add(earthMesh);
-objects.push(earthMesh);
-
-+const moonOrbit = new THREE.Object3D();
-+moonOrbit.position.x = 2;
-+earthOrbit.add(moonOrbit);
-
-+const moonMaterial = new THREE.MeshPhongMaterial({color: 0x888888, emissive: 0x222222});
-+const moonMesh = new THREE.Mesh(sphereGeometry, moonMaterial);
-+moonMesh.scale.set(.5, .5, .5);
-+moonOrbit.add(moonMesh);
-+objects.push(moonMesh);
-
-

再び、描画されないシーングラフのノードを追加しました。これは、earthOrbitと呼ばれるObject3Dです。 -そして、このノードにearthMeshmoonMeshの両方を追加しました。 -新しいシーングラフは、このようになります。

-

-

そして、このように描画されます。

-

- -

-

記事の上部でお見せした螺旋のパターンに沿った月が見えます。 -しかし、手動で操作する必要はありませんでした。 -ただ、シーングラフを設定しただけです。

-

シーングラフのノードが分かるような、なにかを描写すると、便利なことがあります。 -Three.jsはこれをするために、helpfulとか、helpersとかがあります。

-

一つはAxesHelperです。 -ローカルなXYZ軸を表す -3つの線を描画します。 -私たちが作った全てのノードに加えましょう。

-
// add an AxesHelper to each node
-objects.forEach((node) => {
-  const axes = new THREE.AxesHelper();
-  axes.material.depthTest = false;
-  axes.renderOrder = 1;
-  node.add(axes);
-});
-
-

私たちの場合、たとえ球体の内部であったとしても、軸を表示させたいです。 -これをするために、マテリアルのdepthTestをfalseにします。 -これによって、軸がなにかの内部に描画されているかどうかチェックしなくなります。 -全ての球体の後に描画されるように、renderOrderも1に設定します(デフォルト値は0です)。 -そうしないと、球体が軸の上に描画され、軸を覆ってしまう可能性があります。

-

- -

-

x (赤)z (青)の -軸が見えます。私たちはオブジェクトをまっすぐ見下ろしていて、オブジェクトはy軸を中心に -回転しているので、y (緑)軸があまり見えません。

-

位置が重なった軸が2組あるので、見づらいかもしれません。 -sunMeshsolarSystemは同じ場所にあります。 -同様に、earthMeshearthOrbitは同じ場所にあります。 -各ノードに対してオン/オフできるように、簡単な操作を加えてみましょう。 -そのついでに、GridHelper というヘルパー関数も追加しておきましょう。 -これはX,Z平面に2次元グリッドを作ります。デフォルトでは、グリッドは10x10ユニットです。

-

lil-guiも使います。 -これはthree.jsプロジェクトでとても一般的なUIライブラリです。 -lil-guiはオブジェクトとそのオブジェクトの属性名を受け取り、 -属性の型に基づいて、自動的にその属性を操作するUIを作成します。

-

それぞれのノードに対して、GridHelperAxesHelperの両方を作りたいです。 -それぞれのノートにラベルが必要なので、古いループを削除し、 -各ノードにhelperを加える関数を呼ぶ形式にします。

-
-// add an AxesHelper to each node
--objects.forEach((node) => {
--  const axes = new THREE.AxesHelper();
--  axes.material.depthTest = false;
--  axes.renderOrder = 1;
--  node.add(axes);
--});
-
-+function makeAxisGrid(node, label, units) {
-+  const helper = new AxisGridHelper(node, units);
-+  gui.add(helper, 'visible').name(label);
-+}
-+
-+makeAxisGrid(solarSystem, 'solarSystem', 25);
-+makeAxisGrid(sunMesh, 'sunMesh');
-+makeAxisGrid(earthOrbit, 'earthOrbit');
-+makeAxisGrid(earthMesh, 'earthMesh');
-+makeAxisGrid(moonMesh, 'moonMesh');
-
-

makeAxisGridは、lil-guiをハッピーにするAxisGridHelperクラスを作ります。 -前述したように、lil-guiは、オブジェクトの名前が付いた属性を操作するUIを自動的に生成します。 -属性の型に応じて異なるUIが作成されます。 -チェックボックスを作って欲しいので、bool属性を指定する必要があります。 -しかし、軸とグリッドの両方を一つの属性で表示/非表示にしたいので、 -属性のgetterとsetterを持ったクラスを作成します。 -この方法で、lil-guiに一つの属性を操作するように思わせることができますが、 -内部的には各ノードにAxesHelperGridHelperの両方のvisible属性を設定することができます。

-
// Turns both axes and grid visible on/off
-// lil-gui requires a property that returns a bool
-// to decide to make a checkbox so we make a setter
-// and getter for `visible` which we can tell lil-gui
-// to look at.
-class AxisGridHelper {
-  constructor(node, units = 10) {
-    const axes = new THREE.AxesHelper();
-    axes.material.depthTest = false;
-    axes.renderOrder = 2;  // after the grid
-    node.add(axes);
-
-    const grid = new THREE.GridHelper(units, units);
-    grid.material.depthTest = false;
-    grid.renderOrder = 1;
-    node.add(grid);
-
-    this.grid = grid;
-    this.axes = axes;
-    this.visible = false;
-  }
-  get visible() {
-    return this._visible;
-  }
-  set visible(v) {
-    this._visible = v;
-    this.grid.visible = v;
-    this.axes.visible = v;
-  }
-}
-
-

注意することは、AxesHelperrenderOrderを2に設定し、GridHelperには1を設定することです。 -こうすることで、軸はグリッドの後に描画されます。 -そうしないと、グリッドが軸を上書きしてしまうかもしれません。

-

- -

-

solarSystemのチェックをオンにすると、上で設定したように、 -どのように地球が中心からちょうど10ユニットにあるか分かるでしょう。 -地球がsolarSystemローカルな空間にどのように存在するか分かります。 -同様に、もしearthOrbitのチェックをオンにすると、 -どのように月がearthOrbitローカルな空間の中心から、ちょうど2ユニットあるか分かるでしょう。

-

もう少しシーングラフの例を紹介します。 -簡単なゲームの世界の自動車は、このようなシーングラフだとしましょう。

-

-

もし車のbody全体を動かすと、それに伴ってwheelsが動くでしょう。 -もしbodyにwheelsとは別にバウンドして欲しいとすると、 -bodyとwheelsを、車のフレームを表す"frame"ノードの子要素にできます。

-

別の例はゲームの世界の人間です。

-

-

とても複雑な人間のシーングラフを見てください。 -実際は、上記のシーングラフは単純化されています。 -例えば、全ての手の指(少なくとも28ノード)、全ての足の指(さらに28ノード)、 -加えて顔と顎、目、そしてもっと様々な部位もカバーするように、グラフを拡張できるかもしれません。

-

もう少し複雑なシーングラフを作りましょう。戦車を作ります。 -戦車は6つの車輪と砲塔があります。この戦車はある道筋に沿って走ります。 -そこら中を移動する球体があり、戦車はその球体を狙うとしましょう。

-

これがシーングラフです。メッシュは緑色、Object3Dは青色、明かりは金色、カメラは紫色です。 -シーングラフに追加されていないカメラが一つあります。

-
- -

コードを見て、これらのノードの設定を確認してください。

-

ターゲット、つまり戦車が狙っているものとして、targetOrbit(Object3D) があります。 -これはちょうど前述のearthOrbitと同じように回転します。 -targetOrbitの子要素であるtargetElevation (Object3D)は、 -targetOrbitからのオフセットと基準となる高さを提供します。 -この子要素には、targetElevationに対して相対的に浮き沈みする、targetBobと呼ばれるObject3Dがあります。 -最後に、targetMeshがあります。回転させて色を変えることができる、ただの立方体です。

-
// move target
-targetOrbit.rotation.y = time * .27;
-targetBob.position.y = Math.sin(time * 2) * 4;
-targetMesh.rotation.x = time * 7;
-targetMesh.rotation.y = time * 13;
-targetMaterial.emissive.setHSL(time * 10 % 1, 1, .25);
-targetMaterial.color.setHSL(time * 10 % 1, 1, .25);
-
-

戦車には、tankと呼ばれるObject3Dがあります。 -これを使って戦車の子要素をすべて移動させることができます。 -コードではSplineCurveを使っています。これは曲線に沿った位置を求めることができます。 -0.0は曲線の始点です。1.0は曲線の終点です。これにより、戦車がある現在地を求めます。 -次に、カーブの少し下の位置を求めて、Object3D.lookAtを使い、戦車をその方向に向けます。

-
const tankPosition = new THREE.Vector2();
-const tankTarget = new THREE.Vector2();
-
-...
-
-// move tank
-const tankTime = time * .05;
-curve.getPointAt(tankTime % 1, tankPosition);
-curve.getPointAt((tankTime + 0.01) % 1, tankTarget);
-tank.position.set(tankPosition.x, 0, tankPosition.y);
-tank.lookAt(tankTarget.x, 0, tankTarget.y);
-
-

戦車のてっぺんに付いている砲塔は、戦車の子要素なので自動的に動きます。 -ターゲットの方を向かせるのに、ターゲットの位置を求め、次に再びObject3D.lookAtを使うだけです。

-
const targetPosition = new THREE.Vector3();
-
-...
-
-// face turret at target
-targetMesh.getWorldPosition(targetPosition);
-turretPivot.lookAt(targetPosition);
-
-

turretCameraturretMeshの子要素なので、砲塔と一緒に上下に動き、回転します。

-
// make the turretCamera look at target
-turretCamera.lookAt(targetPosition);
-
-

targetBobの子要素であるtargetCameraPivotもあります。これはターゲットと一緒に浮遊します。 -戦車に狙いを定めましょう。targetCameraにターゲット自身に高さを合わせるためです。 -もしカメラをtargetBobの子要素にして、カメラ自身に狙いを定めさせただけだと、 -カメラがターゲットの内側に入り込んでしまうでしょう。

-
// make the targetCameraPivot look at the tank
-tank.getWorldPosition(targetPosition);
-targetCameraPivot.lookAt(targetPosition);
-
-

最後に、全ての車輪を回転させます。

-
wheelMeshes.forEach((obj) => {
-  obj.rotation.x = time * 3;
-});
-
-

初期化時に、4つ全てのカメラの配列を設定します。

-
const cameras = [
-  { cam: camera, desc: 'detached camera', },
-  { cam: turretCamera, desc: 'on turret looking at target', },
-  { cam: targetCamera, desc: 'near target looking at tank', },
-  { cam: tankCamera, desc: 'above back of tank', },
-];
-
-const infoElem = document.querySelector('#info');
-
-

描画時にカメラを周回させます。

-
const camera = cameras[time * .25 % cameras.length | 0];
-infoElem.textContent = camera.desc;
-
-

- -

-

シーングラフの動作と、使い方のアイデアを、この例から得られればと思います。 -Object3Dノードを作り、物体をその子要素にすることは、three.jsのような3Dエンジンを上手く使うために -重要なステップです。 -思い通りになにかを動かしたり回転させたりすることは、しばしば複雑な数学が必要に見えるかもしれません。 -例えばシーングラフなしで、月の動きを操作したり、車の車体に対して壮太知的に車輪を置いたりすることは、 -とても難しいかもしれません。しかし、シーングラフを使うことで、とても簡単になるのです。

-

次はマテリアルを説明します

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/setup.html b/manual/ja/setup.html deleted file mode 100644 index 032f29b59862eb..00000000000000 --- a/manual/ja/setup.html +++ /dev/null @@ -1,76 +0,0 @@ - - - のセットアップ - - - - - - - - - - - - - -
-
-

のセットアップ

-
-
-
-

これはthree.jsの連載記事の1つです。 -最初の記事はThree.jsの基礎知識でした。 -まだ読んでない人はそこから読んでみるといいかもしれません。

-

先に進む前に開発環境のセットアップの話をする必要があります。特にセキュリティ上の理由から、WebGLはハードディスクから直接画像を扱う事ができません。開発をするためにはWebサーバーを利用する必要があります。幸運な事に開発用のWebサーバーをセットアップし利用する事は非常に簡単です。

-

まず最初にこのサイト全体をこのリンクからダウンロードする事ができます。 -ダウンロードしたらzipファイルをダブルクリックで解凍して下さい。

-

次にシンプルなWebサーバーの1つをダウンロードします。 -ユーザーインターフェースのあるWebサーバーをお望みならServezがあります。

-

- -
- -

-

ファイルを解凍してフォルダを指定し、"Start"をクリックしてからブラウザでhttp://localhost:8080/を表示するか、またはサンプルを閲覧したい場合はhttp://localhost:8080/threejsにアクセスして下さい。

-

Servezのサービスを停止するにはstopを選ぶか、Servezを終了します。

-

コマンドラインが好きな方は(私はそうしてる)、別の方法としてnode.jsを使う事もできます。

-

ダウンロードしてインストールし、コマンドプロンプト / コンソール / ターミナルウィンドウを開きます。WindowsのNode.jsインストーラーで追加した場合、"Node Command Prompt"を選択する必要があります。

-

servezをインストールするには、次のように入力します。

-
npm -g install servez
-

もしMacを使ってる場合は以下を入力します。

-
sudo npm -g install servez
-

次に以下を入力します。

-
servez path/to/folder/where/you/unzipped/files
-

または、私と同じであれば以下を入力します。

-
cd path/to/folder/where/you/unzipped/files
-servez
-

そして、以下のように表示されるはずです。

-

- -
- -

-

ブラウザでhttp://localhost:8080/にアクセスして下さい。

-

もしパスを指定しなかった場合、servezは現在のフォルダをserveします。

-

これらのオプションが好きでない場合、他にもたくさんのシンプルなサーバーがあります

-

これでサーバーのセットアップが完了したのでテクスチャのページに移動しましょう。

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/shadertoy.html b/manual/ja/shadertoy.html deleted file mode 100644 index 52d1e755477ac3..00000000000000 --- a/manual/ja/shadertoy.html +++ /dev/null @@ -1,389 +0,0 @@ - - - とShadertoy - - - - - - - - - - - - - -
-
-

とShadertoy

-
-
-
-

Shadertoyは凄いシェーダーの実験場で有名なサイトです。よく聞かれるのがThree.jsでShadertoyのシェーダーを使う方法です。

-

シェーダーのおもちゃと呼ばれている理由を認識する事が大切です。 -一般的にshadertoyのシェーダーはベストプラクティスではありません。 -むしろ、dwitter (140文字でコードを書く)やjs13kGames (13k以下でゲームを作る)のような楽しいチャレンジです。

-

Shadertoyの場合のパズルは、ピクセルに何か面白いものを描画する関数を書く事です。 -それは楽しいチャレンジで、多くの投稿は凄いシェーダーです。 -しかし、それはベストプラクティスではありません。

-

街全体を描く凄いshadertoyシェーダーで比較してみましょう。

-
- -

上記のシェーダーは私のPCではフルスクリーンだと1秒間に約5フレームで動作します。 -Citiesのようなゲーム: スカイラインとは対照的です。

-
- -

このゲームはテクスチャと三角形の建物を描画する伝統的な技術を使用しており、同じマシンで1秒間に30~60フレーム動作します。

-

それでもthree.jsでShadertoyのシェーダーを使ってみましょう。

-

以下は2019年1月現在でshadertoy.comで"New"を選ぶした時のデフォルトのshadertoyシェーダーです。

-
// By iq: https://www.shadertoy.com/user/iq
-// license: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
-void mainImage( out vec4 fragColor, in vec2 fragCoord )
-{
-    // Normalized pixel coordinates (from 0 to 1)
-    vec2 uv = fragCoord/iResolution.xy;
-
-    // Time varying pixel color
-    vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx+vec3(0,2,4));
-
-    // Output to screen
-    fragColor = vec4(col,1.0);
-}
-
-

シェーダーを理解する上で重要な事の1つは、特殊な型を含む3D数学用に設計されたGLSL (Graphics Library Shading Language)と呼ばれる言語で書かれている事です。 -上記コードでは vec4vec2vec3 のような特殊な型があります。 -vec2 は2つの値を持ち vec3 は3つ、vec4 は4つの値を持ちます。 -たくさんの方法で引数を指定できます。 -例えば以下のように xyzw を使うのが最も一般的です。

-
vec4 v1 = vec4(1.0, 2.0, 3.0, 4.0);
-float v2 = v1.x + v1.y;  // adds 1.0 + 2.0
-
-

JavaScriptとは異なり、GLSLはC/C++のように変数の型を宣言しなければなりません。 -var v = 1.2; ではなく float v = 1.2; となり v は浮動小数点数です。

-

GLSLを詳しく解説するとこの記事で紹介している以上の事ができます。 -GLSLの簡単な概要はこの記事を参照して下さい。 -The Book of Shadersの連載記事の後に続くかもしれません。

-

2019年1月現在ではshadertoy.comフラグメントシェーダー のみ使えます。 -フラグメントシェーダーの役割は、ピクセルの位置が与えられた時にそのピクセルの色を出力します。

-

上記の関数ではシェーダーに fragColor という out パラメーターがあります。 -outoutput の略です。 -outは関数が値を提供する事を意味するパラメーターです。 -これを何か他の色に設定します。

-

また、in (入力用) パラメーターとして fragCoord があります。 -これは描画しようとしているピクセル座標です。座標によって色を決めます。 -描画先のキャンバスが400 x 300ピクセルの場合、この関数は400 x 300回つまり12万回呼ばれます。 -毎回 fragCoord は別のピクセル座標になります。

-

コードに未定義の変数が2つ使用されています。 -1つは iResolution です。これはキャンバスの解像度です。 -キャンバスが400 x 300の場合、iResolution は400, 300になります。 -ピクセル座標が変化すると uv はテクスチャ全体で0.0 〜 1.0の間で変化します。 -正規化された値を使い動作させると物事が単純になる事が多く、shadertoyのシェーダーの大部分は正規化したものから始まります。

-

コードに定義されていないもう1つの変数は iTime です。 -これはページが読み込まれてからの時間を秒単位で表します。

-

シェーダの専門用語では、これらのグローバル変数は ユニフォーム(uniform) 変数と呼ばれています。 -この変数は変更されないため ユニフォーム と呼ばれ、シェーダーの1回のイテレーションから次のイテレーションまで同じ状態を保ちます。 -ここで注意したいのはそれらは全てshadertoy特有のものです。 -これらは オフィシャルな GLSL変数ではありません。 -それらはshadertoy側で作った変数です。

-

Shadertoyのドキュメントを見ると、さらにいくつか特有の定義があります。 -とりあえず、上記のシェーダーで使われている2つを処理するコードを書いてみましょう。

-

まずはキャンバスを塗りつぶす1枚の平面を作ってみましょう。 -まだ読んでいない方は背景とスカイボックスの記事でこのようにしましたので、その例を参考に立方体を削除してみましょう。 -かなり短いコードなので全体を紹介します。

-
function main() {
-  const canvas = document.querySelector('#c');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-  renderer.autoClearColor = false;
-
-  const camera = new THREE.OrthographicCamera(
-    -1, // left
-     1, // right
-     1, // top
-    -1, // bottom
-    -1, // near,
-     1, // far
-  );
-  const scene = new THREE.Scene();
-  const plane = new THREE.PlaneGeometry(2, 2);
-  const material = new THREE.MeshBasicMaterial({
-      color: 'red',
-  });
-  scene.add(new THREE.Mesh(plane, material));
-
-  function resizeRendererToDisplaySize(renderer) {
-    const canvas = renderer.domElement;
-    const width = canvas.clientWidth;
-    const height = canvas.clientHeight;
-    const needResize = canvas.width !== width || canvas.height !== height;
-    if (needResize) {
-      renderer.setSize(width, height, false);
-    }
-    return needResize;
-  }
-
-  function render() {
-    resizeRendererToDisplaySize(renderer);
-
-    renderer.render(scene, camera);
-
-    requestAnimationFrame(render);
-  }
-
-  requestAnimationFrame(render);
-}
-
-main();
-
-

背景とスカイボックスの記事で説明したように、これらのパラメーターを持つ OrthographicCamera と2の長さの平面がキャンバスを塗り潰します。 -平面は赤の MeshBasicMaterial を使用しているため、赤いキャンバスが表示されます。

-

- -

-

これで動作するようになったので、shadertoyシェーダーを追加してみましょう。

-
const fragmentShader = `
-#include <common>
-
-uniform vec3 iResolution;
-uniform float iTime;
-
-// By iq: https://www.shadertoy.com/user/iq
-// license: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
-void mainImage( out vec4 fragColor, in vec2 fragCoord )
-{
-    // Normalized pixel coordinates (from 0 to 1)
-    vec2 uv = fragCoord/iResolution.xy;
-
-    // Time varying pixel color
-    vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx+vec3(0,2,4));
-
-    // Output to screen
-    fragColor = vec4(col,1.0);
-}
-
-void main() {
-  mainImage(gl_FragColor, gl_FragCoord.xy);
-}
-`;
-
-

上記では2つのユニフォーム変数を宣言しました。 -そして、shadertoyからGLSLのシェーダーコードを追加しました。 -最後に mainImage を呼び出し、gl_FragColorgl_FragCoord.xy を渡します。 -gl_FragColor はオフィシャルのグローバル変数でシェーダーが現在のピクセルに設定する色を指定します。 -gl_FragCoord は現在の色を選択しているピクセルの座標を教えてくれる、もう1つのオフィシャルなグローバル変数です。

-

次にシェーダーに値を設定できるようにThree.jsのユニフォームを設定します。

-
const uniforms = {
-  iTime: { value: 0 },
-  iResolution:  { value: new THREE.Vector3() },
-};
-
-

Three.jsの各ユニフォームには value パラメータがあります。 -この値はユニフォームの型と一致してなければなりません。

-

フラグメントシェーダーとユニフォームの両方を ShaderMaterial に渡します。

-
-const material = new THREE.MeshBasicMaterial({
--    color: 'red',
--});
-+const material = new THREE.ShaderMaterial({
-+  fragmentShader,
-+  uniforms,
-+});
-
-

レンダリングする前にユニフォームの値を設定します。

-
-function render() {
-+function render(time) {
-+  time *= 0.001;  // convert to seconds
-
-  resizeRendererToDisplaySize(renderer);
-
-+  const canvas = renderer.domElement;
-+  uniforms.iResolution.value.set(canvas.width, canvas.height, 1);
-+  uniforms.iTime.value = time;
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-
-

注意点: なぜ iResolutionvec3 なのか、 3番目の値に何が入っているのか -shadertoy.comには文書化されてない のでさっぱりわかりません。 -上記では使わないのでとりあえず1にしておきます。 ¯\_(ツ)_/¯

-
-

- -

-

これは新しいシェーダーでShadertoyで見たものと一致します。 -少なくとも2019年1月の時点では😉。上記のシェーダーは何をしているのでしょうか?

-
    -
  • uv は0 〜 1です。
  • -
  • cos(uv.xyx) は3つのコサイン値を vec3 としてます。
  • -
  • 3つとは uv.xuv.yuv.z です。
  • -
  • 時間を加えて、cos(iTime+uv.xyx) でアニメーションさせます。
  • -
  • cos(iTime+uv.xyx+vec3(0,2,4)) のように vec3(0,2,4) を加えると、コサイン波の cos が-1 〜 1にオフセットされます。
  • -
  • そのため 0.5 * 0.5 + cos(....) は -1 <-> 1 〜 0.0 <-> 1.0 に変換され、その結果が現在のピクセルのRGB色として利用されます。
  • -
-

わずかな変更でコサイン波が見やすくなります。 -uv は0 〜 1までの間だけです。 -コサインは2πで繰り返すので、0 〜 40.0を掛けて40にしてみましょう。 -これで6.3回くらいリピートするはずです。

-
-vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx+vec3(0,2,4));
-+vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx*40.0+vec3(0,2,4));
-
-

以下を数えてみると6.3回くらいリピートしています。 -+vec3(0,2,4) で4だけオフセットされているので赤の間に青が見えます。 -それがないと青と赤が完全に重なり、紫になってしまいます。

-

- -

-

シンプルな入力でshadertoyにある -a city canalや -a forest、 -a snailや -a mushroom -をより印象的なものにして見る事ができます。 -上手くいけば三角形からシーンを作る伝統的な方法と比較して、一般的に正しいアプローチではない理由も明確になるでしょう。 -全てのピクセルの色を計算するために多くの計算をしなければならないので、これらの例は非常に遅く実行されます。

-

いくつかのshadertoyシェーダーは、これのようにテクスチャを入力として受け取るものがあります。

-
// By Daedelus: https://www.shadertoy.com/user/Daedelus
-// license: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
-#define TIMESCALE 0.25
-#define TILES 8
-#define COLOR 0.7, 1.6, 2.8
-
-void mainImage( out vec4 fragColor, in vec2 fragCoord )
-{
-    vec2 uv = fragCoord.xy / iResolution.xy;
-    uv.x *= iResolution.x / iResolution.y;
-
-    vec4 noise = texture2D(iChannel0, floor(uv * float(TILES)) / float(TILES));
-    float p = 1.0 - mod(noise.r + noise.g + noise.b + iTime * float(TIMESCALE), 1.0);
-    p = min(max(p * 3.0 - 1.8, 0.1), 2.0);
-
-    vec2 r = mod(uv * float(TILES), 1.0);
-    r = vec2(pow(r.x - 0.5, 2.0), pow(r.y - 0.5, 2.0));
-    p *= 1.0 - pow(min(1.0, 12.0 * dot(r, r)), 2.0);
-
-    fragColor = vec4(COLOR, 1.0) * p;
-}
-
-

シェーダーにテクスチャを渡すのは通常のマテリアルにテクスチャを渡すのと似ていますが、ユニフォームにテクスチャを設定する必要があります。

-

まず、シェーダーにテクスチャのユニフォームを追加します。 -GLSLでは sampler2D と呼ばれています。

-
const fragmentShader = `
-#include <common>
-
-uniform vec3 iResolution;
-uniform float iTime;
-+uniform sampler2D iChannel0;
-
-...
-
-

ここで取り上げたこれのようなテクスチャをロードし、ユニフォームの値を設定します。

-
+const loader = new THREE.TextureLoader();
-+const texture = loader.load('resources/images/bayer.png');
-+texture.minFilter = THREE.NearestFilter;
-+texture.magFilter = THREE.NearestFilter;
-+texture.wrapS = THREE.RepeatWrapping;
-+texture.wrapT = THREE.RepeatWrapping;
-const uniforms = {
-  iTime: { value: 0 },
-  iResolution:  { value: new THREE.Vector3() },
-+  iChannel0: { value: texture },
-};
-
-

- -

-

これまではShadertoy.comで使われているShadertoyシェーダーをそのまま使っていましたが、キャンバスを塗りつぶすように描画しています。 -しかし、そのユースケースだけに限定する必要はありません。 -覚えておくべき重要な事は、shadertoy上で書かれている関数は fragCoord の入力と iResolution を受け取るだけです。 -代わりにテクスチャ座標のような他のものを使えば、他のテクスチャと同じように使えます。 -関数を使ってテクスチャを生成するこの手法は、プロシージャルテクスチャと呼ばれています。

-

上記のシェーダーを変更してみましょう。 -最も簡単なのはthree.jsが通常提供しているテクスチャ座標を取得し、それに iResolution を掛けて fragCoords に渡す事かもしれません。

-

そのためには ヴァリイング(varying) を追加します。 -ヴァリイングとは頂点シェーダーからフラグメントシェーダーに渡される値で、頂点間で補間される値(または変化する値)の事です。 -フラグメントシェーダーで使用するために宣言します。 -Three.jsのテクスチャ座標は uv で前にある vヴァリイング を意味します。

-
...
-
-+varying vec2 vUv;
-
-void main() {
--  mainImage(gl_FragColor, gl_FragCoord.xy);
-+  mainImage(gl_FragColor, vUv * iResolution.xy);
-}
-
-

次に独自の頂点シェーダーも用意する必要があります。 -ここでは最小限のthree.jsの頂点シェーダーを紹介します。 -Three.jsは uvprojectionMatrixmodelViewMatrixposition を宣言し、その値を提供します。

-
const vertexShader = `
-  varying vec2 vUv;
-  void main() {
-    vUv = uv;
-    gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
-  }
-`;
-
-

頂点シェーダーを ShaderMaterial に渡します。

-
const material = new THREE.ShaderMaterial({
-  vertexShader,
-  fragmentShader,
-  uniforms,
-});
-
-

iResolution の値は初期化時には変化しないので、iResolution の値をユニフォームに設定できます。

-
const uniforms = {
-  iTime: { value: 0 },
--  iResolution:  { value: new THREE.Vector3() },
-+  iResolution:  { value: new THREE.Vector3(1, 1, 1) },
-  iChannel0: { value: texture },
-};
-
-

レンダリング時に設定する必要がなくなりました。

-
-const canvas = renderer.domElement;
--uniforms.iResolution.value.set(canvas.width, canvas.height, 1);
-uniforms.iTime.value = time;
-
-

元のカメラとレスポンシブデザインの記事から3つの回転する立方体を設定するコードでコピーバックしました。 -その結果です。

-

- -

-

これで少しでもthree.jsを使ったshadertoyシェーダーの使い方を知ってもらえればと思います。 -繰り返しになりますが、ほとんどのshadertoyシェーダーは実際にパフォーマンスの高い方法で描画する推奨された方法ではなく、 -面白いチャレンジ(単一の機能で全てを描画する)と覚えておく事が重要です。 -それでもshadertoyのシェーダー達は素晴らしく、印象的で、美しく、どのように機能するかコードを見て学ぶ事ができます。

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/shadows.html b/manual/ja/shadows.html deleted file mode 100644 index f07959802763bb..00000000000000 --- a/manual/ja/shadows.html +++ /dev/null @@ -1,434 +0,0 @@ - - - のシャドウ - - - - - - - - - - - - - -
-
-

のシャドウ

-
-
-
-

この記事はThree.jsの連載記事の1つです。 -最初の記事はThree.jsの基礎知識です。 -まだ読んでいない場合、そこから始めると良いかもしれません。 -この記事を読む前に、前回のカメラの記事ライトの記事も読んでおくと良いです。

-

コンピュータ上での影の表現は複雑なトピックになります。 -three.jsで利用できる解決策も含め様々な解決策がありますが、どれもトレードオフがあります。

-

Three.jsは シャドウマップ をデフォルトで使用してます。 -シャドウマップを機能させるには、全てのライトにシャドウを落とし、光源に対して全てのオブジェクトもシャドウを落としてレンダリングします。 -急ぐ必要はないので もう一度読んでみて下さい!

-

つまり、20個のオブジェクトと5個のライトがあり、全てのオブジェクトとライトにシャドウを落としている場合、シーン全体が6回描画されます。 -全てのオブジェクトがライト#1、ライト#2、ライト#3に描画され、最初の5回の描画からデータを使って実際のシーンが描画されます。

-

さらに悪い事に点光源がシャドウを落としている場合、6回もシーン描画しなければならないのです。

-

これらの理由からシャドウを生成するライトをたくさん持つよりも、他の解決策を見つけるのが一般的です。 -一般的な解決策は複数ライトを持つ事ですが、ディレクショナルライトでシャドウを生成する方法があります。

-

もう1つの解決策はライトマップやアンビエントオクルージョンマップを使用し、オフラインでライティングの効果を事前計算する方法もあります。 -静的なライティングのヒントになりますが、少なくともそれは速いです。 -その両方に関しては別の記事で取り上げます。

-

もう1つの解決策はフェイクシャドウです。 -平面を作り影に似たグレースケールのテクスチャを入れて、オブジェクト下の地面の上に描画します。

-

例えばこのテクスチャをフェイクシャドウしてみましょう。

-
- -

前回の記事のコードの一部を使用します。

-

背景色を白に設定してみましょう。

-
const scene = new THREE.Scene();
-+scene.background = new THREE.Color('white');
-
-

同じチェッカーボードの地面を使いますが、今回の地面には照明は必要ないので MeshBasicMaterial を使用します。

-
+const loader = new THREE.TextureLoader();
-
-{
-  const planeSize = 40;
-
--  const loader = new THREE.TextureLoader();
-  const texture = loader.load('resources/images/checker.png');
-  texture.wrapS = THREE.RepeatWrapping;
-  texture.wrapT = THREE.RepeatWrapping;
-  texture.magFilter = THREE.NearestFilter;
-  const repeats = planeSize / 2;
-  texture.repeat.set(repeats, repeats);
-
-  const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
-  const planeMat = new THREE.MeshBasicMaterial({
-    map: texture,
-    side: THREE.DoubleSide,
-  });
-+  planeMat.color.setRGB(1.5, 1.5, 1.5);
-  const mesh = new THREE.Mesh(planeGeo, planeMat);
-  mesh.rotation.x = Math.PI * -.5;
-  scene.add(mesh);
-}
-
-

色が 1.5, 1.5, 1.5 である事に注意して下さい。 -これにより、チェッカーボードのテクスチャの色がそれぞれ1.5倍になります。 -テクスチャの色は 0x808080 と 0xC0C0C0 でミディアムグレーとライトグレーなので、1.5を掛けると白とライトグレーのチェッカーボードになります。

-

シャドウテクスチャを読み込んでみましょう。

-
const shadowTexture = loader.load('resources/images/roundshadow.png');
-
-

各球体と関連するオブジェクトを保持する配列を作成します。

-
const sphereShadowBases = [];
-
-

そして、球体のジオメトリを作ります。

-
const sphereRadius = 1;
-const sphereWidthDivisions = 32;
-const sphereHeightDivisions = 16;
-const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
-
-

フェイクシャドウのための平面のジオメトリも作ります。

-
const planeSize = 1;
-const shadowGeo = new THREE.PlaneGeometry(planeSize, planeSize);
-
-

そして、たくさんの球体を作ります。 -各球体に対して THREE.Object3D を作成し base に格納しシャドウの平面と球体メッシュの両方をbaseの子にします。 -これでbaseを動かすと、球体とシャドウの両方が動きます。 -Zファイティングを防ぐためにシャドウを少し上にします。 -また、depthWrite をfalseにしてシャドウがお互いに混乱しないようにします。 -この2つの問題は別の記事で解説します。 -このシャドウは照明が不要なので MeshBasicMaterial にします。

-

各球体を異なる色相、ベース、球体メッシュ、シャドウのメッシュ、各球体のyの初期位置を保存します。

-
const numSpheres = 15;
-for (let i = 0; i < numSpheres; ++i) {
-  // make a base for the shadow and the sphere
-  // so they move together.
-  const base = new THREE.Object3D();
-  scene.add(base);
-
-  // add the shadow to the base
-  // note: we make a new material for each sphere
-  // so we can set that sphere's material transparency
-  // separately.
-  const shadowMat = new THREE.MeshBasicMaterial({
-    map: shadowTexture,
-    transparent: true,    // so we can see the ground
-    depthWrite: false,    // so we don't have to sort
-  });
-  const shadowMesh = new THREE.Mesh(shadowGeo, shadowMat);
-  shadowMesh.position.y = 0.001;  // so we're above the ground slightly
-  shadowMesh.rotation.x = Math.PI * -.5;
-  const shadowSize = sphereRadius * 4;
-  shadowMesh.scale.set(shadowSize, shadowSize, shadowSize);
-  base.add(shadowMesh);
-
-  // add the sphere to the base
-  const u = i / numSpheres;   // goes from 0 to 1 as we iterate the spheres.
-  const sphereMat = new THREE.MeshPhongMaterial();
-  sphereMat.color.setHSL(u, 1, .75);
-  const sphereMesh = new THREE.Mesh(sphereGeo, sphereMat);
-  sphereMesh.position.set(0, sphereRadius + 2, 0);
-  base.add(sphereMesh);
-
-  // remember all 3 plus the y position
-  sphereShadowBases.push({base, sphereMesh, shadowMesh, y: sphereMesh.position.y});
-}
-
-

2つのライトを設定しました。 -1つは HemisphereLight で強度2にしました。

-
{
-  const skyColor = 0xB1E1FF;  // light blue
-  const groundColor = 0xB97A20;  // brownish orange
-  const intensity = 2;
-  const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
-  scene.add(light);
-}
-
-

もう1つは DirectionalLight で球体はいくつかの定義を得られます。

-
{
-  const color = 0xFFFFFF;
-  const intensity = 1;
-  const light = new THREE.DirectionalLight(color, intensity);
-  light.position.set(0, 10, 5);
-  light.target.position.set(-5, 0, 0);
-  scene.add(light);
-  scene.add(light.target);
-}
-
-

そのままレンダリングしてますが、球体をアニメーション化してみましょう。 -それぞれの球体、シャドウ、baseのセットに対して、 -baseをxz平面内で移動させて Math.abs(Math.sin(time)) で球体を上下に移動させると弾むようなアニメーションします。 -シャドウのマテリアルの不透明度を設定し、各球体が高くなるにつれてシャドウを薄くなるようにしています。

-
function render(time) {
-  time *= 0.001;  // convert to seconds
-
-  ...
-
-  sphereShadowBases.forEach((sphereShadowBase, ndx) => {
-    const {base, sphereMesh, shadowMesh, y} = sphereShadowBase;
-
-    // u is a value that goes from 0 to 1 as we iterate the spheres
-    const u = ndx / sphereShadowBases.length;
-
-    // compute a position for the base. This will move
-    // both the sphere and its shadow
-    const speed = time * .2;
-    const angle = speed + u * Math.PI * 2 * (ndx % 1 ? 1 : -1);
-    const radius = Math.sin(speed - ndx) * 10;
-    base.position.set(Math.cos(angle) * radius, 0, Math.sin(angle) * radius);
-
-    // yOff is a value that goes from 0 to 1
-    const yOff = Math.abs(Math.sin(time * 2 + ndx));
-    // move the sphere up and down
-    sphereMesh.position.y = y + THREE.MathUtils.lerp(-2, 2, yOff);
-    // fade the shadow as the sphere goes up
-    shadowMesh.material.opacity = THREE.MathUtils.lerp(1, .25, yOff);
-  });
-
-  ...
-
-

そして、ここに15種類の跳ねるボールがあります。

-

- -

-

全てのオブジェクトに丸や楕円形のシャドウを使用するのが一般的です。 -異なる形状のシャドウのテクスチャを使用できます。 -シャドウをハードエッジでギザギザにしてもいいかもしれません。 -このタイプのシャドウを使った良い例がどうぶつの森 ポケットキャンプです。 -それぞれのキャラクターがシンプルな丸いシャドウになっており、レンダリングコストが低く効果的です。 -モニュメントバレーでは、メインキャラクターにもこのシャドウが使われているようです。

-

そこでシャドウマップに移りますが、シャドウを落とす事ができるライトが3つあります。 -DirectionalLightPointLightSpotLight です。

-

まずは、ライトの記事のヘルパーの例を参考に DirectionalLight を使ってみましょう。

-

最初にレンダラーのシャドウを有効にします。

-
const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+renderer.shadowMap.enabled = true;
-
-

そして、シャドウを落とすためにライトのcastShadowを有効にします。

-
const light = new THREE.DirectionalLight(color, intensity);
-+light.castShadow = true;
-
-

シーン内の各メッシュを見て、シャドウを落とすか受け取るか決めます。

-

下敷きになっているものはあまり気にせず、平面(地面)はシャドウだけを受けるようにしましょう。

-
const mesh = new THREE.Mesh(planeGeo, planeMat);
-mesh.receiveShadow = true;
-
-

立方体と球体はシャドウを落とし受け取るようにしましょう。

-
const mesh = new THREE.Mesh(cubeGeo, cubeMat);
-mesh.castShadow = true;
-mesh.receiveShadow = true;
-
-...
-
-const mesh = new THREE.Mesh(sphereGeo, sphereMat);
-mesh.castShadow = true;
-mesh.receiveShadow = true;
-
-

これを実行してみます。

-

- -

-

何が起こったのでしょうか? -なぜ影の一部が欠けているのでしょうか?

-

これはシャドウマップは光の視点でシーンをレンダリングし作成されるからです。 -この場合、DirectionalLight にカメラがあり、ターゲットを見ています。 -以前取り上げたカメラと同じように -ライトのシャドウカメラは影がレンダリングされ、内部の領域を定義します。 -上記の例ではその面積が小さすぎます。

-

その領域を可視化するために、ライトのシャドウカメラを取得して CameraHelper をシーンに追加します。

-
const cameraHelper = new THREE.CameraHelper(light.shadow.camera);
-scene.add(cameraHelper);
-
-

これでシャドウが落とされ受け取れる領域が見えるようになりました。

-

- -

-

ターゲットのX値を前後に調整すると、ライトのシャドウカメラボックスの中にあるものだけが影を描画する場所が明確になります。

-

ライトのシャドウカメラを調整するとその箱の大きさを調整できます。

-

ライトのシャドウカメラボックスを調整するためのGUIを追加してみましょう。 -DirectionLight は全ての光が平行な方向に進むので、DirectionalLight はシャドウカメラに OrthographicCamera を使います。 -以前のカメラの記事OrthographicCamera がどのように動作するかを説明しました。

-

OrthographicCamera は、leftrighttopbottomnearfarzoom プロパティでその箱、または 錐台の視点 を定義してる事を思い出して下さい。

-

ここでもlil-guiのヘルパークラスを作ってみましょう。 -オブジェクトと2つのプロパティを渡す DimensionGUIHelper を作ります。 -lil-guiが調整できるプロパティを追加し、2つのプロパティの正と負の値を設定します。 -これを使い leftrightwidth に、updownheight に設定します。

-
class DimensionGUIHelper {
-  constructor(obj, minProp, maxProp) {
-    this.obj = obj;
-    this.minProp = minProp;
-    this.maxProp = maxProp;
-  }
-  get value() {
-    return this.obj[this.maxProp] * 2;
-  }
-  set value(v) {
-    this.obj[this.maxProp] = v /  2;
-    this.obj[this.minProp] = v / -2;
-  }
-}
-
-

カメラの記事で作成した MinMaxGUIHelper を使い nearfar を調整します。

-
const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-gui.add(light, 'intensity', 0, 2, 0.01);
-+{
-+  const folder = gui.addFolder('Shadow Camera');
-+  folder.open();
-+  folder.add(new DimensionGUIHelper(light.shadow.camera, 'left', 'right'), 'value', 1, 100)
-+    .name('width')
-+    .onChange(updateCamera);
-+  folder.add(new DimensionGUIHelper(light.shadow.camera, 'bottom', 'top'), 'value', 1, 100)
-+    .name('height')
-+    .onChange(updateCamera);
-+  const minMaxGUIHelper = new MinMaxGUIHelper(light.shadow.camera, 'near', 'far', 0.1);
-+  folder.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
-+  folder.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
-+  folder.add(light.shadow.camera, 'zoom', 0.01, 1.5, 0.01).onChange(updateCamera);
-+}
-
-

何か値が変更された時は updateCamera 関数を呼び出すようにします。 -ライトやヘルパー、ライトのシャドウカメラやカメラのヘルパーを更新するupdateCamera関数を書いてみましょう。

-
function updateCamera() {
-  // update the light target's matrixWorld because it's needed by the helper
-  light.target.updateMatrixWorld();
-  helper.update();
-  // update the light's shadow camera's projection matrix
-  light.shadow.camera.updateProjectionMatrix();
-  // and now update the camera helper we're using to show the light's shadow camera
-  cameraHelper.update();
-}
-updateCamera();
-
-

これでライトのシャドウカメラにGUIを追加したので値を変更できます。

-

- -

-

widthheight を30ぐらいにすると、シャドウが正しく描画されこのシーンでシャドウにする設定が完全にカバーできました。

-

しかし、ここで疑問が湧いてきます。 -なぜ widthheight に巨大な数値を設定して全てをカバーしないのでしょうか? -widthheight を100にすると、以下のようなものが表示されます。

-
- -

この低解像度のシャドウはどうなっているでしょうか!?

-

この問題はシャドウに関連した設定を意識する必要があります。 -シャドウマップとはシャドウが描かれるテクスチャです。 -このテクスチャはサイズがあります。 -上記で設定したシャドウカメラの領域はその大きさになっています。 -つまり、設定した面積が大きいほどシャドウのブロックが多くなります。

-

シャドウマップのテクスチャの解像度は light.shadow.mapSize.widthlight.shadow.mapSize.height で設定できます。 -デフォルトは512 x 512です。 -大きくするほどメモリを消費し計算が遅くなるので、できるだけ小さく設定しシーンを動作させたいです。 -ライトのシャドウカメラ領域も同様です。 -小さくすると影の見栄えが良くなるので、面積を小さくしてシーンをカバーしましょう。 -各ユーザーのコンピューターには、利用可能な最大テクスチャサイズがある事に注意して下さい。 -renderer.capabilities.maxTextureSizeで利用可能な最大テクスチャサイズがわかります。

- -

SpotLight に切り替えると、ライトのシャドウカメラは PerspectiveCamera になります。 -DirectionalLight のシャドウカメラの設定を手動で行えます。 -ただ、SpotLight のシャドウカメラは SpotLight 自身によって制御されます。 -シャドウカメラの fovSpotLightangle に接続しています。 -aspect はシャドウマップのサイズによって自動的に設定されます。

-
-const light = new THREE.DirectionalLight(color, intensity);
-+const light = new THREE.SpotLight(color, intensity);
-
-

ライトの記事にあった penumbraangle の設定を元に戻しました。

-

- -

- -

そして最後に PointLight でシャドウをつけます。 -PointLight は全方向に光を放つので関連する設定は nearfar だけです。 -それ以外の場合、PointLight のシャドウは、効果的な6つの SpotLight のシャドウになります。 -これは PointLight のシャドウの描画が非常に遅くなります。

-

シーンの周りに箱を置いて、壁や天井にシャドウが見えるようにしてみましょう。 -マテリアルの side プロパティを THREE.BackSide に設定します。 -これで箱の外側ではなく内側をレンダリングしています。 -床のようにシャドウを受けるように設定します。 -また、箱の底が床より少し下になるように箱の位置を設定し、床と箱がズレないようにします。

-
{
-  const cubeSize = 30;
-  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
-  const cubeMat = new THREE.MeshPhongMaterial({
-    color: '#CCC',
-    side: THREE.BackSide,
-  });
-  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
-  mesh.receiveShadow = true;
-  mesh.position.set(0, cubeSize / 2 - 0.1, 0);
-  scene.add(mesh);
-}
-
-

そして、ライトを PointLight に切り替えます。

-
-const light = new THREE.SpotLight(color, intensity);
-+const light = new THREE.PointLight(color, intensity);
-
-....
-
-// so we can easily see where the point light is
-+const helper = new THREE.PointLightHelper(light);
-+scene.add(helper);
-
-

- -

-

GUIの position を使ってライトを移動させると、壁一面にシャドウが落ちます。 -また、nearfar の設定を調整できます。 -near よりも近い時にはシャドウを受け取らず、far よりも遠い時には常にシャドウになっています。

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/textures.html b/manual/ja/textures.html deleted file mode 100644 index 6f14fcca321c46..00000000000000 --- a/manual/ja/textures.html +++ /dev/null @@ -1,542 +0,0 @@ - - - のテクスチャ - - - - - - - - - - - - - -
-
-

のテクスチャ

-
-
-
-

この記事はthree.jsについてのシリーズ記事の一つです。 -最初の記事はThree.jsの基礎知識です。 -まだ読んでない人は、そちらから先に読んでみるといいかもしれません。

-

テクスチャはThree.jsの大きなトピックの一つです。 -どのレベルで説明するといいか100%承知してはいませんが、やってみようと思います。 -Three.jsにはたくさんのトピックがあり、互いに関係しているので、一度に説明するのが難しいのです。 -これがこの記事の内容の早見表です。

- - -

ハロー・テクスチャ

-

テクスチャは一般的にPhotoshopやGIMPのような3rdパーティーのプログラムで最もよく作られる画像です。 -例えば、この画像を立方体に乗せてみましょう。

-
- -
- -

最初の例を修正してみましょう。TextureLoaderを作ることで、必要なことはすべてできます。 -loadを画像のURLを引数にして呼び、colorを設定する代わりに、 -マテリアルのmap属性にその結果を渡してください。

-
+const loader = new THREE.TextureLoader();
-+const texture = loader.load( 'resources/images/wall.jpg' );
-+texture.colorSpace = THREE.SRGBColorSpace;
-
-const material = new THREE.MeshBasicMaterial({
--  color: 0xFF8844,
-+  map: texture,
-});
-
-

MeshBasicMaterialを使っているので、光源が必要ないことに注意してください。

-

- -

-

立方体の各面に異なる6つのテクスチャを貼り付ける

-

立方体の各面に貼り付ける、6つのテクスチャはどのようなものでしょうか。

-
-
- - - -
-
- - - -
-
- -

Meshを作るときに、単に6つのマテリアルを作り、配列として渡します。

-
const loader = new THREE.TextureLoader();
--const texture = loader.load( 'resources/images/wall.jpg' );
--texture.colorSpace = THREE.SRGBColorSpace;
-
--const material = new THREE.MeshBasicMaterial({
--  map: texture,
--});
-+const materials = [
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-1.jpg')}),
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-2.jpg')}),
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-3.jpg')}),
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-4.jpg')}),
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-5.jpg')}),
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-6.jpg')}),
-+];
--const cube = new THREE.Mesh(geometry, material);
-+const cube = new THREE.Mesh(geometry, materials);
-
-+function loadColorTexture( path ) {
-+  const texture = loader.load( path );
-+  texture.colorSpace = THREE.SRGBColorSpace;
-+  return texture;
-+}
-
-

動きました!

-

- -

-

ただし、全ての種類のジオメトリが複数のマテリアルに対応しているわけではないことに注意してください。 -BoxGeometryBoxGeometryは、それぞれの面に6つのマテリアルを使えます。 -ConeGeometryConeGeometryは2つのマテリアルを使うことができ、一つは底面、一つは円錐面に適用されます。 -CylinderGeometryCylinderGeometryは3つのマテリアルを使うことができ、一つは底面、一つは上面、一つは側面に適用されます。 -その他のケースでは、カスタムジオメトリのビルドや読み込み、テクスチャの座標の修正が必要になります。

-

1つのジオメトリに複数の画像を適用したいなら、 -テクスチャアトラスを使うのが、ほかの3Dエンジンでははるかに一般的で、はるかに高性能です。 -テクスチャアトラスは、一つのテクスチャに複数の画像を配置し、ジオメトリの頂点の座標を使って -テクスチャのどの部分がジオメトリのおのおのの三角形に使われるか、選択するものです。

-

テクスチャの座標とはなんでしょうか?ジオメトリ頂点に与えられたデータのことで、 -テクスチャのどの部分がその頂点に対応するか指定するものです。 -カスタムジオメトリの構築を始めるときに説明します。

-

テクスチャの読み込み

-

簡単な方法

-

このサイトのコードのほとんどは、もっとも簡単なテクスチャの読み込み方を使っています。 -TextureLoaderを作り、そのloadメソッドを呼びます。 -これはTextureオブジェクトを返します。

-
const texture = loader.load('resources/images/flower-1.jpg');
-
-

このメソッドを使うと、画像がthree.jsによって非同期的に読み込まれるまで、テクスチャが透明になります。読み込まれた時点で、テクスチャをダウンロードした画像に更新します。

-

この方法では、テクスチャの読み込みを待つ必要がなく、ページをすぐにレンダリングし始めることができるという、大きな利点があります。 -多くのケースでこの方法で問題ありませんが、テクスチャをダウンロードし終えたときにthree.jsに通知してもらうこともできます。

-

テクスチャの読み込みを待つ

-

テクスチャの読み込みを待つために、テクスチャローダーのloadメソッドは、テクスチャの読み込みが終了したときに呼ばれるコールバックを取ります。 -冒頭の例に戻り、このように、Meshを作りシーンに追加する前に、テクスチャの読み込みを待つことができます。

-
const loader = new THREE.TextureLoader();
-loader.load('resources/images/wall.jpg', (texture) => {
-  const material = new THREE.MeshBasicMaterial({
-    map: texture,
-  });
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-  cubes.push(cube);  // add to our list of cubes to rotate
-});
-
-

ブラウザのキャッシュをクリアし、接続が遅くならない限り、違いが分かることはないと思いますが、 -ちゃんとテクスチャが読み込まれるのを待っているので、安心してください。

-

- -

-

複数テクスチャの読み込みを待つ

-

全てのテクスチャが読み込まれたことを待つために、LoadingManagerを使うことができます。 -TextureLoaderを渡すと、onLoad属性がコールバックに設定されます。

-
+const loadManager = new THREE.LoadingManager();
-*const loader = new THREE.TextureLoader(loadManager);
-
-const materials = [
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-1.jpg')}),
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-2.jpg')}),
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-3.jpg')}),
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-4.jpg')}),
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-5.jpg')}),
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-6.jpg')}),
-];
-
-+loadManager.onLoad = () => {
-+  const cube = new THREE.Mesh(geometry, materials);
-+  scene.add(cube);
-+  cubes.push(cube);  // add to our list of cubes to rotate
-+};
-
-

LoadingManageronProgress属性もあり、 -プログレスインジケーターを表示するためのコールバックを設定できます。

-

まず、HTMLにプログレスバーを追加しましょう。

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="loading">
-+    <div class="progress"><div class="progressbar"></div></div>
-+  </div>
-</body>
-
-

そしてCSSにも追加します。

-
#loading {
-    position: fixed;
-    top: 0;
-    left: 0;
-    width: 100%;
-    height: 100%;
-    display: flex;
-    justify-content: center;
-    align-items: center;
-}
-#loading .progress {
-    margin: 1.5em;
-    border: 1px solid white;
-    width: 50vw;
-}
-#loading .progressbar {
-    margin: 2px;
-    background: white;
-    height: 1em;
-    transform-origin: top left;
-    transform: scaleX(0);
-}
-
-

そうすると、コード内でonProgressコールバックのprogressbarのスケールが更新できます。 -これは、最後のアイテムが読み込まれるURL、いま読み込まれているアイテムの数、アイテムの合計数を渡して呼ばれます。

-
+const loadingElem = document.querySelector('#loading');
-+const progressBarElem = loadingElem.querySelector('.progressbar');
-
-loadManager.onLoad = () => {
-+  loadingElem.style.display = 'none';
-  const cube = new THREE.Mesh(geometry, materials);
-  scene.add(cube);
-  cubes.push(cube);  // add to our list of cubes to rotate
-};
-
-+loadManager.onProgress = (urlOfLastItemLoaded, itemsLoaded, itemsTotal) => {
-+  const progress = itemsLoaded / itemsTotal;
-+  progressBarElem.style.transform = `scaleX(${progress})`;
-+};
-
-

キャッシュを削除して低速なコネクションを作らない限りは、プログレスバーを見ることできないかもしれません。

-

- -

-

異なるオリジンからのテクスチャの読み込み

-

異なるサーバーの画像を使うため、そのサーバーは正しいヘッダーを送る必要があります。 -そうしないと、three.jsでその画像を使うことができず、エラーを受け取るでしょう。 -もし皆さんが画像を提供するサーバーを運用しているなら、 -正しいヘッダーを送るを確認してください。 -画像をホスティングしているサーバーに手を入れられず、権限用のヘッダーを送ることができないなら、 -そのサーバーからの画像を使うことはできません。

-

例えば、imgurflickr、そして -githubは全て、ホストしている画像を -three.jsで使うことができるようなヘッダーを送っています。

-

メモリ使用

-

多くの場合、テクスチャはthree.jsアプリの中で最もメモリを使っています。 -一般的にテクスチャは幅 * 高さ * 4 * 1.33バイトのメモリを消費していることを理解するのは重要です。

-

圧縮については言及していないことに注意してください。.jpgイメージを作り、超高圧縮することもできます。 -例えば、家のシーンを作っているとしましょう。家の中には、テーブルがあり、上面に木目のテクスチャを置くことに決めました。

-
- - -

このイメージはたった157kなので、比較的速くダウンロードすることができます。しかし、 -ピクセルだと3024 x 3761の大きさです。 -前述した式によると、

-
3024 * 3761 * 4 * 1.33 = 60505764.5
-

となり、three.jsの60メガのメモリ!を消費するでしょう。 -このようなテクスチャがいくつかあるだけで、メモリリークしてしまうでしょう。

-

この例を持ち出したのは、テクスチャを使用することの隠れたコストを知っているのが重要だからです。 -three.jsでテクスチャを使うためには、テクスチャのデータをGPUに渡し、一般的に非圧縮にしておく必要があります。

-

この話の教訓は、テクスチャをファイルサイズだけでなく、次元も小さくすることです。 -ファイルサイズの小ささ = 高速なダウンロードです。次元の小ささ = 省メモリです。 -では、どのように小さくできるのでしょうか? -できるだけ小さく、そして十分見えるくらいです。

-

JPG vs PNG

-

これは通常のHTMLとほぼ同じで、PNGはロスレス圧縮なので、lossy圧縮のJPGよりも -一般的にダウンロードが遅くなります。 -しかし、PNGは透過性があります。PNGは法線マップや後ほど説明する非画像マップのような非画像データにも適したフォーマットです。

-

WebGLにおいて、JPGがPNGよりも省メモリではないことを覚えておいてください。上記を参照してください。

-

フィルタリングとMIP

-

この16x16のテクスチャを

-
- -

立方体に適用してみます。

-
- -

この立方体をとても小さく描画してみましょう。

-
- -

ふーむ、見えにくいです。小さな立方体を拡大してみましょう。

-
- -

GPUは小さな立方体のどのピクセルにどの色を使うか、どうやって知るのでしょうか? -立方体が小さすぎて1、2ピクセルしかないとしたらどうでしょうか?

-

フィルタリングとはこういうものです。

-

もしフォトショップなら近くの全てのピクセルを平均して、1、2ピクセルの色を見つけます。 -これはとても遅い操作です。GPUはミップマップを使ってこの問題を解決します。

-

MIPはテクスチャのコピーで、ピクセルがブレンドされて次の小さいMIPを作られます。そのため、前のMIPの半分の幅と半分の高さになっています。 -MIPは1x1ピクセルのMIPが得られるまで作られます。 -全てのMIP上の画像はこのようになります。

-
- -

さて、立方体が1、2ピクセルの小ささに描かれたとき、どんな色にするか決めるため、GPUは最も小さなMIPレベルか次に小さいMIPか選ぶことができます。

-

three.jsでは、テクスチャが元の大きさより大きく描かれたときと、小さく描かれたときの両方で、処理の設定を選ぶことができます。

-

テクスチャが元の大きさより大きく描かれたときのフィルタ設定として、texture.magFilter属性にTHREE.NearestFilterTHREE.LinearFilterを設定することができます。 -NearestFilterは元のテクスチャから最も近い1ピクセルを使用するということです。 -低解像度のテクスチャでは、マインクラフトのようにピクセル化された見た目になります。

-

LinearFilterはテクスチャから、色を決めたいピクセルに最も近い4ピクセルを選び、 -実際の点が4つのピクセルからどれだけ離れているかに応じて適切な比率で混ぜ合わせます。

-
-
-
-
Nearest
-
-
-
-
Linear
-
-
- -

元の大きさよりもテクスチャが小さく描画された時のフィルタ設定では、 -texture.minFilter属性を6つの値から一つ設定できます。

-
    -
  • THREE.NearestFilter

    -

    上と同様に、テクスチャの最も近いピクセルを選ぶ。

    -
  • -
  • THREE.LinearFilter

    -

    上と同様に、テクスチャから4ピクセルを選んで混ぜ合わせる。

    -
  • -
  • THREE.NearestMipmapNearestFilter

    -

    適切なMIPを選び、ピクセルを一つ選ぶ。

    -
  • -
  • THREE.NearestMipmapLinearFilter

    -

    2つMIPを選び、それぞれからピクセルを選んで、その2つを混ぜる。

    -
  • -
  • THREE.LinearMipmapNearestFilter

    -

    適切なMIPを選び、4ピクセルを選んで混ぜ合わせる。

    -
  • -
  • THREE.LinearMipmapLinearFilter

    -

    2つMIPを選び、それぞれから4ピクセルを選んで、8つ全部を混ぜ合わせて1ピクセルにする。

    -
  • -
-

ここで6つ全ての設定の例を見せましょう。

-
-
-
-
click to
change
texture
-
-
nearest
-
linear
-
nearest
mipmap
nearest
-
nearest
mipmap
linear
-
linear
mipmap
nearest
-
linear
mipmap
linear
-
-
- -

注意することは、左上と中央上はNearestFilterを使っていて、LinearFilterはMIPを使っていないことです。GPUが元のテクスチャからピクセルを選ぶので、遠くはちらついて見えます。 -左側はたった一つのピクセルが選ばれ、中央は4つのピクセルが選ばれて混ぜ合わされます。しかし、 -良い色の表現には至っていません。 -ほかの4つの中では、右下のLinearMipmapLinearFilterが一番良いです。

-

上の画像をクリックすると、上で使用しているテクスチャと、MIPレベルごとに色が異なるテクスチャが切り替わります。

-
-
-
- -

これで、起きていることが分かりやすいでしょう。 -左上と中央上は、最初のMIPがずっと遠くまで使われているのが分かります。 -右上と中央下は、別のMIPが使われているのがよく分かります。

-

元のテクスチャに切り替えると、右下が滑らか、つまり高品質であることが分かります。 -なぜ常にこのモードにしないのか聞きたいかもしれません。 -最も明らかな理由は、レトロ感を出すために、ピクセル化してほしいとかです。 -次の理由は、8ピクセルを読み込んで混ぜ合わせることは、1ピクセルを読んで混ぜ合わせるよりも遅いことです。 -1つのテクスチャの速度では違いが出るように思えないかもしれませんが、 -記事が進むにつれて、最終的に4、5のテクスチャを一度に持つマテリアルが出てくるでしょう。 -4テクスチャ * 8ピクセル(テクスチャごと)は、どのピクセルを描画するにも32ピクセル探すことになります。 -これはモバイルデバイスで考えるときに特に重要になります。

-

テクスチャの繰り返し、オフセット、回転、ラッピング

-

テクスチャは、繰り返し、オフセット、回転の設定があります。

-

three.jsのデフォルトのテクスチャは繰り返されません。 -テクスチャが繰り返されるかどうかの設定には、2つの属性があります。 -水平方向のラッピングにwrapSと、垂直方向のラッピングにwrapTです。

-

以下のどれかが設定されます:

-
    -
  • THREE.ClampToEdgeWrapping

    -

    それぞれの角の最後のピクセルが永遠に繰り返されます。

    -
  • -
  • THREE.RepeatWrapping

    -

    テクスチャが繰り返されます。

    -
  • -
  • THREE.MirroredRepeatWrapping

    -

    テクスチャの鏡像が取られ、繰り返されます。

    -
  • -
-

例えば、両方向にラッピングすると、

-
someTexture.wrapS = THREE.RepeatWrapping;
-someTexture.wrapT = THREE.RepeatWrapping;
-
-

繰り返しはrepeat属性で設定されます。

-
const timesToRepeatHorizontally = 4;
-const timesToRepeatVertically = 2;
-someTexture.repeat.set(timesToRepeatHorizontally, timesToRepeatVertically);
-
-

テクスチャのオフセットはoffset属性でできます。 -テクスチャは1単位 = 1テクスチャの大きさにオフセットされます。 -言い換えると、0 = オフセットなし、1 = テクスチャ全体の大きさということです。

-
const xOffset = .5;   // offset by half the texture
-const yOffset = .25;  // offset by 1/4 the texture
-someTexture.offset.set(xOffset, yOffset);
-
-

テクスチャの回転は、rotation属性で、ラジアンで指定します。 -同様に center属性で回転の中心を指定します。 -デフォルトは0,0で、左下の角で回転します。 -オフセットと同じように、単位はテクスチャの大きさなので、.5, .5に設定すると、 -テクスチャの中心での回転になります。

-
someTexture.center.set(.5, .5);
-someTexture.rotation = THREE.MathUtils.degToRad(45);
-
-

最初に取り上げたサンプルでこれらの値を試してみましょう。

-

最初に、テクスチャを操作できるように参照を保持しておきます。

-
+const texture = loader.load('resources/images/wall.jpg');
-const material = new THREE.MeshBasicMaterial({
--  map: loader.load('resources/images/wall.jpg');
-+  map: texture,
-});
-
-

ここでも、簡単なインターフェースを提供するためにlil-guiを使います。

-
import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
-
-

以前のlil-guiの例でしたように、lil-guiに度数で操作できるオブジェクトを与え、 -ラジアン単位でプロパティを設定する簡単なクラスを使います。

-
class DegRadHelper {
-  constructor(obj, prop) {
-    this.obj = obj;
-    this.prop = prop;
-  }
-  get value() {
-    return THREE.MathUtils.radToDeg(this.obj[this.prop]);
-  }
-  set value(v) {
-    this.obj[this.prop] = THREE.MathUtils.degToRad(v);
-  }
-}
-
-

"123"といった文字列から123といった数値に変換するクラスも必要です。 -これは、three.jsはwrapSwrapTのようなenumの設定として数値が必要ですが、 -lil-guiはenumに文字列のみを使うためです。

-
class StringToNumberHelper {
-  constructor(obj, prop) {
-    this.obj = obj;
-    this.prop = prop;
-  }
-  get value() {
-    return this.obj[this.prop];
-  }
-  set value(v) {
-    this.obj[this.prop] = parseFloat(v);
-  }
-}
-
-

このクラスを使って、上記設定のための簡単なGUIをセットアップできます。

-
const wrapModes = {
-  'ClampToEdgeWrapping': THREE.ClampToEdgeWrapping,
-  'RepeatWrapping': THREE.RepeatWrapping,
-  'MirroredRepeatWrapping': THREE.MirroredRepeatWrapping,
-};
-
-function updateTexture() {
-  texture.needsUpdate = true;
-}
-
-const gui = new GUI();
-gui.add(new StringToNumberHelper(texture, 'wrapS'), 'value', wrapModes)
-  .name('texture.wrapS')
-  .onChange(updateTexture);
-gui.add(new StringToNumberHelper(texture, 'wrapT'), 'value', wrapModes)
-  .name('texture.wrapT')
-  .onChange(updateTexture);
-gui.add(texture.repeat, 'x', 0, 5, .01).name('texture.repeat.x');
-gui.add(texture.repeat, 'y', 0, 5, .01).name('texture.repeat.y');
-gui.add(texture.offset, 'x', -2, 2, .01).name('texture.offset.x');
-gui.add(texture.offset, 'y', -2, 2, .01).name('texture.offset.y');
-gui.add(texture.center, 'x', -.5, 1.5, .01).name('texture.center.x');
-gui.add(texture.center, 'y', -.5, 1.5, .01).name('texture.center.y');
-gui.add(new DegRadHelper(texture, 'rotation'), 'value', -360, 360)
-  .name('texture.rotation');
-
-

最後に特記することは、もしテクスチャのwrapSwrapTを変えるなら、 -three.jsが設定の適用を知るために、texture.needsUpdateも設定しなければならないことです。ほかの設定は自動的に適用されます。

-

- -

-

これはテクスチャのトピックへの第一歩にすぎません。 -ある時点で、テクスチャの座標や、マテリアルが適用できる別の9種のテクスチャについても説明します。

-

今のところは、光源に進みましょう。

- -

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/tips.html b/manual/ja/tips.html deleted file mode 100644 index 208c571fa70139..00000000000000 --- a/manual/ja/tips.html +++ /dev/null @@ -1,342 +0,0 @@ - - - のTips - - - - - - - - - - - - - -
-
-

のTips

-
-
-
-

この記事では個別の記事を持つには小さすぎるため、three.jsで遭遇するかもしれないいくつかの小さな問題をまとめています。

-
-

-

キャンバスのスクリーンショットを撮る

-

ブラウザではスクリーンショットを撮れる機能が2つあります。 -古いやり方は canvas.toDataURL、新しいやり方は canvas.toBlob です。

-

以下のようなコードを追加するだけで簡単にスクリーンショットを撮れると思うはずです。

-
<canvas id="c"></canvas>
-+<button id="screenshot" type="button">Save...</button>
-
-
const elem = document.querySelector('#screenshot');
-elem.addEventListener('click', () => {
-  canvas.toBlob((blob) => {
-    saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`);
-  });
-});
-
-const saveBlob = (function() {
-  const a = document.createElement('a');
-  document.body.appendChild(a);
-  a.style.display = 'none';
-  return function saveData(blob, fileName) {
-     const url = window.URL.createObjectURL(blob);
-     a.href = url;
-     a.download = fileName;
-     a.click();
-  };
-}());
-
-

以下はレスポンシブデザインの記事の例で、上記のコードにボタンを配置するためのCSSを追加したものです。

-

- -

-

試してみるとこのようなスクリーンショットが出てきました。

-
- -

はい、ただの黒い画像です。

-

お使いのブラウザやOSによっては上手く撮れる事もありますが、一般的には上手く撮れない可能性が高いです。

-

この問題はパフォーマンスと互換性の理由から、デフォルトではブラウザがWebGLキャンバスに描画後に描画バッファをクリアしてしまいます。

-

解決策としてはキャプチャの直前にレンダリングのコードを呼び出す事です。

-

このコードはいくつか調整する必要があります。最初にレンダリングのコードを分離してみましょう。

-
+const state = {
-+  time: 0,
-+};
-
--function render(time) {
--  time *= 0.001;
-+function render() {
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-  cubes.forEach((cube, ndx) => {
-    const speed = 1 + ndx * .1;
--    const rot = time * speed;
-+    const rot = state.time * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
-  renderer.render(scene, camera);
-
--  requestAnimationFrame(render);
-}
-
-+function animate(time) {
-+  state.time = time * 0.001;
-+
-+  render();
-+
-+  requestAnimationFrame(animate);
-+}
-+requestAnimationFrame(animate);
-
-

render は実際にレンダリングする事だけに関係しており、キャンバスをキャプチャする直前に呼び出す事ができます。

-
const elem = document.querySelector('#screenshot');
-elem.addEventListener('click', () => {
-+  render();
-  canvas.toBlob((blob) => {
-    saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`);
-  });
-});
-
-

そして上手く機能するはずです。

-

- -

-

別の解決策については次の項目を参照して下さい。

-
-

-

キャンバスのクリアを防ぐ

-

アニメーションオブジェクトを使って、ユーザーにお絵かきさせたいとしましょう。 -WebGLRenderer 作成時に preserveDrawingBuffer: true を渡す必要があります。 -これによりブラウザがキャンバスをクリアできなくなります。また、three.jsでもキャンバスをクリアしないようにする必要があります。

-
const canvas = document.querySelector('#c');
--const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+const renderer = new THREE.WebGLRenderer({
-+  canvas,
-+  preserveDrawingBuffer: true,
-+  alpha: true,
-+});
-+renderer.autoClearColor = false;
-
-

- -

-

もしお絵かきプログラムを作ろうとしているのであれば、解像度を変更するとブラウザはキャンバスをクリアしてしまうのでこれは解決策にはなりません。 -ディスプレイのサイズに応じて解像度を変えています。ウィンドウのサイズが変わると表示サイズも変わります。 -これにはユーザーが別のタブでファイルをダウンロードし、ブラウザがステータスバーを追加した場合も含まれます。 -また、ユーザーがスマートフォンを回転しブラウザが縦から横に切り替わった時も含まれます。

-

お絵かきプログラムを作りたいのであれば、レンダーターゲットを使用してテクスチャにレンダリングして下さい。

-
-

-

キーボード入力を取得する

-

このチュートリアルではイベントリスナーを canvas にアタッチする事がよくあります。 -多くのイベントが動作しますが、デフォルトでは動作しないキーボードイベントもあります。

-

例えばキーボードイベントを取得するには、キャンバスのtabindexを0以上にします。

-
<canvas tabindex="0"></canvas>
-
-

しかし、これは新たな問題を引き起こします。tabindex が設定されているものはフォーカスがある時にハイライトされます。 -これを修正するにはCSSの擬似クラスであるfocusでoutlineをnoneにします。

-
canvas:focus {
-  outline:none;
-}
-
-

ここに3つのキャンバスがあります。

-
<canvas id="c1"></canvas>
-<canvas id="c2" tabindex="0"></canvas>
-<canvas id="c3" tabindex="1"></canvas>
-
-

最後のキャンバスだけCSSを追加します。

-
#c3:focus {
-    outline: none;
-}
-
-

全てのイベントリスナーに同じイベントリスナーをアタッチしてみましょう。

-
document.querySelectorAll('canvas').forEach((canvas) => {
-  const ctx = canvas.getContext('2d');
-
-  function draw(str) {
-    ctx.clearRect(0, 0, canvas.width, canvas.height);
-    ctx.textAlign = 'center';
-    ctx.textBaseline = 'middle';
-    ctx.fillText(str, canvas.width / 2, canvas.height / 2);
-  }
-  draw(canvas.id);
-
-  canvas.addEventListener('focus', () => {
-    draw('has focus press a key');
-  });
-
-  canvas.addEventListener('blur', () => {
-    draw('lost focus');
-  });
-
-  canvas.addEventListener('keydown', (e) => {
-    draw(`keyCode: ${e.keyCode}`);
-  });
-});
-
-

1つ目のキャンバスがキーボード入力を受け付けない事に注意して下さい。 -2つ目はキーボード入力をうけつけますがハイライトされます。 -3つ目は両方の問題を解決しています。

-

- -

-
-

-

キャンバスを透明にする

-

デフォルトではthree.jsはキャンバスを不透明にします。 -キャンバスを透明にしたい場合は WebGLRenderer 作成時にalpha:trueを指定します。

-
const canvas = document.querySelector('#c');
--const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+const renderer = new THREE.WebGLRenderer({
-+  canvas,
-+  alpha: true,
-+});
-
-

また、プリマルチプライドアルファを使用しない事を指定したいでしょう。

-
const canvas = document.querySelector('#c');
-const renderer = new THREE.WebGLRenderer({
-  canvas,
-  alpha: true,
-+  premultipliedAlpha: false,
-});
-
-

Three.jsのデフォルトではキャンバスはpremultipliedAlpha: trueで出力されますが、マテリアルはpremultipliedAlpha: falseで出力されます。

-

プリマルチプライドアルファを利用するタイミングの理解を深めたいのであれば、この良い記事を参照して下さい。

-

いずれにしても透明なキャンバスを使った簡単な例で設定してみましょう。

-

レスポンシブデザインの記事の例に上記の設定を適用してみました。 -マテリアルも透明感のあるものにしてみました。

-
function makeInstance(geometry, color, x) {
--  const material = new THREE.MeshPhongMaterial({color});
-+  const material = new THREE.MeshPhongMaterial({
-+    color,
-+    opacity: 0.5,
-+  });
-
-...
-
-

次にHTMLを追加してみましょう。

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="content">
-+    <div>
-+      <h1>Cubes-R-Us!</h1>
-+      <p>We make the best cubes!</p>
-+    </div>
-+  </div>
-</body>
-
-

CSSで文字テキストをキャンバスの前に配置します。

-
body {
-    margin: 0;
-}
-#c {
-    width: 100%;
-    height: 100%;
-    display: block;
-+    position: fixed;
-+    left: 0;
-+    top: 0;
-+    z-index: 2;
-+    pointer-events: none;
-}
-+#content {
-+  font-size: 7vw;
-+  font-family: sans-serif;
-+  text-align: center;
-+  width: 100%;
-+  height: 100%;
-+  display: flex;
-+  justify-content: center;
-+  align-items: center;
-+}
-
-

pointer-events: none はマウスやタッチイベントをキャンバスから見えなくするので、その下のテキストを選択できる事に注意して下さい。

-

- -

-
-

-

背景をthree.jsでアニメーションする

-

よくある質問として、three.jsのアニメーションをWebページの背景にするにはどうしたら良いかという事です。

-

明確な2つの方法があります。

-
    -
  • 以下のようにCSSでキャンバスの positionfixed にします。
  • -
-
#c {
- position: fixed;
- left: 0;
- top: 0;
- ...
-}
-
-

先ほどの例でこの正解コードを見る事ができます。z-index を-1にするだけでキューブがテキストの後ろに表示されます。

-

この解決策の小さな欠点はJavaScriptをウェブページと統合する必要があります。 -複雑なウェブページの場合、three.jsの描画部分がページの他の要素と競合しないようにする必要があります。

-
    -
  • iframe を使用する
  • -
-

この解決策はこのサイトのトップページで使用してます。

-

ウェブページへiframeを挿入したとします。例えば

-
<iframe id="background" src="responsive.html">
-<div>
-  Your content goes here.
-</div>
-
-

これは基本的には上記でキャンバスに使用したのと同じコードですが、iframeにはデフォルトでborderがあるので bordernone にする必要があります。

-
#background {
-    position: fixed;
-    width: 100%;
-    height: 100%;
-    left: 0;
-    top: 0;
-    z-index: -1;
-    border: none;
-    pointer-events: none;
-}
-

- -

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/transparency.html b/manual/ja/transparency.html deleted file mode 100644 index d7135211732c25..00000000000000 --- a/manual/ja/transparency.html +++ /dev/null @@ -1,377 +0,0 @@ - - - の透過 - - - - - - - - - - - - - -
-
-

の透過

-
-
-
-

Three.jsでの透過は簡単な方法と難しい方法があります。

-

まずは簡単な方法を見ていきましょう。 -2 x 2 x 2のグリッドに8個のキューブを配置したシーンを作ってみましょう。

-

要求されたレンダリングの記事の例から始めて、3個から8個のキューブになるように修正します。 -まず makeInstance 関数の引数に x, y, z を追加しましょう。

-
-function makeInstance(geometry, color) {
-+function makeInstance(geometry, color, x, y, z) {
-  const material = new THREE.MeshPhongMaterial({color});
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
--  cube.position.x = x;
-+  cube.position.set(x, y, z);
-
-  return cube;
-}
-
-

これで8個のキューブを作れます。

-
+function hsl(h, s, l) {
-+  return (new THREE.Color()).setHSL(h, s, l);
-+}
-
--makeInstance(geometry, 0x44aa88,  0);
--makeInstance(geometry, 0x8844aa, -2);
--makeInstance(geometry, 0xaa8844,  2);
-
-+{
-+  const d = 0.8;
-+  makeInstance(geometry, hsl(0 / 8, 1, .5), -d, -d, -d);
-+  makeInstance(geometry, hsl(1 / 8, 1, .5),  d, -d, -d);
-+  makeInstance(geometry, hsl(2 / 8, 1, .5), -d,  d, -d);
-+  makeInstance(geometry, hsl(3 / 8, 1, .5),  d,  d, -d);
-+  makeInstance(geometry, hsl(4 / 8, 1, .5), -d, -d,  d);
-+  makeInstance(geometry, hsl(5 / 8, 1, .5),  d, -d,  d);
-+  makeInstance(geometry, hsl(6 / 8, 1, .5), -d,  d,  d);
-+  makeInstance(geometry, hsl(7 / 8, 1, .5),  d,  d,  d);
-+}
-
-

カメラの調整もしました。

-
const fov = 75;
-const aspect = 2;  // the canvas default
-const near = 0.1;
--const far = 5;
-+const far = 25;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
--camera.position.z = 4;
-+camera.position.z = 2;
-
-

背景を白に設定します。

-
const scene = new THREE.Scene();
-+scene.background = new THREE.Color('white');
-
-

そして2つ目のライトを追加したので、キューブの全ての側面が照らされるようになりました。

-
-{
-+function addLight(...pos) {
-  const color = 0xFFFFFF;
-  const intensity = 1;
-  const light = new THREE.DirectionalLight(color, intensity);
--  light.position.set(-1, 2, 4);
-+  light.position.set(...pos);
-  scene.add(light);
-}
-+addLight(-1, 2, 4);
-+addLight( 1, -1, -2);
-
-

キューブを透過するには transparentフラグを設定し、opacityを設定する必要があります。opacityは1は完全な不透明、0は完全な透明です。

-
function makeInstance(geometry, color, x, y, z) {
--  const material = new THREE.MeshPhongMaterial({color});
-+  const material = new THREE.MeshPhongMaterial({
-+    color,
-+    opacity: 0.5,
-+    transparent: true,
-+  });
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  cube.position.set(x, y, z);
-
-  return cube;
-}
-
-

8個の透明なキューブになりました。

-

- -

-

上記の動作サンプルの上でドラッグしてビューを回転してみて下さい。

-

簡単に修正できたようですが...よく見て下さい。キューブの裏面がないです。

-
-
no backs
- -

マテリアルの記事でマテリアルのプロパティsideについて学びました。 -THREE.DoubleSide に設定し、各キューブの両面が描画されるようにします。

-
const material = new THREE.MeshPhongMaterial({
-  color,
-  map: loader.load(url),
-  opacity: 0.5,
-  transparent: true,
-+  side: THREE.DoubleSide,
-});
-
-

これが結果です。

-

- -

-

回転させてみて下さい。 -裏面を見ると上手く動作してるように見えますが、時々できない事があります。

-
-
the left back face of each cube is missing
- -

これは3Dオブジェクトの一般的な描画で発生します。 -各ジオメトリの各三角形は一度に1つずつ描画します。 -三角形の各ピクセルが描画されると2つの事が記録されます。 -1つはそのピクセルの色、もう1つはそのピクセルの深さです。 -次の三角形が描画される時、以前に記録された深さよりも各ピクセルの深さが深い場合は描画されません。

-

不透明なものには効果的ですが、透明なものには失敗します。

-

解決策としては、透明なものを並べ替えて手前より奥のものから描画する事です。 -Three.jsでは Mesh のようなオブジェクトに対してこれを行います。 -そうでなければ、最初のサンプルではいくつかのキューブが他のキューブをブロックし、描画に失敗していたでしょう。 -残念ながら、個々の三角形が破綻し非常に遅くなります。

-

キューブには12個の三角形が各面に2個ずつあり、描画される順番はジオメトリで作られた順番と同じです。 -そのため、どちらの方向を見ているかによって、カメラに近い三角形が最初に描画されるかもしれません。 -その場合、裏面の三角形は描画されません。これにより時々裏面が見えない事があります。

-

球体やキューブのような凸状のオブジェクトの場合は、全てのキューブをシーンに2個ずつ追加するのが1つの解決法です。 -裏面の三角形だけを描画するマテリアルで1個、表面の三角形だけを描画するマテリアルで1個です。

-
function makeInstance(geometry, color, x, y, z) {
-+  [THREE.BackSide, THREE.FrontSide].forEach((side) => {
-    const material = new THREE.MeshPhongMaterial({
-      color,
-      opacity: 0.5,
-      transparent: true,
-+      side,
-    });
-
-    const cube = new THREE.Mesh(geometry, material);
-    scene.add(cube);
-
-    cube.position.set(x, y, z);
-+  });
-}
-
-

それは 動作してるように見えます

-

- -

-

この方法はThree.jsの並び順が安定している事が前提です。

-

つまり、最初に side. THREE.BackSide のメッシュを描画し、その後に side.THREE.FrontSide のメッシュを描画し、2つとも同じ位置にある場合です。

-

交差する平面を2つ作ってみましょう(キューブに関連するコードを全て削除後)。 -各面にテクスチャを追加します。

-
const planeWidth = 1;
-const planeHeight = 1;
-const geometry = new THREE.PlaneGeometry(planeWidth, planeHeight);
-
-const loader = new THREE.TextureLoader();
-
-function makeInstance(geometry, color, rotY, url) {
-  const texture = loader.load(url, render);
-  const material = new THREE.MeshPhongMaterial({
-    color,
-    map: texture,
-    opacity: 0.5,
-    transparent: true,
-    side: THREE.DoubleSide,
-  });
-
-  const mesh = new THREE.Mesh(geometry, material);
-  scene.add(mesh);
-
-  mesh.rotation.y = rotY;
-}
-
-makeInstance(geometry, 'pink',       0,             'resources/images/happyface.png');
-makeInstance(geometry, 'lightblue',  Math.PI * 0.5, 'resources/images/hmmmface.png');
-
-

平面は一度に片側しか見れないため、今回は side: THREE.DoubleSide を使えます。 -また、render 関数にテクスチャ読み込み関数を渡し、読み込みの終了時に再レンダリングする事にも注意して下さい。 -このサンプルが連続したレンダリングではなく、要求されたレンダリングになっているためです。

-

- -

-

そしてまたしても同じような問題が発生しています。

-
-
half a face is missing
- -

この解決策は各表面を手動で2つの表面に分割し、交差しないようにします。

-
function makeInstance(geometry, color, rotY, url) {
-+  const base = new THREE.Object3D();
-+  scene.add(base);
-+  base.rotation.y = rotY;
-
-+  [-1, 1].forEach((x) => {
-    const texture = loader.load(url, render);
-+    texture.offset.x = x < 0 ? 0 : 0.5;
-+    texture.repeat.x = .5;
-    const material = new THREE.MeshPhongMaterial({
-      color,
-      map: texture,
-      opacity: 0.5,
-      transparent: true,
-      side: THREE.DoubleSide,
-    });
-
-    const mesh = new THREE.Mesh(geometry, material);
--    scene.add(mesh);
-+    base.add(mesh);
-
--    mesh.rotation.y = rotY;
-+    mesh.position.x = x * .25;
-  });
-}
-
-

どうやって達成するかはあなた次第です。 -Blenderのようなモデリングツールを使用していた場合は、テクスチャ座標を手動で調整すると思います。 -ここでは PlaneGeometry を使用していますが、デフォルトではテクスチャを平面全体に引き伸ばします。 -テクステャの記事で説明したように texture.repeattexture.offset を設定し、 -各平面上の表面のテクスチャから正しい半分が得られるようにテクスチャを拡大縮小したり移動させたりできます。

-

上記のコードでは Object3D を作成し、その親となる2つの平面を作成しています。 -親の Object3D を回転させた方が必要な計算するよりも簡単そうです。

-

- -

-

この解決策は、交点の位置を変えない2つの平面のような単純なものにしか機能しません。

-

テクスチャのあるオブジェクトの場合は、アルファテストを設定するのがもう1つの解決法です。

-

アルファテストでは、指定したアルファの値以下ではピクセルを描画しません。 -ピクセルを全く描画しなければ、上記のような奥行きの問題はなくなります。 -比較的シャープなエッジの効いたテクスチャの場合、これはかなり効果的です。 -例としては、植物や木の葉のテクスチャ、または草のパッチが含まれます。

-

2つの平面で試してみましょう。まずはテクスチャを使い分けてみましょう。 -先ほどのテクスチャは不透明100%でした。この2つは透過を利用しています。

-
-
-
-
- -

交差する2つの平面(分割する前)に戻り、これらのテクスチャを使用して alphaTest を設定してみましょう。

-
function makeInstance(geometry, color, rotY, url) {
-  const texture = loader.load(url, render);
-  const material = new THREE.MeshPhongMaterial({
-    color,
-    map: texture,
--    opacity: 0.5,
-    transparent: true,
-+    alphaTest: 0.5,
-    side: THREE.DoubleSide,
-  });
-
-  const mesh = new THREE.Mesh(geometry, material);
-  scene.add(mesh);
-
-  mesh.rotation.y = rotY;
-}
-
--makeInstance(geometry, 'pink',       0,             'resources/images/happyface.png');
--makeInstance(geometry, 'lightblue',  Math.PI * 0.5, 'resources/images/hmmmface.png');
-+makeInstance(geometry, 'white', 0,             'resources/images/tree-01.png');
-+makeInstance(geometry, 'white', Math.PI * 0.5, 'resources/images/tree-02.png');
-
-

これを実行する前に小さなUIを追加し、alphaTesttransparent の設定をもっと簡単に操作できるようにしましょう。 -今回はシーングラフの記事で紹介したようにlil-guiを使います。

-

まず、シーン内の全てのマテリアルに値を設定するlil-guiのヘルパーを作ります。

-
class AllMaterialPropertyGUIHelper {
-  constructor(prop, scene) {
-    this.prop = prop;
-    this.scene = scene;
-  }
-  get value() {
-    const {scene, prop} = this;
-    let v;
-    scene.traverse((obj) => {
-      if (obj.material && obj.material[prop] !== undefined) {
-        v = obj.material[prop];
-      }
-    });
-    return v;
-  }
-  set value(v) {
-    const {scene, prop} = this;
-    scene.traverse((obj) => {
-      if (obj.material && obj.material[prop] !== undefined) {
-        obj.material[prop] = v;
-        obj.material.needsUpdate = true;
-      }
-    });
-  }
-}
-
-

そして、guiを追加します。

-
const gui = new GUI();
-gui.add(new AllMaterialPropertyGUIHelper('alphaTest', scene), 'value', 0, 1)
-    .name('alphaTest')
-    .onChange(requestRenderIfNotRequested);
-gui.add(new AllMaterialPropertyGUIHelper('transparent', scene), 'value')
-    .name('transparent')
-    .onChange(requestRenderIfNotRequested);
-
-

もちろん、lil-guiを含める必要があります。

-
import * as THREE from 'three';
-import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
-+import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
-
-

その結果がこちらです。

-

- -

-

動作していますが、ズームしてみると1つの平面に白い線が入っているのがわかります。

-
- -

これは前と同じ、深さの問題です。 -その平面が先に描画されていたので、後ろの平面は描画されません。 -これに完全な解決策はありません。 -使用例に合った解決策を見つけるために、alphaTest を調整したり transparent をオフにしたりして下さい。

-

この記事から完璧な透過を目指すのは難しいです。 -問題とトレードオフと回避策があります。

-

例えば、あなたが車を持っていると言います。 -車は通常、四方がフロントガラスになっています。 -上記の並び順の問題を回避したいのであれば、各ウィンドウを独自のオブジェクトにして、three.jsがウィンドウを並び替えて正しい順序で描画できるようにする必要があります。

-

一部の植物や草を作っている場合は、アルファテストでの解決が一般的です。

-

どの解決策を選択するかは、あなたのニーズによります。

- -
-
-
- - - - - - - - diff --git a/manual/ja/voxel-geometry.html b/manual/ja/voxel-geometry.html deleted file mode 100644 index 904c25aa1206af..00000000000000 --- a/manual/ja/voxel-geometry.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Voxel(Minecraft Like) Geometry - - - - - - - - - - - - - -
-
-

Voxel(Minecraft Like) Geometry

-
-
-
-

すいません、この記事はまだ翻訳してません. Translations Welcome! 😄

-

ここに元の英語の記事があります.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/webxr-basics.html b/manual/ja/webxr-basics.html deleted file mode 100644 index f7af921ade6b6d..00000000000000 --- a/manual/ja/webxr-basics.html +++ /dev/null @@ -1,42 +0,0 @@ - - - VR - - - - - - - - - - - - - -
-
-

VR

-
-
-
-

すいません、この記事はまだ翻訳してません. Translations Welcome! 😄

-

ここに元の英語の記事があります.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/webxr-look-to-select.html b/manual/ja/webxr-look-to-select.html deleted file mode 100644 index 9c011fe92bc2e3..00000000000000 --- a/manual/ja/webxr-look-to-select.html +++ /dev/null @@ -1,42 +0,0 @@ - - - VR - Look to Select - - - - - - - - - - - - - -
-
-

VR - Look to Select

-
-
-
-

すいません、この記事はまだ翻訳してません. Translations Welcome! 😄

-

ここに元の英語の記事があります.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ja/webxr-point-to-select.html b/manual/ja/webxr-point-to-select.html deleted file mode 100644 index 85b643373559f9..00000000000000 --- a/manual/ja/webxr-point-to-select.html +++ /dev/null @@ -1,42 +0,0 @@ - - - VR - 3DOF Point to Select - - - - - - - - - - - - - -
-
-

VR - 3DOF Point to Select

-
-
-
-

すいません、この記事はまだ翻訳してません. Translations Welcome! 😄

-

ここに元の英語の記事があります.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/align-html-elements-to-3d.html b/manual/ko/align-html-elements-to-3d.html deleted file mode 100644 index d67dc8342abe25..00000000000000 --- a/manual/ko/align-html-elements-to-3d.html +++ /dev/null @@ -1,648 +0,0 @@ - - - HTML 요소를 3D로 정렬하기 - - - - - - - - - - - - - - -
-
-

HTML 요소를 3D로 정렬하기

-
-
-
-

※ 이 글은 Three.js의 튜토리얼 시리즈로서, 먼저 Three.js의 기본 구조에 관한 글을 읽고 오길 권장합니다.

-

때로 3D 장면에 텍스트를 넣어야 하는 경우가 있을 겁니다. 방법이야 다양하지만 각기 장단점이 있죠.

-
    -
  • 3D 텍스트를 쓴다.

    -

    원시 모델에 관한 글을 보면 TextGeometry로 3D 텍스트를 만든 예제를 찾을 수 있을 겁니다. 로고에 애니메이션을 준다던가 하는 경우에는 유용하지만 상태, 정보, 이름 등을 붙이는 경우라면 오히려 불편하겠죠.

    -
  • -
  • 2D 텍스트로 텍스처를 만들어 렌더링한다.

    -

    캔버스를 텍스처로 활용하기를 보면 캔버스를 텍스처로 활용하는 방법이 나옵니다. 캔버스에 텍스처를 렌더링하고 이 캔버스를 광고판처럼 렌더링하는 거죠. 이 방법의 장점은 텍스트가 3D 장면 안에 포함된다는 겁니다. 컴퓨터 화면에 나타난 텍스트 등을 렌더링하려면 이 방법이 가장 적당하겠죠.

    -
  • -
  • HTML 요소의 위치를 3D에 맞춘다.

    -

    이 방법의 장점은 HTML의 모든 기능을 사용할 수 있다는 겁니다. HTML에 자식 요소를 얼마든지 추가할 수도 있고, CSS로 스타일을 지정할 수도 있고, 실제 텍스트이니 사용자가 직접 선택할 수도 있죠.

    -
  • -
-

이 글에서는 맨 마지막 방법에 대해 다룰 겁니다.

-

간단한 것부터 시작해보죠. 원시 모델 위에 이름표를 붙인 3D 장면을 구현할 겁니다. 예제는 반응형 디자인에 관한 글의 예제를 수정해 쓰도록 하죠.

-

여기에 OrbitControls를 넣습니다. 조명에 관한 글에서 다뤘었죠.

-
import * as THREE from 'three';
-+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-
-
const controls = new OrbitControls(camera, canvas);
-controls.target.set(0, 0, 0);
-controls.update();
-
-

이름표를 담을 HTML 요소도 추가합니다.

-
<body>
--  <canvas id="c"></canvas>
-+  <div id="container">
-+    <canvas id="c"></canvas>
-+    <div id="labels"></div>
-+  </div>
-</body>
-
-

캔버스 요소와 <div id="labels"><div id="container">의 자식으로 둔 뒤 CSS로 서로 겹치게 합니다.

-
#c {
--    width: 100%;
--    height: 100%;
-+    width: 100%;  /* id=container가 사이즈를 결정하도록 합니다. */
-+    height: 100%;
-    display: block;
-}
-+#container {
-+  position: relative;  /* 자식이 이 요소를 기준 삼도록 합니다. */
-+  width: 100%;
-+  height: 100%;
-+  overflow: hidden;
-+}
-+#labels {
-+  position: absolute;  /* 캔버스 위에 자리잡도록 합니다. */
-+  left: 0;             /* id=container 위 왼쪽에 기준하도록 합니다. */
-+  top: 0;
-+  color: white;
-+}
-
-

이름표의 CSS도 작성합니다.

-
#labels > div {
-  position: absolute;  /* 캔버스 위에 자리잡도록 합니다.  */
-  left: 0;             /* 부모의 위 왼쪽에 기준하도록 합니다. */
-  top: 0;
-  cursor: pointer;     /* 포인터가 위에 올라갔을 때 포인터 스타일을 바꿉니다. */
-  font-size: large;
-  user-select: none;   /* 텍스트를 선택할 수 없도록 합니다. */
-  text-shadow:         /* 글자에 검은 테두리를 두릅니다. */
-    -1px -1px 0 #000,
-     0   -1px 0 #000,
-     1px -1px 0 #000,
-     1px  0   0 #000,
-     1px  1px 0 #000,
-     0    1px 0 #000,
-    -1px  1px 0 #000,
-    -1px  0   0 #000;
-}
-#labels > div:hover {
-  color: red;
-}
-
-

자바스크립트 쪽은 수정사항이 많지 않습니다. makeInstance라는 함수에서 정육면체를 만들 때 이름표 요소(element)를 같이 만들도록 바꿉니다.

-
+const labelContainerElem = document.querySelector('#labels');
-
--function makeInstance(geometry, color, x) {
-+function makeInstance(geometry, color, x, name) {
-  const material = new THREE.MeshPhongMaterial({ color });
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  cube.position.x = x;
-
-+  const elem = document.createElement('div');
-+  elem.textContent = name;
-+  labelContainerElem.appendChild(elem);
-
--  return cube;
-+  return { cube, elem };
-}
-
-

이제 각 정육면체를 만들 때 새로운 <div> 요소를 <div id="labels">에 추가할 겁니다. 또한 정육면체(cube)만 반환하는 게 아니라 이름표 요소(label)를 같이 반환합니다.

-

함수를 호출할 때 이름을 같이 넘겨주도록 바꿉니다.

-
const cubes = [
--  makeInstance(geometry, 0x44aa88,  0),
--  makeInstance(geometry, 0x8844aa, -2),
--  makeInstance(geometry, 0xaa8844,  2),
-+  makeInstance(geometry, 0x44aa88,  0, 'Aqua'),
-+  makeInstance(geometry, 0x8844aa, -2, 'Purple'),
-+  makeInstance(geometry, 0xaa8844,  2, 'Gold'),
-];
-
-

이제 렌더링 시 이름표의 위치만 정해주면 끝입니다.

-
const tempV = new THREE.Vector3();
-
-...
-
--cubes.forEach((cube, ndx) => {
-+cubes.forEach((cubeInfo, ndx) => {
-+  const { cube, elem } = cubeInfo;
-  const speed = 1 + ndx * .1;
-  const rot = time * speed;
-  cube.rotation.x = rot;
-  cube.rotation.y = rot;
-
-+  // 정육면체의 중심 좌표를 가져옵니다.
-+  cube.updateWorldMatrix(true, false);
-+  cube.getWorldPosition(tempV);
-+
-+  /**
-+   * 정규화(normalize)된 화면 상의 현재 좌표값을 가져옵니다.
-+   * x와 y의 범위는 -1에서 +1까지로, x = -1은 왼쪽, y = -1은 아래쪽입니다.
-+   **/
-+  tempV.project(camera);
-+
-+  // 정규화된 위치값을 CSS 좌표로 바꿉니다.
-+  const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
-+  const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
-+
-+  // 이름표 요소를 해당 좌표로 옮깁니다.
-+  elem.style.transform = `translate(-50%, -50%) translate(${ x }px,${ y }px)`;
-});
-
-

- -

-

하지만 좀 어색합니다. 몇 가지 개선해야 할 점들이 보이네요.

-

먼저 정육면체들을 돌려 정육면체가 겹치도록 하니 이름표도 겹쳐 보입니다.

-
- -

또 화면을 축소해 정육면체가 절두체(frustum) 밖으로 벗어나게 해도 이름표가 여전히 보입니다.

-

이름표가 겹쳐 보이는 건 피킹에 관한 글에서 썼던 기법을 이용해 해결할 수 있습니다. 이름표 위치에서 RayCaster로 광선을 쏴 처음으로 걸리는 물체가 이름표와 짝이 아니라면 이름표를 보이지 않게 하는 것이죠.

-
const tempV = new THREE.Vector3();
-+const raycaster = new THREE.Raycaster();
-
-...
-
-cubes.forEach((cubeInfo, ndx) => {
-  const {cube, elem} = cubeInfo;
-  const speed = 1 + ndx * .1;
-  const rot = time * speed;
-  cube.rotation.x = rot;
-  cube.rotation.y = rot;
-
-  // 정육면체의 중심 좌표를 가져옵니다.
-  cube.updateWorldMatrix(true, false);
-  cube.getWorldPosition(tempV);
-
-  /**
-   * 정규화(normalize)된 화면 상의 현재 좌표값을 가져옵니다.
-   * x와 y의 범위는 -1에서 +1까지로, x = -1은 왼쪽, y = -1은 아래쪽입니다.
-   **/
-  tempV.project(camera);
-
-+  // raycaster로 이름표의 시점과 만나는 물체를 구합니다.
-+  raycaster.setFromCamera(tempV, camera);
-+  const intersectedObjects = raycaster.intersectObjects(scene.children);
-+  // 짝궁 정육면체가 제일 처음 보이는 물체와 같다면 이름표를 보이게 합니다.
-+  const show = intersectedObjects.length && cube === intersectedObjects[0].object;
-+
-+  if (!show) {
-+    // 이름표를 숨깁니다.
-+    elem.style.display = 'none';
-+  } else {
-+    // 이름표를 보이게 합니다.
-+    elem.style.display = '';
-
-    // 정규화된 위치값을 CSS 좌표로 바꿉니다.
-    const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
-    const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
-
-    // 이름표 요소를 해당 좌표로 옮깁니다.
-    elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
-+  }
-});
-
-

겹치는 문제를 해결했습니다.

-

절두체 밖으로 나갔을 경우의 문제를 해결해봅시다. tempV.z 값으로 정육면체의 중심이 절두체 밖으로 나갔는지를 확인해 해결할 수 있을 것 같네요.

-
-  if (!show) {
-+  if (!show || Math.abs(tempV.z) > 1) {
-    // 이름표를 숨깁니다.
-    elem.style.display = 'none';
-
-

잘 적용된 것 같지만 뭔가 2% 부족합니다. 이전에 계산했던 정규화된 좌표에는 z값이 있고, 이 값의 -1은 카메라의 near, +1은 far을 의미하거든요.

-

- -

-

위 방법은 물체의 중점을 기준으로 이름표의 노출 여부를 계산하기에 실제로 사용하기 어렵습니다. 큰 물체의 경우 중점이 절두체의 바깥에 있더라도 나머지 반쪽은 절두체 안에 있을 수 있으니까요.

-

더 정확한 방법은 해당 물체가 절두체 안에 있는지 확인하는 겁니다. 물론 더 많은 연산이 필요하겠죠. 정육면체 3개 정도야 크게 부담이 되진 않을지 몰라도 물체가 많아지면 그만큼 연산 부담도 커질 겁니다.

-

Three.js에는 물체의 경계 구체(bounding sphere)가 절두체 안에 있는지 계산해주는 함수가 있습니다.

-
// 초기화 단계
-const frustum = new THREE.Frustum();
-const viewProjection = new THREE.Matrix4();
-
-...
-
-// 좌표 확인 전
-camera.updateMatrix();
-camera.updateMatrixWorld();
-camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
-
-...
-
-// 각 mesh마다 좌표를 업데이트합니다.
-someMesh.updateMatrix();
-someMesh.updateMatrixWorld();
-
-viewProjection.multiplyMatrices(
-    camera.projectionMatrix, camera.matrixWorldInverse);
-frustum.setFromProjectionMatrix(viewProjection);
-const inFrustum = frustum.contains(someMesh));
-
-

지금의 예제는 피킹을 사용하기에 성능이 다소 느립니다. 피킹에 관한 글에서 다뤘듯 GPU 기반 피킹을 쓸 수도 있지만 구현하기가 복잡하죠. 어떤 방법을 적용할지는 상황을 보고 판단해야 합니다.

-

또 이름표가 나타나는 순서에도 문제가 있습니다. 현재 예제의 이름표를 더 길게 바꿔보죠.

-
const cubes = [
--  makeInstance(geometry, 0x44aa88,  0, 'Aqua'),
--  makeInstance(geometry, 0x8844aa, -2, 'Purple'),
--  makeInstance(geometry, 0xaa8844,  2, 'Gold'),
-+  makeInstance(geometry, 0x44aa88,  0, 'Aqua Colored Box'),
-+  makeInstance(geometry, 0x8844aa, -2, 'Purple Colored Box'),
-+  makeInstance(geometry, 0xaa8844,  2, 'Gold Colored Box'),
-];
-
-

CSS도 바꿔 줄바꿈이 일어나지 않도록 합니다.

-
#labels > div {
-+  white-space: nowrap;
-
-

이러면 아래와 같은 문제가 나타납니다.

-
- -

위 그림에서 보라색 정육면체는 청록색 정육면체의 뒤에 있지만, 보라색 정육면체의 이름표는 청록색 정육면체의 앞에 있습니다.

-

이름표 요소에 z-index 스타일을 지정해 이 문제를 해결할 수 있습니다. 계산된 위치값에는 -1이 앞, 1이 뒤를 의미하는 z값이 있죠. z-index값은 정수이고 값이 클수록 앞에 위치하니 다음과 같이 하면 제대로 정렬될 겁니다.

-
// 정규화된 위치값을 CSS 좌표로 바꿉니다.
-const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
-const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
-
-// 이름표 요소를 해당 좌표로 옮깁니다.
-elem.style.transform = `translate(-50%, -50%) translate(${ x }px,${ y }px)`;
-
-+// 정렬을 위해 z-index 값을 설정합니다.
-+elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
-
-

z값은 소수점 단위의 값이기에 충분히 큰 숫자를 지정하지 않으면 비슷한 z-index가 지정될 수 있습니다. 또한 이름표가 페이지의 다른 요소를 가리지 않도록 이름표 컨테이너 요소의 z-index를 설정합니다. 이러면 브라우저가 새 쌓임 컨텍스트(stacking context)를 만들어 이름표의 z-index가 아무리 커도 다른 페이지 요소의 위에 올라가지 않도록 할 수 있습니다.

-
#labels {
-  position: absolute;  /* 캔버스 위에 자리잡도록 합니다.  */
-+  z-index: 0;          /* 새 쌓임 컨테스트를 만들어 자식 요소가 페이지의 다른 요소와 엉키지 않도록 합니다. */
-  left: 0;             /* 부모의 위 왼쪽에 기준하도록 합니다. */
-  top: 0;
-  color: white;
-  z-index: 0;
-}
-
-

이제 이름표가 제대로 정렬되어 보일 겁니다.

-

- -

-

여기서 끝낼 수도 있으나 예제를 하나 더 만들어 복잡한 상황에서 발생할 수 있는 문제를 알아보겠습니다. 구글 맵같은 지구본을 만들어 각 나라의 이름을 표시해보도록 하죠.

-

인터넷을 뒤져 각 나라의 영역 데이터를 찾았습니다. 라이선스는 CC-BY-SA네요.

-

개인적으로 코드를 작성해 각 나라의 윤곽선을 만들고 나라의 이름과 위치를 JSON 데이터로 만들었습니다.

-
- -

JSON 데이터는 아래와 같은 형태의 배열입니다.

-
[
-  {
-    "name": "Algeria",
-    "min": [
-      -8.667223,
-      18.976387
-    ],
-    "max": [
-      11.986475,
-      37.091385
-    ],
-    "area": 238174,
-    "lat": 28.163,
-    "lon": 2.632,
-    "population": {
-      "2005": 32854159
-    }
-  },
-  ...
-
-

min, max, lat, lon은 나라의 위도(latitude)와 경도(longitude)를 나타냅니다.

-

데이터를 실제로 사용해봅시다. 다중 요소 렌더링 최적화하기에서 썼던 예제를 기반으로 사용하겠습니다. 물론 많은 요소를 렌더링하는 건 아니지만, 기존 코드에 포함되어 있는 불필요한 렌더링 제거 기법도 그대로 사용할 겁니다.

-

먼저 구체를 만들고 각 나라의 육곽선 텍스처를 입힙니다.

-
{
-  const loader = new THREE.TextureLoader();
-  const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
-  const geometry = new THREE.SphereGeometry(1, 64, 32);
-  const material = new THREE.MeshBasicMaterial({ map: texture });
-  scene.add(new THREE.Mesh(geometry, material));
-}
-
-

다음으로 로더를 만들어 JSON 파일을 불러옵니다.

-
async function loadJSON(url) {
-  const req = await fetch(url);
-  return req.json();
-}
-
-...
-
-let countryInfos;
-async function loadCountryData() {
-  countryInfos = await loadJSON('resources/data/world/country-info.json');
-     ...
-  }
-  requestRenderIfNotRequested();
-}
-loadCountryData();
-
-

불러온 데이터로 각 나라와 이름표를 생성합니다.

-

다중 요소 렌더링 최적화하기에서 씬 그래프를 활용해 지구본의 위도와 경도를 계산했었죠. 이번에도 계산을 단순화하기 위해 이 방법을 그대로 사용합니다. 아래 코드가 어떻게 작동하는가에 대해서는 원본 글을 참고하기 바랍니다.

-
const lonFudge = Math.PI * 1.5;
-const latFudge = Math.PI;
-// 아래 헬퍼 Object3D는 육면체들의 위치 변화를 간단하게 만들어줍니다.
-// lonHelper를 Y축으로 돌려 경도(longitude)를 맞출 수 있습니다.
-const lonHelper = new THREE.Object3D();
-// latHelper를 X축으로 돌려 위도(latitude)를 맞출 수 있습니다.
-const latHelper = new THREE.Object3D();
-lonHelper.add(latHelper);
-// positionHelper는 다른 요소의 기준축을 구체의 끝에 맞추는 역할을 합니다.
-const positionHelper = new THREE.Object3D();
-positionHelper.position.z = 1;
-latHelper.add(positionHelper);
-
-

각 이름표의 좌표를 계산합니다.

-
const labelParentElem = document.querySelector('#labels');
-for (const countryInfo of countryInfos) {
-  const { lat, lon, name } = countryInfo;
-
-  // 헬퍼가 위도와 경도를 가리키게 바꿉니다.
-  lonHelper.rotation.y = THREE.MathUtils.degToRad(lon) + lonFudge;
-  latHelper.rotation.x = THREE.MathUtils.degToRad(lat) + latFudge;
-
-  // 위도와 경도를 구합니다.
-  positionHelper.updateWorldMatrix(true, false);
-  const position = new THREE.Vector3();
-  positionHelper.getWorldPosition(position);
-  countryInfo.position = position;
-
-  // 각 나라마다 텍스트 요소를 추가합니다.
-  const elem = document.createElement('div');
-  elem.textContent = name;
-  labelParentElem.appendChild(elem);
-  countryInfo.elem = elem;
-
-

위 코드는 아까와 비슷하게 각 나라마다 텍스트 요소를 만들었습니다. 이전에는 따로 배열을 두었지만 이번에는 각 나라에 대한 정보가 있는 contryInfos가 있죠. 이 요소의 elem 속성에 이름표 요소를, 지구본 위의 위치값을 position 속성에 지정했습니다.

-

또한 정육면체 예제와 마찬가지로 이름표의 위치를 렌더링 시에 업데이트하도록 합니다.

-
const tempV = new THREE.Vector3();
-
-function updateLabels() {
-  // JSON 파일을 아직 불러오지 않았을 경우
-  if (!countryInfos) {
-    return;
-  }
-
-  for (const countryInfo of countryInfos) {
-    const {position, elem} = countryInfo;
-
-    /**
-     * 정규화(normalize)된 화면 상의 현재 좌표값을 가져옵니다.
-     * x와 y의 범위는 -1에서 +1까지로, x = -1은 왼쪽, y = -1은 아래쪽입니다.
-     **/
-    tempV.copy(position);
-    tempV.project(camera);
-
-    // 정규화된 위치값을 CSS 좌표로 바꿉니다.
-    const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
-    const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
-
-    // 이름표 요소를 해당 좌표로 옮깁니다.
-    elem.style.transform = `translate(-50%, -50%) translate(${ x }px,${ y }px)`;
-
-    // 정렬을 위해 z-index 값을 설정합니다.
-    elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
-  }
-}
-
-

위 코드는 이전 예제와 대체로 비슷합니다. 이름표의 위치를 초기화할 때 미리 계산한다는 것만 다르죠. 지구본이 움직이는 게 아니라 카메라가 움직이기에 이렇게 구현할 수 있습니다.

-

다음으로 렌더링 루프에서 updateLabels 함수를 호출합니다.

-
function render() {
-  renderRequested = false;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-  controls.update();
-
-+  updateLabels();
-
-  renderer.render(scene, camera);
-}
-
-

결과를 보죠.

-

- -

-

이름표가 넘쳐 나네요!

-

문제를 따져보자면 크게 2가지입니다.

-
    -
  1. 카메라 반대편을 바라보는 이름표도 나타난다.

    -
  2. -
  3. 이름표가 너무 많아 읽기가 힘들다.

    -
  4. -
-

문제 #1번은 이전처럼 RayCaster를 사용할 수가 없습니다. 감지할 수 있는 게 구체밖에 없거든요. 대신 특정 나라가 카메라에 보이는지는 확인할 수 있을 겁니다. 지금 예제의 이름표들은 반지름이 1.0인 구체의 바깥면에 있습니다. 이미 구체 단위로 정렬이 된 것이니 계산이 그나마 쉽겠죠.

-
const tempV = new THREE.Vector3();
-+const cameraToPoint = new THREE.Vector3();
-+const cameraPosition = new THREE.Vector3();
-+const normalMatrix = new THREE.Matrix3();
-
-function updateLabels() {
-  // JSON 파일을 아직 불러오지 않았을 경우
-  if (!countryInfos) {
-    return;
-  }
-
-+  const minVisibleDot = 0.2;
-+  // 카메라의 상대 방향을 나타내는 행렬 좌표를 가져옵니다.
-+  normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
-+  // 카메라의 위치를 가져옵니다.
-+  camera.getWorldPosition(cameraPosition);
-  for (const countryInfo of countryInfos) {
-    const { position, elem } = countryInfo;
-
-+    /**
-+     * 카메라의 방향에 기반해 위치를 조정합니다.
-+     * 구체는 중점에 있고 구체의 반지름이 한 칸이기에 아래는
-+     * 카메라에 상대적인 위치 벡터를 반환합니다.
-+     **/
-+    tempV.copy(position);
-+    tempV.applyMatrix3(normalMatrix);
-+
-+    // 카메라로부터 이 위치까지의 거리를 계산합니다.
-+    cameraToPoint.copy(position);
-+    cameraToPoint.applyMatrix4(camera.matrixWorldInverse).normalize();
-+
-+    /**
-+     * 카메라에서 현재 위치의 방향(벡터)값으로 카메라에서 지구본 위 위치값까지의
-+     * 방향값을 구한 뒤, 이 값들로 스칼라곱을 구합니다.
-+     * 1 = 카메라를 바라봄
-+     * 0 = 카메라가 구체를 바라봤을 때 구체의 탄젠트(tangent) 지점에 있음
-+     * < 0 = 다른 쪽을 바라봄
-+     **/
-+    const dot = tempV.dot(cameraToPoint);
-+
-+    // 카메라를 바라보지 않는다면 이름표를 숨깁니다.
-+    if (dot < minVisibleDot) {
-+      elem.style.display = 'none';
-+      continue;
-+    }
-+
-+    // 이름표 요소에 기존 display 스타일이 적용되도록 합니다.
-+    elem.style.display = '';
-
-    /**
-     * 정규화(normalize)된 화면 상의 현재 좌표값을 가져옵니다.
-     * x와 y의 범위는 -1에서 +1까지로, x = -1은 왼쪽, y = -1은 아래쪽입니다.
-     **/
-    tempV.copy(position);
-    tempV.project(camera);
-
-    // 정규화된 위치값을 CSS 좌표로 바꿉니다.
-    const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
-    const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
-
-    // 이름표 요소를 해당 좌표로 옮깁니다.
-    elem.style.transform = `translate(-50%, -50%) translate(${ x }px,${ y }px)`;
-
-    // 정렬을 위해 z-index 값을 설정합니다.
-    elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
-  }
-}
-
-

위 코드는 위치값을 방향(벡터)값으로 써 카메라에 상대적인 위치값을 구합니다. 그리고 카메라에서 지구본 위 해당 위치값까지의 방향값을 구해 이걸로 스칼라곱(dot product)을 구하죠. 이 스칼라곱은 각 벡터 사이의 각도의 코사인값입니다. 이 값이 -1이면 이름표가 카메라를 바라본다는 것이고, 0은 완벽하게 구체 가장자리에 있다는 것, 0보다 크면 구체의 뒤에 있다는 것을 의미하죠.

-
-
-
-
-
- -

위 그림에서 이름표(label)가 바라보는 방향과 카메라가 이름표를 바라보는 방향의 스칼라곱을 확인할 수 있습니다. GUI로 이름표를 돌리면 스칼라곱이 -1.0일 때는 이름표가 완벽히 카메라를 바라보고, 0.0일 때는 두 화살표가 서로 완전히 수직이 됩니다. 90도, 그러니까 스칼라곱이 0보다 클 때는 이름표가 구체 뒤로 가죠.

-

다음으로 #2번 문제, 이름표가 너무 많이 뭉쳐 있는 문제를 해결해봅시다. 일단 이름표의 노출 여부를 결정할 방법을 마련해야 합니다. 한 방법은 땅덩어리가 큰 나라만 우선 보여주는 겁니다. 아까 불러온 데이터에 나라가 차지하는 영역에 대한 min, max 값이 있었죠. 이를 이용해 각 나라의 영역 크기을 계산하면 어떤 나라를 우선적으로 보여줄지 결정할 수 있을 겁니다.

-

먼저 초기화 시에 미리 영역 크기를 계산합니다.

-
const labelParentElem = document.querySelector('#labels');
-for (const countryInfo of countryInfos) {
-  const { lat, lon, min, max, name } = countryInfo;
-
-  // 헬퍼가 위도와 경도를 가리키게 바꿉니다.
-  lonHelper.rotation.y = THREE.MathUtils.degToRad(lon) + lonFudge;
-  latHelper.rotation.x = THREE.MathUtils.degToRad(lat) + latFudge;
-
-  // 위도와 경도를 구합니다.
-  positionHelper.updateWorldMatrix(true, false);
-  const position = new THREE.Vector3();
-  positionHelper.getWorldPosition(position);
-  countryInfo.position = position;
-
-+  // 각 나라의 영영 크기를 계산합니다.
-+  const width = max[0] - min[0];
-+  const height = max[1] - min[1];
-+  const area = width * height;
-+  countryInfo.area = area;
-
-  // 각 나라마다 텍스트 요소를 추가합니다.
-  const elem = document.createElement('div');
-  elem.textContent = name;
-  labelParentElem.appendChild(elem);
-  countryInfo.elem = elem;
-}
-
-

그리고 렌더링 루프에서 영역 크기를 기반으로 이름표의 노출 여부를 결정합니다.

-
+const large = 20 * 20;
-const maxVisibleDot = 0.2;
-// 카메라의 상대 방향을 나타내는 행렬 좌표를 가져옵니다.
-normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
-// 카메라의 위치를 가져옵니다.
-camera.getWorldPosition(cameraPosition);
-for (const countryInfo of countryInfos) {
--  const { position, elem } = countryInfo;
-+  const { position, elem, area } = countryInfo;
-+  // 영역이 특정 값보다 작다면 이름표를 표시하지 않습니다.
-+  if (area < large) {
-+    elem.style.display = 'none';
-+    continue;
-+  }
-
-  ...
-
-

마지막으로 어떤 값이 적당한지 알기 어려우니 이 값을 조정할 수 있도록 GUI를 추가합니다.

-
import * as THREE from 'three';
-import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-+import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
-
-
+const settings = {
-+  minArea: 20,
-+  maxVisibleDot: -0.2,
-+};
-+const gui = new GUI({ width: 300 });
-+gui.add(settings, 'minArea', 0, 50).onChange(requestRenderIfNotRequested);
-+gui.add(settings, 'maxVisibleDot', -1, 1, 0.01).onChange(requestRenderIfNotRequested);
-
-function updateLabels() {
-  if (!countryInfos) {
-    return;
-  }
-
--  const large = 20 * 20;
--  const maxVisibleDot = -0.2;
-+  const large = settings.minArea * settings.minArea;
-  // 카메라의 상대 방향을 나타내는 행렬 좌표를 가져옵니다.
-  normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
-  // 카메라의 위치를 가져옵니다.
-  camera.getWorldPosition(cameraPosition);
-  for (const countryInfo of countryInfos) {
-
-    ...
-
-    // 카메라를 바라보지 않는다면 이름표를 숨깁니다.
--   if (dot > maxVisibleDot) {
-+   if (dot > settings.maxVisibleDot) {
-      elem.style.display = 'none';
-      continue;
-    }
-
-

이제 결과를 보죠.

-

- -

-

지구본을 돌려보면 뒤로 간 이름표가 사라지는 걸 확인할 수 있습니다. minVisibleDot 값을 조정하면 사라지는 지점이 변하고, minArea 값을 조정하면 영역이 더 작은/큰 나라를 볼 수 있죠.

-

작업을 진행할수록 구글 맵을 구현하는 데 굉장히 많은 노력이 들어갔겠구나 하는 생각이 들었습니다. 아마 구글 팀도 여러 기준을 사용했을 겁니다. 사용자의 현재 위치나 기본 언어 설정, 로그인되어 있다면 계정 설정, 또는 인기도나 임의의 우선 순위를 설정해서 보여주는 등 기준으로 삼을 수 있는 건 무궁무진하죠.

-

모든 상황을 다 가정할 수는 없지만, 이 글이 HTML 요소를 3D 요소에 맞춰 정렬하는 데 도움이 되었으면 합니다. 아마 몇 가지 내용은 나중에 바뀔 수도 있으니 참고해주세요.

-

다음 글에서는 더 나아가 지구본 위의 나라를 선택하고 강조해보겠습니다.

-

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/backgrounds.html b/manual/ko/backgrounds.html deleted file mode 100644 index 557334b3593fc5..00000000000000 --- a/manual/ko/backgrounds.html +++ /dev/null @@ -1,256 +0,0 @@ - - - 배경과 하늘 상자 - - - - - - - - - - - - - - -
-
-

배경과 하늘 상자

-
-
-
-

이 시리즈의 예제 대부분은 단색 배경을 사용했습니다.

-

Three.js에서 단순한 배경을 넣는 건 CSS만큼이나 쉽습니다. 반응형 디자인에 관한 -글의 예제에서 2가지만 바꿔주면 되죠.

-

먼저 CSS로 canvas에 배경을 추가합니다.

-
<style>
-body {
-    margin: 0;
-}
-#c {
-    width: 100%;
-    height: 100%;
-    display: block;
-+    background: url(resources/images/daikanyama.jpg) no-repeat center center;
-+    background-size: cover;
-}
-</style>
-
-

그리고 WebGLRendereralpha 옵션을 켜 아무것도 없는 공간은 투명하게 -보이도록 설정합니다.

-
function main() {
-  const canvas = document.querySelector('#c');
--  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+  const renderer = new THREE.WebGLRenderer({
-+    antialias: true,
-+    canvas,
-+    alpha: true,
-+  });
-
-

간단하지 않나요?

-

- -

-

배경이 후처리 효과의 영향을 받게 하려면 -Three.js로 배경을 렌더링해야 합니다.

-

간단히 장면의 배경에 텍스처를 입혀주기만 하면 되죠.

-
const loader = new THREE.TextureLoader();
-const bgTexture = loader.load('resources/images/daikanyama.jpg');
-bgTexture.colorSpace = THREE.SRGBColorSpace;
-scene.background = bgTexture;
-
-

- -

-

배경이 지정되긴 했지만, 화면에 맞춰 늘어났네요.

-

이미지의 일부만 보이도록 repeatoffset 속성을 조정해 문제를 해결해봅시다.

-
function render(time) {
-
-   ...
-+  /**
-+   * 배경 텍스처의 repeat과 offset 속성을 조정해 이미지의 비율이 깨지지
-+   * 않도록 합니다.
-+   * 이미지를 불러오는 데 시간이 걸릴 수 있으니 감안해야 합니다.
-+   **/
-+  const canvasAspect = canvas.clientWidth / canvas.clientHeight;
-+  const imageAspect = bgTexture.image ? bgTexture.image.width / bgTexture.image.height : 1;
-+  const aspect = imageAspect / canvasAspect;
-+
-+  bgTexture.offset.x = aspect > 1 ? (1 - 1 / aspect) / 2 : 0;
-+  bgTexture.repeat.x = aspect > 1 ? 1 / aspect : 1;
-+
-+  bgTexture.offset.y = aspect > 1 ? 0 : (1 - aspect) / 2;
-+  bgTexture.repeat.y = aspect > 1 ? 1 : aspect;
-
-  ...
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-

이제 Three.js가 배경을 렌더링합니다. 그냥 보기에 CSS와 큰 차이는 없지만, -후처리 효과의 영향을 받는다는 점이 다릅니다.

-

- -

-

물론 3D 장면을 만들 때 단순한 배경을 자주 사용하진 않습니다. 대신 주로 일종의 -하늘 상자(skybox)를 사용하죠. 하늘 상자란 말 그대로 하늘을 그려놓은 상자로써, -상자 안에 카메라를 놓으면 마치 배경에 하늘이 있는 것처럼 보이는 효과를 줍니다.

-

일반적으로 육면체에 텍스처를 입히고 안쪽을 렌더링하도록 설정해 하늘 상자를 -구현합니다. 각 면에 수평선처럼 보이는 이미지를 텍스처로 배치하는 거죠(텍스처 -좌표를 이용해). 하늘 구체(sky sphere)나 하늘 돔(sky dom)도 자주 사용하는 -방식입니다. 다시 말해 육면체나 구체를 만들고, 텍스처를 입힌 -뒤, 바깥 면이 아닌 안쪽 면을 렌더링하도록 THREE.BackSide 값을 넣어주면 -됩니다. 그리고 바로 장면(scene)에 추가하거나, 하늘 상자/구체/돔을 담당할 -장면 하나, 다른 요소를 담당할 장면 하나 이렇게 총 2개를 만들 수도 있죠. -OrthographicCamera를 쓸 필요는 없으니 PerspectiveCamera를 그대로 -사용하면 됩니다.

-

다른 방법 중 하나는 큐브맵(Cubemap)입니다. 큐브맵은 정육면체의 한 면 당 -하나, 총 6개의 면을 가진 텍스처로, 텍스처 좌표 대신 중앙에서 바깥쪽을 가리키는 -방향으로 색상값을 결정합니다.

-

아래 6개의 이미지는 캘리포니아 마운틴 뷰에 있는 컴퓨터 역사 박물관에서 찍은 -사진입니다.

-
- - - -
-
- - - -
- -

이들을 CubeTextureLoader로 불러와 장면의 배경으로 설정합니다.

-
{
-  const loader = new THREE.CubeTextureLoader();
-  const texture = loader.load([
-    'resources/images/cubemaps/computer-history-museum/pos-x.jpg',
-    'resources/images/cubemaps/computer-history-museum/neg-x.jpg',
-    'resources/images/cubemaps/computer-history-museum/pos-y.jpg',
-    'resources/images/cubemaps/computer-history-museum/neg-y.jpg',
-    'resources/images/cubemaps/computer-history-museum/pos-z.jpg',
-    'resources/images/cubemaps/computer-history-museum/neg-z.jpg',
-  ]);
-  scene.background = texture;
-}
-
-

이 텍스처는 별도 조정이 필요 없으니 위에서 작성했던 코드를 삭제합니다.

-
function render(time) {
-
-   ...
-
--  /**
--   * 배경 텍스처의 repeat과 offset 속성을 조정해 이미지의 비율이 깨지지
--   * 않도록 합니다.
--   * 이미지를 불러오는 데 시간이 걸릴 수 있으니 감안해야 합니다.
--   **/
--  const canvasAspect = canvas.clientWidth / canvas.clientHeight;
--  const imageAspect = bgTexture.image ? bgTexture.image.width / bgTexture.image.height : 1;
--  const aspect = imageAspect / canvasAspect;
--
--  bgTexture.offset.x = aspect > 1 ? (1 - 1 / aspect) / 2 : 0;
--  bgTexture.repeat.x = aspect > 1 ? 1 / aspect : 1;
--
--  bgTexture.offset.y = aspect > 1 ? 0 : (1 - aspect) / 2;
--  bgTexture.repeat.y = aspect > 1 ? 1 : aspect;
-
-  ...
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-

카메라도 조작이 가능하도록 만듭니다.

-
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-
-
const fov = 75;
-const aspect = 2;  // canvas 기본값
-const near = 0.1;
--const far = 5;
-+const far = 100;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
--camera.position.z = 2;
-+camera.position.z = 3;
-
-+const controls = new OrbitControls(camera, canvas);
-+controls.target.set(0, 0, 0);
-+controls.update();
-
-

예제를 드래그하면 큐브맵이 주위를 둘러싼 게 보일 겁니다.

-

- -

-

다른 방법은 등장방형도법(Equirectangular map)을 이용하는 겁니다. 이런 사진은 -주로 360도 카메라로 촬영합니다.

-

다음 사진이 사이트에서 -가져온 사진입니다.

-
- -

등장방형도법의 사용법은 별로 다르지 않습니다. 먼저 등장방형도법 이미지를 텍스처로 불러온 뒤, 콜백에서 불러온 이미지 텍스처를 WebGLCubeRenderTarget.fromEquirectangularTexture를 호출할 때 넘겨주면 큐브맵(정육면체를 펼친 모양의 텍스처)를 만들 수 있습니다. WebGLCubeRenderTarget을 생성할 때 큐브맵의 크기를 지정해주기만 하면 되죠. 예제의 경우 등장방형도법 이미지의 높이를 넘겨주면 될 겁니다.

-
{
--  const loader = new THREE.CubeTextureLoader();
--  const texture = loader.load([
--    'resources/images/cubemaps/computer-history-museum/pos-x.jpg',
--    'resources/images/cubemaps/computer-history-museum/neg-x.jpg',
--    'resources/images/cubemaps/computer-history-museum/pos-y.jpg',
--    'resources/images/cubemaps/computer-history-museum/neg-y.jpg',
--    'resources/images/cubemaps/computer-history-museum/pos-z.jpg',
--    'resources/images/cubemaps/computer-history-museum/neg-z.jpg',
--  ]);
--  scene.background = texture;
-+  const loader = new THREE.TextureLoader();
-+  const texture = loader.load(
-+    'resources/images/equirectangularmaps/tears_of_steel_bridge_2k.jpg',
-+    () => {
-+      texture.mapping = THREE.EquirectangularReflectionMapping;
-+      texture.colorSpace = THREE.SRGBColorSpace;
-+      scene.background = texture;
-+    });
-}
-
-

어렵지 않게 등장방형도법 텍스처를 구현했습니다.

-

- -

-

등장방형도법은 복잡한 쉐이더를 사용하기에 큐브맵보다 성능이 떨어집니다. -다행히 등장방형도법 이미지를 큐브맵으로 바꾸는 건 그다지 어려운 일이 아니죠. -이 사이트를 이용하면 쉽게 -이미지를 변경할 수 있을 겁니다.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/billboards.html b/manual/ko/billboards.html deleted file mode 100644 index 5450dbac1666e3..00000000000000 --- a/manual/ko/billboards.html +++ /dev/null @@ -1,282 +0,0 @@ - - - 빌보드(Billboards) - - - - - - - - - - - - - - -
-
-

빌보드(Billboards)

-
-
-
-

이전 글에서는 CanvasTexture를 이용해 캐릭터에 이름표/명찰을 붙이는 법을 알아봤습니다. 때로는 이 이름표가 항상 카메라를 향하게 해야 할 경우도 있겠죠. Three.js의 SpriteSpriteMaterial을 사용하면 이걸 쉽게 구현할 수 있습니다.

-

캔버스 텍스처에 관한 글의 명찰 예제를 가져와 여기에 SpriteSpriteMaterial을 사용해봅시다.

-
function makePerson(x, labelWidth, size, name, color) {
-  const canvas = makeLabelCanvas(labelWidth, size, name);
-  const texture = new THREE.CanvasTexture(canvas);
-  // 텍스처용 캔버스는 2D이므로 픽셀이 모자랑 경우 대략적으로
-  // 필터링하게끔 설정합니다.
-  texture.minFilter = THREE.LinearFilter;
-  texture.wrapS = THREE.ClampToEdgeWrapping;
-  texture.wrapT = THREE.ClampToEdgeWrapping;
-
--  const labelMaterial = new THREE.MeshBasicMaterial({
-+  const labelMaterial = new THREE.SpriteMaterial({
-    map: texture,
--    side: THREE.DoubleSide,
-    transparent: true,
-  });
-
-  const root = new THREE.Object3D();
-  root.position.x = x;
-
-  const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
-  root.add(body);
-  body.position.y = bodyHeight / 2;
-
-  const head = new THREE.Mesh(headGeometry, bodyMaterial);
-  root.add(head);
-  head.position.y = bodyHeight + headRadius * 1.1;
-
--  const label = new THREE.Mesh(labelGeometry, labelMaterial);
-+  const label = new THREE.Sprite(labelMaterial);
-  root.add(label);
-  label.position.y = bodyHeight * 4 / 5;
-  label.position.z = bodyRadiusTop * 1.01;
-
-

이제 명찰이 항상 카메라를 바라봅니다.

-

- -

-

하지만 특정 각도에서 보니 명찰이 사람과 겹쳐 보입니다.

-
- -

간단히 명찰의 위치를 옮겨버리면 문제를 해결할 수 있겠죠.

-
+// 명찰의 크기를 조정합니다.
-+const labelBaseScale = 0.01;
-const label = new THREE.Sprite(labelMaterial);
-root.add(label);
--label.position.y = bodyHeight * 4 / 5;
--label.position.z = bodyRadiusTop * 1.01;
-+label.position.y = head.position.y + headRadius + size * labelBaseScale;
-
--// 명찰의 크기를 조정합니다.
--const labelBaseScale = 0.01;
-label.scale.x = canvas.width  * labelBaseScale;
-label.scale.y = canvas.height * labelBaseScale;
-
-

- -

-

추가로 이 빌보드(billboard, 광고판)*에 파사드를 적용할 수 있습니다.

-
-

※ 맥락상 명찰이 갑자기 빌보드가 되는 게 이상하지만, 원문의 흐름 자체를 수정할 수 없어 그대로 번역합니다. 또한 광고판으로 번역할 수도 있으나, 우리나라 사람들에게 광고판이라는 게 잘 와닿지 않을 것이기에 그냥 영문을 그대로 옮겨 표기하였습니다. 역주.

-
-

3D 물체를 그리는 대신 3D 물체의 이미지로 2D 평면을 렌더링하는 기법입니다. 꽤 많은 경우 3D 물체를 그냥 렌더링하는 것보다 빠르죠.

-

예제로 나무가 그리드 형태로 배치된 장면(scene)을 만들어봅시다. 각 나무의 줄기는 원통, 윗부분은 원뿔로 만들겠습니다.

-

먼저 모든 나무가 공통으로 사용할 원뿔, 원통의 geometry와 재질(material)을 만듭니다.

-
const trunkRadius = .2;
-const trunkHeight = 1;
-const trunkRadialSegments = 12;
-const trunkGeometry = new THREE.CylinderGeometry(
-    trunkRadius, trunkRadius, trunkHeight, trunkRadialSegments);
-
-const topRadius = trunkRadius * 4;
-const topHeight = trunkHeight * 2;
-const topSegments = 12;
-const topGeometry = new THREE.ConeGeometry(
-    topRadius, topHeight, topSegments);
-
-const trunkMaterial = new THREE.MeshPhongMaterial({ color: 'brown' });
-const topMaterial = new THREE.MeshPhongMaterial({ color: 'green' });
-
-

다음으로 함수를 하나 만듭니다. 이 함수는 나무 줄기 Mesh와 윗부분 Mesh를 만들고 이 둘을 Object3D의 자식으로 넣는 역할입니다.

-
function makeTree(x, z) {
-  const root = new THREE.Object3D();
-  const trunk = new THREE.Mesh(trunkGeometry, trunkMaterial);
-  trunk.position.y = trunkHeight / 2;
-  root.add(trunk);
-
-  const top = new THREE.Mesh(topGeometry, topMaterial);
-  top.position.y = trunkHeight + topHeight / 2;
-  root.add(top);
-
-  root.position.set(x, 0, z);
-  scene.add(root);
-
-  return root;
-}
-
-

그리고 반복문을 돌려 나무를 그리드 형태로 배치합니다.

-
for (let z = -50; z <= 50; z += 10) {
-  for (let x = -50; x <= 50; x += 10) {
-    makeTree(x, z);
-  }
-}
-
-

땅 역할을 할 평면도 배치합니다.

-
// 땅을 추가합니다.
-{
-  const size = 400;
-  const geometry = new THREE.PlaneGeometry(size, size);
-  const material = new THREE.MeshPhongMaterial({ color: 'gray' });
-  const mesh = new THREE.Mesh(geometry, material);
-  mesh.rotation.x = Math.PI * -0.5;
-  scene.add(mesh);
-}
-
-

배경은 하늘색으로 바꿔줍니다.

-
const scene = new THREE.Scene();
--scene.background = new THREE.Color('white');
-+scene.background = new THREE.Color('lightblue');
-
-

- -

-

나무는 11x11, 총 121그루입니다. 각 나무는 12각형 원뿔과 48각형 원통으로 이루어졌으니, 나무 하나 당 삼각형이 60개인 셈입니다. 121 * 60이면 총 삼각형 7260개네요. 물론 그다지 많은 수는 아니지만 더 세밀한 3차원 나무는 하나당 1000-3000개의 삼각형이 필요할 겁니다. 나무 하나당 3000개면 총 36300개의 삼각형을 그려야 하는 셈이죠.

-

파사드(facade)를 이용하면 삼각형의 양을 줄일 수 있습니다.

-

그래픽 프로그램으로 파사드를 만들 수도 있지만, 코드를 작성해 직접 하나를 만들어봅시다.

-

RenderTarget을 이용해 3D 물체를 텍스처로 바꾸는 코드를 작성합니다. RenderTarget에 렌더링하는 법은 이전 글에서 다루었으니 참고 바랍니다.

-
function frameArea(sizeToFitOnScreen, boxSize, boxCenter, camera) {
-  const halfSizeToFitOnScreen = sizeToFitOnScreen * 0.5;
-  const halfFovY = THREE.MathUtils.degToRad(camera.fov * .5);
-  const distance = halfSizeToFitOnScreen / Math.tan(halfFovY);
-
-  camera.position.copy(boxCenter);
-  camera.position.z += distance;
-
-  // 절두체가 육면체를 포함하도록 near와 far 값을 조정합니다.
-  camera.near = boxSize / 100;
-  camera.far = boxSize * 100;
-
-  camera.updateProjectionMatrix();
-}
-
-function makeSpriteTexture(textureSize, obj) {
-  const rt = new THREE.WebGLRenderTarget(textureSize, textureSize);
-
-  const aspect = 1;  // 렌더 타겟이 정사각형이므로
-  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-
-  scene.add(obj);
-
-  // 물체를 감싼 육면체를 계산합니다.
-  const box = new THREE.Box3().setFromObject(obj);
-
-  const boxSize = box.getSize(new THREE.Vector3());
-  const boxCenter = box.getCenter(new THREE.Vector3());
-
-  // 카메라가 육면체를 감싸도록 설정합니다.
-  const fudge = 1.1;
-  const size = Math.max(...boxSize.toArray()) * fudge;
-  frameArea(size, size, boxCenter, camera);
-
-  renderer.autoClear = false;
-  renderer.setRenderTarget(rt);
-  renderer.render(scene, camera);
-  renderer.setRenderTarget(null);
-  renderer.autoClear = true;
-
-  scene.remove(obj);
-
-  return {
-    position: boxCenter.multiplyScalar(fudge),
-    scale: size,
-    texture: rt.texture,
-  };
-}
-
-

위 예제에서 주의 깊게 봐야하는 부분:

-

위 예제는 코드 밖에서 선언한 시야각 (field of view, fov)을 사용했습니다.

-

.obj 파일 불러오기에서 썼던 방법을 약간 수정해 나무를 감싼 육면체를 계산했습니다.

-

.obj 파일 불러오기에서 썼던 frameArea도 가져왔습니다. 이번에는 주어진 시아갹에 해당 물체가 포함되게 하려면 얼마나 떨어져야 하는지 계산할 때 사용했죠. 그리고 이 결과값으로 카메라를 물체를 감싼 육면체 중심에서 -z축 방향으로 옮겼습니다.

-

절두체의 크기를 1.1 (fudge(속임수))만큼 키워 나무가 렌더 타겟에 완전히 들어가도록 했습니다. 물체가 절두체 안에 있는지 계산한 결과는 물체의 가장자리를 포함하지 않기에 그대로 뒀다면 물체가 카메라 밖으로 벗어나 보였을 겁니다. 카메라가 완전히 물체를 담는 값을 계산할 수도 있지만, 그건 공간 낭비이기도 하기에 단순히 속임수를 사용한 것이죠.

-

그리고 렌더 타겟에 장면을 렌더링한 뒤 나무를 장면에서 제거했습니다.

-

여기서 중요한 건 조명은 따로 넣어야 하나, 다른 요소는 없도록 해야 한다는 겁니다.

-

일단 장면에서 배경색도 제거합니다.

-
const scene = new THREE.Scene();
--scene.background = new THREE.Color('lightblue');
-
-

마지막으로 함수에서 받은 텍스처의 위치와 스케일을 조정해 파사드가 같은 위치에 나타나도록 해야 합니다.

-

먼저 나무를 만들어 위에서 만든 함수에 넘겨줍니다.

-
// 빌보드 텍스처를 만듭니다.
-const tree = makeTree(0, 0);
-const facadeSize = 64;
-const treeSpriteInfo = makeSpriteTexture(facadeSize, tree);
-
-

다음으로 나무 대신 파사드를 그리드 형태로 배치합니다.

-
+function makeSprite(spriteInfo, x, z) {
-+  const { texture, offset, scale } = spriteInfo;
-+  const mat = new THREE.SpriteMaterial({
-+    map: texture,
-+    transparent: true,
-+  });
-+  const sprite = new THREE.Sprite(mat);
-+  scene.add(sprite);
-+  sprite.position.set(
-+      offset.x + x,
-+      offset.y,
-+      offset.z + z);
-+  sprite.scale.set(scale, scale, scale);
-+}
-
-for (let z = -50; z <= 50; z += 10) {
-  for (let x = -50; x <= 50; x += 10) {
--    makeTree(x, z);
-+    makeSprite(treeSpriteInfo, x, z);
-  }
-}
-
-

위 코드에서는 파사드에 위치값과 스케일값을 지정해 원래 나무가 있어야할 위치에 파사드가 나타나도록 했습니다.

-

이제 파사드가 제자리에 위치했으니 배경색을 다시 지정합니다.

-
scene.background = new THREE.Color('lightblue');
-
-

나무 파사드로 이루어진 장면을 완성했습니다.

-

- -

-

아까 만들었던 예제와 비교해보면 꽤 비슷할 겁니다. 예제에서는 저-해상도 텍스처, 64x64 픽셀 텍스처를 사용했기에 파사드가 각져 보입니다. 해상도를 높일 수도 있지만, 파사드는 대게 먼 거리에서 작게 보이는 물체에 사용하기에 저-해상도로 사용해도 그다지 문제가 없습니다. 게다가 몇 픽셀되지도 않는 나무를 렌더링하는 데 낭비되는 자원을 절약할 수 있죠.

-

다른 문제는 나무를 한 각도에서 밖에 볼 수 없다는 겁니다. 이 문제는 대게 더 많은 파사드를 렌더링해 해결할 수 있죠. 예를 들어 8개의 파사드를 만들어 카메라가 물체를 바라보는 각도에 따라 다른 파사드를 보여줄 수 있을 겁니다.

-

파사드를 쓸 지는 전적으로 선택이지만, 이 글이 파사드의 활용에 도움이 되었으면 합니다.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/cameras.html b/manual/ko/cameras.html deleted file mode 100644 index 8118485e47812b..00000000000000 --- a/manual/ko/cameras.html +++ /dev/null @@ -1,536 +0,0 @@ - - - 카메라(Cameras) - - - - - - - - - - - - - - -
-
-

카메라(Cameras)

-
-
-
-

※ 이 글은 Three.js의 튜토리얼 시리즈로서, -먼저 Three.js의 기본 구조에 관한 글을 -읽고 오길 권장합니다.

-

이번 장에서는 카메라(cameras)에 대해 알아보겠습니다. 첫 번째 장에서 -일부 다루긴 했지만, 중요 요소인 만큼 더 자세히 살펴볼 필요가 있습니다.

-

Three.js에서 가장 자주 사용하는 카메라는 여태까지 썼던 PerspectiveCamera(원근 카메라)입니다. -이 카메라는 멀리 있는 물체를 가까이 있는 것보다 상대적으로 작게 보이도록 해주죠.

-

PerspectiveCamera절두체(frustum)를 만듭니다. 절두체(frustum)는 입체(보통 원뿔이나 각뿔)를 -절단하는 하나나 두 평행면 사이의 부분을 -의미하죠. 여기서 입체란 정육면체, 원뿔, 구, 원통, 절두체 등의 3D 요소입니다.

-
-
정육면체(cube)
-
원뿔(cone)
-
구(sphere)
-
원통(cylinder)
-
절두체(frustum)
-
- -

이걸 굳이 언급하는 이유는 글을 쓰는 저도 몇 년 동안 이를 몰랐기 때문입니다. 책이든 인터넷 글이든, -절두체라는 단어를 봤다면 눈이 뒤집어졌을 겁니다. 입체의 이름을 알면 이해하기도, 기억하기도 훨씬 -쉽죠 😅.

-

PerspectiveCamera는 4가지 속성을 바탕으로 절두체를 만듭니다. near는 절두체가 어디서 시작할지 -결정하는 속성이고, far는 절두체의 끝입니다. fov는 시아갹(field of view)으로, near와 카메라의 -거리에 따라 절두체의 높이를 계산해 적용합니다. aspect는 절두체의 너비에 관여하는 비율으로, 절두체의 -너비는 절두체의 높이에 이 비율을 곱한 값입니다.

-

-

이전 장에서 썼던 바닥면, 구체, 정육면체로 이루어진 예제를 다시 사용해 -카메라의 속성을 조정할 수 있도록 만들겠습니다.

-

near 속성은 항상 far 속성보다 커야 하니, 이를 제어할 MinMaxGUIHelper 헬퍼 클래스를 -만들겠습니다. 이 클래스는 lil-gui가 제어할 minmax 속성이 있고, lil-gui가 이를 조정할 -때 지정한 두 가지 속성을 동시에 변경합니다.

-
class MinMaxGUIHelper {
-  constructor(obj, minProp, maxProp, minDif) {
-    this.obj = obj;
-    this.minProp = minProp;
-    this.maxProp = maxProp;
-    this.minDif = minDif;
-  }
-  get min() {
-    return this.obj[this.minProp];
-  }
-  set min(v) {
-    this.obj[this.minProp] = v;
-    this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
-  }
-  get max() {
-    return this.obj[this.maxProp];
-  }
-  set max(v) {
-    this.obj[this.maxProp] = v;
-    this.min = this.min;  // min setter로 작동
-  }
-}
-
-

이제 GUI를 만들어보죠.

-
function updateCamera() {
-  camera.updateProjectionMatrix();
-}
-
-const gui = new GUI();
-gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
-const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
-gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
-gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
-
-

카메라의 속성을 변경할 때마다 카메라의 updateProjectionMatrix -메서드를 호출해야 하므로, updateCamera라는 함수를 만들어 값이 변경될 때마다 함수를 호출하도록 -합니다.

-

- -

-

값을 조정하며 카메라가 어떤 식으로 작동하는지 확인해보세요. aspect는 창의 비율을 그대로 사용하도록 -설정되어 있으므로, 이를 바꾸고 싶다면 예제를 새 창에서 열어 코드를 직접 수정해야 합니다.

-

아직도 카메라가 어떤 식으로 작동하는지 보기 어려운가요? 까짓것 그럼 카메라를 하나 더 만들어보죠. -하나는 위의 예제와 같은 방식의 카메라이고, 다른 하나는 이 카메라의 시야와 절두체를 렌더링해 -카메라가 어떻게 움직이는지 관찰할 수 있도록 만들겠습니다.

-

Three.js의 가위 함수(scissor function)을 이용하면 쉽습니다. 가위 함수를 사용해 양쪽에 -장면 두 개, 카메라 두 개를 렌더링하겠습니다.

-

먼저 HTML과 CSS로 양쪽에 div 요소를 배치합니다. 이러면 각각의 카메라에 OrbitControls를 -두어 이벤트 처리하기도 훨씬 간단합니다.

-
<body>
-  <canvas id="c"></canvas>
-+  <div class="split">
-+     <div id="view1" tabindex="1"></div>
-+     <div id="view2" tabindex="2"></div>
-+  </div>
-</body>
-
-

CSS로 두 div 요소가 canvas 위 양쪽에 자리하게 합니다.

-
.split {
-  position: absolute;
-  left: 0;
-  top: 0;
-  width: 100%;
-  height: 100%;
-  display: flex;
-}
-.split > div {
-  width: 100%;
-  height: 100%;
-}
-
-

카메라의 절두체를 시각화할 CameraHelper를 추가합니다.

-
const cameraHelper = new THREE.CameraHelper(camera);
-
-...
-
-scene.add(cameraHelper);
-
-

다음으로 두 div 요소를 참조합니다.

-
const view1Elem = document.querySelector('#view1');
-const view2Elem = document.querySelector('#view2');
-
-

그리고 기존 OrbitControls가 왼쪽 div 요소의 이벤트에만 반응하도록 설정합니다.

-
-const controls = new OrbitControls(camera, canvas);
-+const controls = new OrbitControls(camera, view1Elem);
-
-

다음으로 PerspectiveCamera와 두 번째 OrbitControls를 추가합니다. 이 OrbitControls를 -두 번째 카메라에 종속시키고, 오른쪽 div 요소의 이벤트에만 반응하도록 합니다.

-
const camera2 = new THREE.PerspectiveCamera(
-  60,  // 시야각(fov)
-  2,   // 비율(aspect)
-  0.1, // near
-  500, // far
-);
-camera2.position.set(40, 10, 30);
-camera2.lookAt(0, 5, 0);
-
-const controls2 = new OrbitControls(camera2, view2Elem);
-controls2.target.set(0, 5, 0);
-controls2.update();
-
-

끝으로 가위 함수를 사용해 화면을 분할하겠습니다. 카메라 각각의 시점에 따라 -장면을 canvas의 양쪽에 나눠 렌더링하게끔 할 것입니다.

-

아래의 함수는 canvas 위에 덮어 씌운 요소의 사각 좌표(rectangle)를 구합니다. -그리고 해당 사각 좌표로 renderer의 화면(viewport)과 가위(scissor)의 값을 -정의한 뒤, 사각 좌표의 가로세로 비율을 반환합니다.

-
function setScissorForElement(elem) {
-  const canvasRect = canvas.getBoundingClientRect();
-  const elemRect = elem.getBoundingClientRect();
-
-  // canvas에 대응하는 사각형을 구하기
-  const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
-  const left = Math.max(0, elemRect.left - canvasRect.left);
-  const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
-  const top = Math.max(0, elemRect.top - canvasRect.top);
-
-  const width = Math.min(canvasRect.width, right - left);
-  const height = Math.min(canvasRect.height, bottom - top);
-
-  // canvas의 일부분만 렌더링하도록 scissor 적용
-  const positiveYUpBottom = canvasRect.height - bottom;
-  renderer.setScissor(left, positiveYUpBottom, width, height);
-  renderer.setViewport(left, positiveYUpBottom, width, height);
-
-  // 비율 반환
-  return width / height;
-}
-
-

이제 이 함수를 사용해 render 함수에서 장면을 두 번 렌더링할 수 있습니다.

-
  function render() {
-
--    if (resizeRendererToDisplaySize(renderer)) {
--      const canvas = renderer.domElement;
--      camera.aspect = canvas.clientWidth / canvas.clientHeight;
--      camera.updateProjectionMatrix();
--    }
-
-+    resizeRendererToDisplaySize(renderer);
-+
-+    // 가위 활성화
-+    renderer.setScissorTest(true);
-+
-+    // 기존 화면 렌더링
-+    {
-+      const aspect = setScissorForElement(view1Elem);
-+
-+      // 비율에 따라 카메라 조정
-+      camera.aspect = aspect;
-+      camera.updateProjectionMatrix();
-+      cameraHelper.update();
-+
-+      // 기존 화면에서 가이드라인(CameraHelper)이 노출되지 않도록 설정
-+      cameraHelper.visible = false;
-+
-+      scene.background.set(0x000000);
-+
-+      // 렌더링
-+      renderer.render(scene, camera);
-+    }
-+
-+    // 두 번째 카메라 렌더링
-+    {
-+      const aspect = setScissorForElement(view2Elem);
-+
-+      // 비율에 따라 카메라 조정
-+      camera2.aspect = aspect;
-+      camera2.updateProjectionMatrix();
-+
-+      // 가이드라인 활성화
-+      cameraHelper.visible = true;
-+
-+      scene.background.set(0x000040);
-+
-+      renderer.render(scene, camera2);
-+    }
-
--    renderer.render(scene, camera);
-
-    requestAnimationFrame(render);
-  }
-
-  requestAnimationFrame(render);
-}
-
-

위 예제에서는 분할된 두 화면을 구별하기 쉽두록 두 번째 화면의 배경을 진한 -파란색으로 칠했습니다.

-

또한 render 함수 안에서 모든 것을 처리하기에, updateCamera 함수도 -제거하였습니다.

-
-function updateCamera() {
--  camera.updateProjectionMatrix();
--}
-
-const gui = new GUI();
--gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
-+gui.add(camera, 'fov', 1, 180);
-const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
--gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
--gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
-+gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near');
-+gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far');
-
-

이제 두 번째 화면에서 첫 번째 카메라의 절두체를 확인할 수 있습니다.

-

- -

-

왼쪽은 기존의 화면과 같고 오른쪽에 왼쪽 카메라의 절두체가 보입니다. -패널에서 near, far, fov 값을 조정하거나 마우스로 화면을 움직여보면, -오른쪽 화면의 절두체 안에 있는 물체만 왼쪽 화면에 노출됨을 확인할 수 -있을 겁니다.

-

누군가 이렇게 물을지도 모르겠네요. 그냥 near를 0.0000000001로 설정하고 far를 -10000000000000로 설정해버리면요? 이러면 모든 게 항상 다 보이지 않나요? 이유를 -설명하자면, GPU는 어떤 물체가 앞에 있거나 다른 물체의 뒤에 있을 때만 정밀도가 -높기 때문입니다. 정밀도는 일정량이 nearfar 사이에 퍼져 있는데, 기본적으로 -카메라에 가까울 수록 정밀도가 높고 멀수록 정밀도가 낮아집니다.

-

현상을 직접 확인해보죠. 위의 예제를 수정해 20개의 구체를 한 줄로 세우겠습니다.

-
{
-  const sphereRadius = 3;
-  const sphereWidthDivisions = 32;
-  const sphereHeightDivisions = 16;
-  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
-  const numSpheres = 20;
-  for (let i = 0; i < numSpheres; ++i) {
-    const sphereMat = new THREE.MeshPhongMaterial();
-    sphereMat.color.setHSL(i * .73, 1, 0.5);
-    const mesh = new THREE.Mesh(sphereGeo, sphereMat);
-    mesh.position.set(-sphereRadius - 1, sphereRadius + 2, i * sphereRadius * -2.2);
-    scene.add(mesh);
-  }
-}
-
-

near 속성을 0.00001로 설정합니다.

-
const fov = 45;
-const aspect = 2;  // canvas 기본값
--const near = 0.1;
-+const near = 0.00001;
-const far = 100;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-
-

그리고 기존의 GUI 코드를 수정해 0.00001의 작은 단위도 설정할 수 있도록 합니다.

-
-gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
-+gui.add(minMaxGUIHelper, 'min', 0.00001, 50, 0.00001).name('near').onChange(updateCamera);
-
-

어떤 결과가 나올 것 같나요?

-

- -

-

이는 z-파이팅(z-fighting, Stitching)의 한 예입니다. 컴퓨터의 GPU가 어떤 픽셀이 -앞이고 어떤 픽셀을 뒤로 보내야할지 결정할 정밀도가 모자를 때 발생하는 현상이죠.

-

위 예제가 어떻게 해도 정상적으로 보인다면, 아래 이미지를 보기 바랍니다.

-
- -

한 가지 해결책은 Three.js에게 픽셀의 앞 뒤를 결정할 때 다른 방법을 쓰도록 설정하는 -것입니다. WebGLRenderer를 생성할 때 logarithmicDepthBuffer 속성을 활성화해주면 -되죠.

-
-const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+const renderer = new THREE.WebGLRenderer({
-+  antialias: true,
-+  canvas,
-+  logarithmicDepthBuffer: true,
-+});
-
-

대게의 경우 정상적으로 보일 겁니다.

-

- -

-

문제가 그대로라면 이 해결책을 쓸 수 없는 건가?라는 의문에 빠질 수 있습니다. -이 해결책이 먹히지 않은 이유는 일부 GPU만 이 기능을 지원하기 때문이죠. 2018년 -9월 기준으로 거의 모든 데스크탑 GPU가 이 기능을 지원하나, 모바일 기기는 대부분 -이 기능을 지원하지 않습니다.

-

이 기능을 쓰지 말아야 하는 또 다른 이유는, 이 기능이 일반적인 해결책보다 훨씬 -성능이 나쁘기 때문입니다.

-

게다가 이 기능을 활성화해도, near를 더 작게, far를 더 멀게 설정하다보면 -결국 같은 현상을 만나게 될 겁니다.

-

이는 항상 nearfar를 설정하는데 많은 공을 들여야 한다는 의미입니다. -near는 대상이 보이는 한 가장 멀게, far도 대상이 보이는 한 카메라와 가장 -가깝게 설정하는 것이 좋죠. 만약 거대한 공간을 렌더링하는 경우, 예를 들어 사람의 -속눈썹과 50km 떨어진 산을 동시에 보이게 하려면 다른 해결책-나중에 다룰지도 모르는-을 -찾아야 합니다. 당장은 nearfar를 적절하게 설정하는 게 중요하다는 것만 -알아둡시다.

-

두 번째로 자주 사용하는 카메라는 OrthographicCamera(정사영 카메라)입니다. -절두체 대신 left, right, top, bottom, near, far로 육면체를 -정의해 사용하죠. 육면체로 화면을 투사하기에 원근 효과가 없습니다.

-

2분할 화면 예제를 수정해 첫 번째 화면을 OrthographicCamera로 바꾸겠습니다.

-

먼저 OrthographicCamera를 만들어보죠.

-
const left = -1;
-const right = 1;
-const top = 1;
-const bottom = -1;
-const near = 5;
-const far = 50;
-const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
-camera.zoom = 0.2;
-
-

먼저 leftbottom을 -1로, righttop을 1로 설정했습니다. 이러면 육면체는 -기본적으로 너비 2칸, 높이 2칸이 되겠죠. 그리고 육면체의 비율을 조정해 lefttop의 -값을 조정할 수 있도록, zoom 속성을 이용해 카메라에 보이는 범위를 조정할 수 있도록 -했습니다.

-

다음으로 zoom 속성을 조정할 GUI를 추가합니다.

-
const gui = new GUI();
-+gui.add(camera, 'zoom', 0.01, 1, 0.01).listen();
-
-

listen 메서드를 호출하면 lil-gui가 변화를 감지합니다. 이렇게 한 이유는 OrbitControls이 -마우스 휠 스크롤을 감지해 zoom 속성을 변경하기 때문이죠.

-

끝으로 왼쪽 화면을 렌더링할 때 OrthographicCamera를 업데이트하도록 설정합니다.

-
{
-  const aspect = setScissorForElement(view1Elem);
-
-  // 요소의 비율에 맞춰 카메라 업데이트
--  camera.aspect = aspect;
-+  camera.left   = -aspect;
-+  camera.right  =  aspect;
-  camera.updateProjectionMatrix();
-  cameraHelper.update();
-
-  // 기존 화면에서 가이드라인(CameraHelper)이 노출되지 않도록 설정
-  cameraHelper.visible = false;
-
-  scene.background.set(0x000000);
-  renderer.render(scene, camera);
-}
-
-

이제 OrthographicCamera가 어떻게 작동하는지 확인할 차례입니다.

-

- -

-

Three.js에서 OrthographicCamera는 주로 2D 요소를 표현하기 위해 사용합니다. -카메라에 얼마나 많은 요소를 보여줄지만 결정하면 되죠. 만약 canvas의 1픽셀을 -카메라의 한 칸과 같은 크기로 지정하고 싶다면...

-

중점을 장면의 중심에 두고 1 픽셀을 Three.js의 한 칸으로 만들 수 있습니다.

-
camera.left = -canvas.width / 2;
-camera.right = canvas.width / 2;
-camera.top = canvas.height / 2;
-camera.bottom = -canvas.height / 2;
-camera.near = -1;
-camera.far = 1;
-camera.zoom = 1;
-
-

2D canvas처럼 중점을 상단 왼쪽에 두려면 다음과 같이 설정할 수 있죠.

-
camera.left = 0;
-camera.right = canvas.width;
-camera.top = 0;
-camera.bottom = canvas.height;
-camera.near = -1;
-camera.far = 1;
-camera.zoom = 1;
-
-

중점이 상단 왼쪽에 있을 경우의 좌표는 2D canvas처럼 0, 0입니다.

-

한 번 만들어보죠! 먼저 카메라를 설정합니다.

-
const left = 0;
-const right = 300;  // canvas 기본 크기
-const top = 0;
-const bottom = 150;  // canvas 기본 크기
-const near = -1;
-const far = 1;
-const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
-camera.zoom = 1;
-
-

다음으로 평면(plane) 6개를 만들어 각각 다른 텍스처를 적용하겠습니다. -각 평면마다 THREE.Object3D 인스턴스를 만들어 평면의 부모로 설정합니다. -이러면 중점을 0, 0, 상단 좌측으로 지정해 좌표를 지정하기가 쉽습니다.

-
const loader = new THREE.TextureLoader();
-const textures = [
-  loader.load('resources/images/flower-1.jpg'),
-  loader.load('resources/images/flower-2.jpg'),
-  loader.load('resources/images/flower-3.jpg'),
-  loader.load('resources/images/flower-4.jpg'),
-  loader.load('resources/images/flower-5.jpg'),
-  loader.load('resources/images/flower-6.jpg'),
-];
-const planeSize = 256;
-const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
-const planes = textures.map((texture) => {
-  const planePivot = new THREE.Object3D();
-  scene.add(planePivot);
-  texture.magFilter = THREE.NearestFilter;
-  const planeMat = new THREE.MeshBasicMaterial({
-    map: texture,
-    side: THREE.DoubleSide,
-  });
-  const mesh = new THREE.Mesh(planeGeo, planeMat);
-  planePivot.add(mesh);
-  // 평면을 움직여 상단 좌측이 중점이 되도록 설정
-  mesh.position.set(planeSize / 2, planeSize / 2, 0);
-  return planePivot;
-});
-
-

그리고 render 함수 안에 canvas의 사이즈가 변경되었을 때 카메라를 업데이트하는 -코드를 추가합니다.

-
function render() {
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    camera.right = canvas.width;
-    camera.bottom = canvas.height;
-    camera.updateProjectionMatrix();
-  }
-
-  ...
-
-

planesTHREE.Mesh, 평면의 배열입니다. 시간값을 기반으로 이 평면이 따로 -움직이도록 하겠습니다.

-
function render(time) {
-  time *= 0.001;  // 초 단위로 변경
-
-  ...
-
-  const distAcross = Math.max(20, canvas.width - planeSize);
-  const distDown = Math.max(20, canvas.height - planeSize);
-
-  // total distance to move across and back
-  const xRange = distAcross * 2;
-  const yRange = distDown * 2;
-  const speed = 180;
-
-  planes.forEach((plane, ndx) => {
-    // compute a unique time for each plane
-    const t = time * speed + ndx * 300;
-
-    // get a value between 0 and range
-    const xt = t % xRange;
-    const yt = t % yRange;
-
-    // set our position going forward if 0 to half of range
-    // and backward if half of range to range
-    const x = xt < distAcross ? xt : xRange - xt;
-    const y = yt < distDown   ? yt : yRange - yt;
-
-    plane.position.set(x, y, 0);
-  });
-
-  renderer.render(scene, camera);
-
-

이미지가 완벽하게 가장자리에서 튕기는 것을 확인할 수 있을 겁니다. 2D canvas에서 -픽셀값을 이용해 구현할 때와 같은 방식이죠.

-

- -

-

OrthographicCamera는 게임 엔진 에디터 등에서처럼 3D 모델링 결과물의 상, 하, 좌, 우, -앞, 뒤를 렌더링할 때도 사용합니다.

-
- -

위 스크린샷의 1개의 화면만 원근(perspective) 카메라이고 나머지 3개는 정사영(orthographic) -카메라입니다.

-

여기까지 카메라의 기초에 대해 살펴보았습니다. 카메라를 움직이는 방법에 대해서는 다른 -글에서 좀 더 상세히 설명할 거예요. 다음은 장에서는 그림자(shadows)에 -대해 먼저 살펴보겠습니다.

-

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/canvas-textures.html b/manual/ko/canvas-textures.html deleted file mode 100644 index bc8b1eb044a2d3..00000000000000 --- a/manual/ko/canvas-textures.html +++ /dev/null @@ -1,385 +0,0 @@ - - - 캔버스 텍스처 - - - - - - - - - - - - - - -
-
-

캔버스 텍스처

-
-
-
-

※ 이 글은 텍스처에 관한 글에서 이어집니다. 이전 글을 읽지 않았다면 먼저 읽고 오기 바랍니다.

-

이전 글에서 텍스처를 다룰 때는 주로 이미지 파일로 텍스처를 만들었습니다. 하지만 경우에 따라서는 런타임에 텍스처를 만들어야 할 수도 있죠. CanvasTexture를 사용하면 캔버스를 텍스처로 활용할 수 있습니다.

-

캔버스 텍스처는 <canvas> 요소를 인자로 받습니다. 2D 캔버스 API에 대해 잘 모른다면 MDN의 튜토리얼을 참고하세요.

-

간단한 2D 캔버스 앱을 만들어봅시다. 아래는 크기, 색, 위치 모두 무작위인 점을 반복해서 렌더링하는 예제입니다.

-
const ctx = document.createElement('canvas').getContext('2d');
-document.body.appendChild(ctx.canvas);
-ctx.canvas.width = 256;
-ctx.canvas.height = 256;
-ctx.fillStyle = '#FFF';
-ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
-
-function randInt(min, max) {
-  if (max === undefined) {
-    max = min;
-    min = 0;
-  }
-  return Math.random() * (max - min) + min | 0;
-}
-
-function drawRandomDot() {
-  ctx.fillStyle = `#${ randInt(0x1000000).toString(16).padStart(6, '0') }`;
-  ctx.beginPath();
-
-  const x = randInt(256);
-  const y = randInt(256);
-  const radius = randInt(10, 64);
-  ctx.arc(x, y, radius, 0, Math.PI * 2);
-  ctx.fill();
-}
-
-function render() {
-  drawRandomDot();
-  requestAnimationFrame(render);
-}
-requestAnimationFrame(render);
-
-

- -

-

이제 위 캔버스를 텍스처로 만들어봅시다. 이전 글에서 정육면체에 텍스처를 입혔던 예제를 가져오겠습니다. 이미지를 불러오는 코드를 제거하고 대신 CanvasTexture에 방금 만든 캔버스를 넘겨 인스턴스를 생성합니다.

-
const cubes = [];  // 정육면체를 회전시키기 위한 배열입니다.
--const loader = new THREE.TextureLoader();
--
-+const ctx = document.createElement('canvas').getContext('2d');
-+ctx.canvas.width = 256;
-+ctx.canvas.height = 256;
-+ctx.fillStyle = '#FFF';
-+ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
-+const texture = new THREE.CanvasTexture(ctx.canvas);
-
-const material = new THREE.MeshBasicMaterial({
--  map: loader.load('resources/images/wall.jpg'),
-+  map: texture,
-});
-const cube = new THREE.Mesh(geometry, material);
-scene.add(cube);
-cubes.push(cube);  // 정육면체 배열에 추가해 애니메이션이 적용되도록 합니다.
-
-

그리고 렌더링 루프에서 무작위 점을 찍도록 합니다.

-
function render(time) {
-  time *= 0.001;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-+  drawRandomDot();
-+  texture.needsUpdate = true;
-
-  cubes.forEach((cube, ndx) => {
-    const speed = .2 + ndx * .1;
-    const rot = time * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-

추가로 CanvasTexture가 변경되었을 때 Three.js가 텍스처를 업데이트하도록 needsUpdate 속성을 true로 설정합니다.

-

이제 정육면체에 캔버스 텍스처가 적용되었을 겁니다.

-

- -

-

Three.js가 캔버스 텍스처를 렌더링할 때는 이전에 별도의 글에서 설명했던 RenderTarget을 쓰는 게 더 좋습니다.

-

캔버스 텍스처는 주로 장면에 텍스트를 삽입할 때 사용합니다. 예를 들어 캐릭터의 명찰에 이름을 표기하는 경우 캔버스 텍스처를 명찰의 텍스처로 사용할 수 있겠죠.

-

한 번 3명의 사람이 있는 장면을 만들어 각 사람에게 명찰을 달아봅시다.

-

위 예제를 그대로 가져와 정육면체 관련 코드를 지웁니다. 배경은 하얀색으로 바꾸고 두 개의 조명을 넣습니다.

-
const scene = new THREE.Scene();
-+scene.background = new THREE.Color('white');
-+
-+function addLight(position) {
-+  const color = 0xFFFFFF;
-+  const intensity = 1;
-+  const light = new THREE.DirectionalLight(color, intensity);
-+  light.position.set(...position);
-+  scene.add(light);
-+  scene.add(light.target);
-+}
-+addLight([-3, 1, 1]);
-+addLight([ 2, 1, .5]);
-
-

2D 캔버스를 이용해 명찰을 만드는 함수를 작성합니다.

-
+function makeLabelCanvas(size, name) {
-+  const borderSize = 2;
-+  const ctx = document.createElement('canvas').getContext('2d');
-+  const font =  `${ size }px bold sans-serif`;
-+  ctx.font = font;
-+  // 이름의 길이를 예측합니다.
-+  const doubleBorderSize = borderSize * 2;
-+  const width = ctx.measureText(name).width + doubleBorderSize;
-+  const height = size + doubleBorderSize;
-+  ctx.canvas.width = width;
-+  ctx.canvas.height = height;
-+
-+  // 캔버스 크기를 바꿨을 때 폰트를 다시 설정해줘야 합니다.
-+  ctx.font = font;
-+  ctx.textBaseline = 'top';
-+
-+  ctx.fillStyle = 'blue';
-+  ctx.fillRect(0, 0, width, height);
-+  ctx.fillStyle = 'white';
-+  ctx.fillText(name, borderSize, borderSize);
-+
-+  return ctx.canvas;
-+}
-
-

다음으로 원통이 몸, 구체가 머리, 평면이 명찰인 간단한 사람을 만들어야 합니다.

-

먼저 공통으로 사용할 geometry를 만듭니다.

-
+const bodyRadiusTop = .4;
-+const bodyRadiusBottom = .2;
-+const bodyHeight = 2;
-+const bodyRadialSegments = 6;
-+const bodyGeometry = new THREE.CylinderGeometry(
-+    bodyRadiusTop, bodyRadiusBottom, bodyHeight, bodyRadialSegments);
-+
-+const headRadius = bodyRadiusTop * 0.8;
-+const headLonSegments = 12;
-+const headLatSegments = 5;
-+const headGeometry = new THREE.SphereGeometry(
-+    headRadius, headLonSegments, headLatSegments);
-+
-+const labelGeometry = new THREE.PlaneGeometry(1, 1);
-
-

다음으로 이 geometry들을 이용해 사람을 만드는 함수를 만듭니다.

-
+function makePerson(x, size, name, color) {
-+  const canvas = makeLabelCanvas(size, name);
-+  const texture = new THREE.CanvasTexture(canvas);
-+  // 텍스처용 캔버스는 2D이므로 픽셀이 모자랑 경우 대략적으로
-+  // 필터링하게끔 설정합니다.
-+  texture.minFilter = THREE.LinearFilter;
-+  texture.wrapS = THREE.ClampToEdgeWrapping;
-+  texture.wrapT = THREE.ClampToEdgeWrapping;
-+
-+  const labelMaterial = new THREE.MeshBasicMaterial({
-+    map: texture,
-+    side: THREE.DoubleSide,
-+    transparent: true,
-+  });
-+  const bodyMaterial = new THREE.MeshPhongMaterial({
-+    color,
-+    flatShading: true,
-+  });
-+
-+  const root = new THREE.Object3D();
-+  root.position.x = x;
-+
-+  const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
-+  root.add(body);
-+  body.position.y = bodyHeight / 2;
-+
-+  const head = new THREE.Mesh(headGeometry, bodyMaterial);
-+  root.add(head);
-+  head.position.y = bodyHeight + headRadius * 1.1;
-+
-+  const label = new THREE.Mesh(labelGeometry, labelMaterial);
-+  root.add(label);
-+  label.position.y = bodyHeight * 4 / 5;
-+  label.position.z = bodyRadiusTop * 1.01;
-+
-+  // 명찰의 크기를 조정합니다.
-+  const labelBaseScale = 0.01;
-+  label.scale.x = canvas.width  * labelBaseScale;
-+  label.scale.y = canvas.height * labelBaseScale;
-+
-+  scene.add(root);
-+  return root;
-+}
-
-

위 코드에서는 몸, 머리, 명찰을 하나의 Object3D에 넣고 위치를 조정했습니다. 이러면 Object3D만 움직여 해당 사람을 움직일 수 있겠죠. 몸은 2칸으로, 캔버스 사이즈가 픽셀 단위이고 거의 수십 픽셀이기에 여기에 0.01을 곱해 사이즈를 적당한 크기로 조정했습니다.

-

이제 사람과 명찰을 만듭니다.

-
+makePerson(-3, 32, 'Purple People Eater', 'purple');
-+makePerson(-0, 32, 'Green Machine', 'green');
-+makePerson(+3, 32, 'Red Menace', 'red');
-
-

마지막으로 OrbitControls를 넣어 카메라를 움직일 수 있도록 합니다.

-
import * as THREE from 'three';
-+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-
-
const fov = 75;
-const aspect = 2;  // 캔버스 기본값
-const near = 0.1;
--const far = 5;
-+const far = 50;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
--camera.position.z = 2;
-+camera.position.set(0, 2, 5);
-
-+const controls = new OrbitControls(camera, canvas);
-+controls.target.set(0, 2, 0);
-+controls.update();
-
-

사람 위에 간단한 명찰이 붙었습니다.

-

- -

-

나쁘지 않은 예제이지만 몇 가지 짚고 넘어가야 할 것들이 있습니다.

-
    -
  • 명찰을 확대했을 때 해상도가 낮아 보인다.
  • -
-

이 문제를 해결하는 건 생각보다 까다롭습니다. 고급 폰트 렌더링 기법이 있긴 하나 제가 아는 한 플러그인으로 구현되어 있진 않습니다. 거기다 폰트 데이터를 다운 받아야 하니 상대적으로 느리겠죠.

-

다른 방법은 명찰의 해상도를 높이는 겁니다. 텍스처용 캔버스의 사이즈를 두 배로 설정하고 labelBaseScale을 반으로 낮추면 지금과 같은 크기에 해상도만 높일 수 있겠죠.

-
    -
  • 이름의 길이가 길수록 명찰도 길어진다.
  • -
-

이 문제를 해결하려면 명찰 크기를 고정하고 텍스트를 우겨넣으면 됩니다.

-

이건 상대적으로 구현하기 쉽습니다. 명찰의 넓이를 넘겨주고 텍스트의 스케일을 해당 넓이에 맞춰 조정하면 되죠.

-
-function makeLabelCanvas(size, name) {
-+function makeLabelCanvas(baseWidth, size, name) {
-  const borderSize = 2;
-  const ctx = document.createElement('canvas').getContext('2d');
-  const font =  `${ size }px bold sans-serif`;
-  ctx.font = font;
-  // 이름의 길이를 예측합니다.
-+  const textWidth = ctx.measureText(name).width;
-
-  const doubleBorderSize = borderSize * 2;
--  const width = ctx.measureText(name).width + doubleBorderSize;
-+  const width = baseWidth + doubleBorderSize;
-  const height = size + doubleBorderSize;
-  ctx.canvas.width = width;
-  ctx.canvas.height = height;
-
-  // 캔버스 크기를 바꿨을 때 폰트를 다시 설정해줘야 합니다.
-  ctx.font = font;
--  ctx.textBaseline = 'top';
-+  ctx.textBaseline = 'middle';
-+  ctx.textAlign = 'center';
-
-  ctx.fillStyle = 'blue';
-  ctx.fillRect(0, 0, width, height);
-
-+  // 명찰의 크기에 맞춰 조정하되 늘어나지 않도록 합니다.
-+  const scaleFactor = Math.min(1, baseWidth / textWidth);
-+  ctx.translate(width / 2, height / 2);
-+  ctx.scale(scaleFactor, 1);
-  ctx.fillStyle = 'white';
-  ctx.fillText(name, borderSize, borderSize);
-
-  return ctx.canvas;
-}
-
-

사람을 만들 때 명찰의 넓이도 같이 받도록 바꿉니다.

-
-function makePerson(x, size, name, color) {
--  const canvas = makeLabelCanvas(size, name);
-+function makePerson(x, labelWidth, size, name, color) {
-+  const canvas = makeLabelCanvas(labelWidth, size, name);
-
-...
-
-}
-
--makePerson(-3, 32, 'Purple People Eater', 'purple');
--makePerson(-0, 32, 'Green Machine', 'green');
--makePerson(+3, 32, 'Red Menace', 'red');
-+makePerson(-3, 150, 32, 'Purple People Eater', 'purple');
-+makePerson(-0, 150, 32, 'Green Machine', 'green');
-+makePerson(+3, 150, 32, 'Red Menace', 'red');
-
-

이제 명찰의 텍스트가 크기에 맞춰지고 가운데 정렬됩니다.

-

- -

-

위 예제에서는 텍스처마다 캔버스를 따로 썼습니다. 텍스처마다 캔버스를 따로 쓸지는 여러분의 선택에 달렸습니다. 만약 캔버스를 자주 업데이트해야 한다면 텍스처마다 캔버스를 따로 두는 게 좋겠죠. 반대로 아예, 또는 가끔 업데이트할 거라면 하나의 캔버스를 돌려 쓰는 게 더 나을 겁니다. 명찰은 업데이트할 일이 없으니 위 코드를 고쳐 하나의 캔버스를 쓰도록 해보죠.

-
+const ctx = document.createElement('canvas').getContext('2d');
-
-function makeLabelCanvas(baseWidth, size, name) {
-  const borderSize = 2;
--  const ctx = document.createElement('canvas').getContext('2d');
-  const font =  `${ size }px bold sans-serif`;
-
-  ...
-
-}
-
-+const forceTextureInitialization = function() {
-+  const material = new THREE.MeshBasicMaterial();
-+  const geometry = new THREE.PlaneGeometry();
-+  const scene = new THREE.Scene();
-+  scene.add(new THREE.Mesh(geometry, material));
-+  const camera = new THREE.Camera();
-+
-+  return function forceTextureInitialization(texture) {
-+    material.map = texture;
-+    renderer.render(scene, camera);
-+  };
-+}();
-
-function makePerson(x, labelWidth, size, name, color) {
-  const canvas = makeLabelCanvas(labelWidth, size, name);
-  const texture = new THREE.CanvasTexture(canvas);
-  // 텍스처용 캔버스는 2D이므로 픽셀이 모자랑 경우 대략적으로
-  // 필터링하게끔 설정합니다.
-  texture.minFilter = THREE.LinearFilter;
-  texture.wrapS = THREE.ClampToEdgeWrapping;
-  texture.wrapT = THREE.ClampToEdgeWrapping;
-+  forceTextureInitialization(texture);
-
-  ...
-
-

- -

-

아직 언급하지 않은 다른 문제점이 있습니다. 카메라를 사람 뒤로 돌리면 명찰이 뒤집혀 보인다는 거죠. 만약 명찰을 배지 형태로 사용할 거라면 크게 문제될 일은 없습니다. 하지만 명찰을 3D 게임 캐릭터의 이름표로 사용할 거라면 이름표가 항상 카메라를 향해야 하겠죠. 이 방법에 대해서는 빌보드와 파사드에서 다루겠습니다.

-

단순히 이름표를 구현하는 경우라면 HTML을 이용한 방법을 사용할 수도 있습니다. 다만 HTML 이름표는 항상 3D 요소 위에 있죠. 예제의 명찰은 3차원 세계 안에 있기에 다른 물체에 의해 가려지길 원할 경우 유용합니다.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/cleanup.html b/manual/ko/cleanup.html deleted file mode 100644 index 50bc61c717dd4b..00000000000000 --- a/manual/ko/cleanup.html +++ /dev/null @@ -1,391 +0,0 @@ - - - 메모리 해제하기 - - - - - - - - - - - - - - -
-
-

메모리 해제하기

-
-
-
-

Three.js 앱은 생각보다 많은 메모리를 사용합니다. 3D 모델의 정점 데이터는 보통 1MB에서 20MB 정도의 메모리를 차지하죠. 텍스처로 JPG 파일을 사용하는 모델은 텍스처를 사용하기 위해 JPG 파일의 압축을 완전히 풀어야 하는데, 이 텍스처는 1024x1024당 약 4에서 6MB 정도의 메모리를 사용합니다.

-

대다수의 Three.js 앱은 자원을 한 번 불러오면 페이지가 닫히기 전까지 해당 자원을 버릴 일이 없습니다. 하지만 시간이 지남에 따라 데이터를 바꿔야 한다면 어떨까요?

-

Three.js는 자바스크립트와 달리 할당한 메모리를 알아서 비우지 못합니다. 페이지를 전환하는 경우야 브라우저가 알아서 해당 자원을 메모리에서 지우겠지만, 그 밖의 경우 메모리 해제는 전적으로 개발자에게 달렸습니다.

-

Three.js에서는 textures, geometries, materialsdispose 메서드를 호출해 메모리를 해제할 수 있습니다.

-

가장 간단한 방법은 일일이 호출하는 겁니다. 초기화 시에 아래와 같이 지원을 메모리에 할당하고

-
const boxGeometry = new THREE.BoxGeometry(...);
-const boxTexture = textureLoader.load(...);
-const boxMaterial = new THREE.MeshPhongMaterial({ map: texture });
-
-

아래와 같이 직접 메서드를 호출해 메모리를 해제할 수 있죠.

-
boxGeometry.dispose();
-boxTexture.dispose();
-boxMaterial.dispose();
-
-

하지만 자원이 많아질수록 코드는 지저분해질 겁니다.

-

자원을 추적하는 클래스를 하나 만드는 게 좋겠네요. 클래스에 자원을 지정하고 한 번에 버리도록 해보겠습니다.

-
class ResourceTracker {
-  constructor() {
-    this.resources = new Set();
-  }
-  track(resource) {
-    if (resource.dispose) {
-      this.resources.add(resource);
-    }
-    return resource;
-  }
-  untrack(resource) {
-    this.resources.delete(resource);
-  }
-  dispose() {
-    for (const resource of this.resources) {
-      resource.dispose();
-    }
-    this.resources.clear();
-  }
-}
-
-

텍스처에 관한 글의 첫 번째 예제에 이 클래스를 써 봅시다. 먼저 클래스의 인스턴스를 만듭니다.

-
const resTracker = new ResourceTracker();
-
-

좀 더 간단한 형태로 쓰기 위해 track 메서드를 함수로 만듭니다.

-
const resTracker = new ResourceTracker();
-+const track = resTracker.track.bind(resTracker);
-
-

그리고 각 geometry, 텍스처, 재질(material)에 track 함수를 호출합니다.

-
const boxWidth = 1;
-const boxHeight = 1;
-const boxDepth = 1;
--const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-+const geometry = track(new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth));
-
-const cubes = [];  // 정육면체를 회전시키기 위한 배열
-const loader = new THREE.TextureLoader();
-
--const material = new THREE.MeshBasicMaterial({
--  map: loader.load('resources/images/wall.jpg'),
--});
-+const material = track(new THREE.MeshBasicMaterial({
-+  map: track(loader.load('resources/images/wall.jpg')),
-+}));
-const cube = new THREE.Mesh(geometry, material);
-scene.add(cube);
-cubes.push(cube);  // 회전 애니메이션을 위해 배열에 추가
-
-

자원을 해제할 때 정육면체를 장면에서 제거하고 resTracker.dispose 메서드를 호출하도록 합니다.

-
for (const cube of cubes) {
-  scene.remove(cube);
-}
-cubes.length = 0;  // 정육면체 배열을 비웁니다
-resTracker.dispose();
-
-

하지만 실제로 테스트해보니 귀찮은 작업을 추가해야 합니다. ResourceTracker에 코드를 추가하겠습니다.

-
class ResourceTracker {
-  constructor() {
-    this.resources = new Set();
-  }
-  track(resource) {
--    if (resource.dispose) {
-+    if (resource.dispose || resource instanceof THREE.Object3D) {
-      this.resources.add(resource);
-    }
-    return resource;
-  }
-  untrack(resource) {
-    this.resources.delete(resource);
-  }
-  dispose() {
-    for (const resource of this.resources) {
--      resource.dispose();
-+      if (resource instanceof THREE.Object3D) {
-+        if (resource.parent) {
-+          resource.parent.remove(resource);
-+        }
-+      }
-+      if (resource.dispose) {
-+        resource.dispose();
-+      }
-+    }
-    this.resources.clear();
-  }
-}
-
-

이제 정육면체를 추적할 수 있습니다.

-
const material = track(new THREE.MeshBasicMaterial({
-  map: track(loader.load('resources/images/wall.jpg')),
-}));
-const cube = track(new THREE.Mesh(geometry, material));
-scene.add(cube);
-cubes.push(cube);  // 회전 애니메이션을 위해 배열에 추가
-
-

별도로 정육면체를 제거해야할 필요가 없으니 코드를 삭제합니다.

-
-for (const cube of cubes) {
--  scene.remove(cube);
--}
-cubes.length = 0;  // 정육면체 배열을 비웁니다
-resTracker.dispose();
-
-

코드를 정리해 정육면체, 텍스처, 재질을 다시 추가할 수 있도록 만들고

-
const scene = new THREE.Scene();
-*const cubes = [];  // 정육면체를 회전시키기 위한 배열
-
-+function addStuffToScene() {
-  const resTracker = new ResourceTracker();
-  const track = resTracker.track.bind(resTracker);
-
-  const boxWidth = 1;
-  const boxHeight = 1;
-  const boxDepth = 1;
-  const geometry = track(new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth));
-
-  const loader = new THREE.TextureLoader();
-
-  const material = track(new THREE.MeshBasicMaterial({
-    map: track(loader.load('resources/images/wall.jpg')),
-  }));
-  const cube = track(new THREE.Mesh(geometry, material));
-  scene.add(cube);
-  cubes.push(cube);  // 회전 애니메이션을 위해 배열에 추가
-+  return resTracker;
-+}
-
-

시간에 지남에 따라 물체들을 사라지고 나타나게 합니다.

-
function waitSeconds(seconds = 0) {
-  return new Promise(resolve => setTimeout(resolve, seconds * 1000));
-}
-
-async function process() {
-  for (;;) {
-    const resTracker = addStuffToScene();
-    await wait(2);
-    cubes.length = 0;  // 정육면체 배열을 비웁니다
-    resTracker.dispose();
-    await wait(1);
-  }
-}
-process();
-
-

아래 예제는 정육면체, 텍스처, 재질을 렌더링한 뒤 2초 후에 해당 자원을 버리고, 다시 1초 후에 생성하기를 반복합니다.

-

- -

-

딱히 오류는 없네요.

-

불러온 파일을 해제하려면 코드를 좀 더 추가해야 합니다. Three.js의 로더는 대부분 최상위 Object3D만을 반환하기에 어떤 자원을 사용했는지 체크하려면 일일이 하위 요소를 뒤져봐야 합니다.

-

ResourceTracker를 업데이트해 저 역할을 맡겨보죠.

-

먼저 자원이 Object3D인지 확인해 해당 요소의 geometry, 재질, 하위 요소를 추적하도록 합니다.

-
class ResourceTracker {
-  constructor() {
-    this.resources = new Set();
-  }
-  track(resource) {
-    if (resource.dispose || resource instanceof THREE.Object3D) {
-      this.resources.add(resource);
-    }
-+    if (resource instanceof THREE.Object3D) {
-+      this.track(resource.geometry);
-+      this.track(resource.material);
-+      this.track(resource.children);
-+    }
-    return resource;
-  }
-  ...
-}
-
-

그리고 resource.geometry, resource.material, resource.children이 null이나 undefined일 수 있으므로 track 메서드 상단에서 체크해줍니다.

-
class ResourceTracker {
-  constructor() {
-    this.resources = new Set();
-  }
-  track(resource) {
-+    if (!resource) {
-+      return resource;
-+    }
-
-    if (resource.dispose || resource instanceof THREE.Object3D) {
-      this.resources.add(resource);
-    }
-    if (resource instanceof THREE.Object3D) {
-      this.track(resource.geometry);
-      this.track(resource.material);
-      this.track(resource.children);
-    }
-    return resource;
-  }
-  ...
-}
-
-

resource.children이나 resource.material은 배열 형식일 수 있습니다. 배열일 경우 배열의 요소를 추적하도록 합니다.

-
class ResourceTracker {
-  constructor() {
-    this.resources = new Set();
-  }
-  track(resource) {
-    if (!resource) {
-      return resource;
-    }
-
-+    // 하위 요소 또는 재질이 배열일 경우
-+    if (Array.isArray(resource)) {
-+      resource.forEach(resource => this.track(resource));
-+      return resource;
-+    }
-
-    if (resource.dispose || resource instanceof THREE.Object3D) {
-      this.resources.add(resource);
-    }
-    if (resource instanceof THREE.Object3D) {
-      this.track(resource.geometry);
-      this.track(resource.material);
-      this.track(resource.children);
-    }
-    return resource;
-  }
-  ...
-}
-
-

그리고 재질의 속성 중 텍스처와 균등 변수(uniform)를 처리해줍니다.

-
class ResourceTracker {
-  constructor() {
-    this.resources = new Set();
-  }
-  track(resource) {
-    if (!resource) {
-      return resource;
-    }
-
-*    // 하위 요소 또는 재질이 배열일 경우,
-*    // 균등 변수가 텍스처 배열일 경우
-    if (Array.isArray(resource)) {
-      resource.forEach(resource => this.track(resource));
-      return resource;
-    }
-
-    if (resource.dispose || resource instanceof THREE.Object3D) {
-      this.resources.add(resource);
-    }
-    if (resource instanceof THREE.Object3D) {
-      this.track(resource.geometry);
-      this.track(resource.material);
-      this.track(resource.children);
--    }
-+    } else if (resource instanceof THREE.Material) {
-+      // 재질에 텍스처가 있는지 검사해 추적합니다.
-+      for (const value of Object.values(resource)) {
-+        if (value instanceof THREE.Texture) {
-+          this.track(value);
-+        }
-+      }
-+      // 균등 변수가 텍스처 또는 텍스처의 배열인지 체크합니다.
-+      if (resource.uniforms) {
-+        for (const value of Object.values(resource.uniforms)) {
-+          if (value) {
-+            const uniformValue = value.value;
-+            if (uniformValue instanceof THREE.Texture ||
-+                Array.isArray(uniformValue)) {
-+              this.track(uniformValue);
-+            }
-+          }
-+        }
-+      }
-+    }
-    return resource;
-  }
-  ...
-}
-
-

수정한 클래스를 glTF 파일 불러오기에서 썼던 예제에 적용해 무료 glTF 파일을 불러와보도록 합시다.

-
const gltfLoader = new GLTFLoader();
-function loadGLTF(url) {
-  return new Promise((resolve, reject) => {
-    gltfLoader.load(url, resolve, undefined, reject);
-  });
-}
-
-function waitSeconds(seconds = 0) {
-  return new Promise(resolve => setTimeout(resolve, seconds * 1000));
-}
-
-const fileURLs = [
-  'resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf',
-  'resources/models/3dbustchallange_submission/scene.gltf',
-  'resources/models/mountain_landscape/scene.gltf',
-  'resources/models/simple_house_scene/scene.gltf',
-];
-
-async function loadFiles() {
-  for (;;) {
-    for (const url of fileURLs) {
-      const resMgr = new ResourceTracker();
-      const track = resMgr.track.bind(resMgr);
-      const gltf = await loadGLTF(url);
-      const root = track(gltf.scene);
-      scene.add(root);
-
-      // 해당 요소의 모든 하위 물체를 포함하는 육면체를 계산합니다.
-      const box = new THREE.Box3().setFromObject(root);
-
-      const boxSize = box.getSize(new THREE.Vector3()).length();
-      const boxCenter = box.getCenter(new THREE.Vector3());
-
-      // 카메라가 화면을 전부 담도록 설정합니다.
-      frameArea(boxSize * 1.1, boxSize, boxCenter, camera);
-
-      await waitSeconds(2);
-      renderer.render(scene, camera);
-
-      resMgr.dispose();
-
-      await waitSeconds(1);
-
-    }
-  }
-}
-loadFiles();
-
-

- -

-

코드에 대해 몇 가지 설명하고 끝내겠습니다.

-

만약 2개 이상의 파일을 한 번에 불러오고 나중에 따로 해제하려면 ResourceTracker를 파일별로 생성하면 됩니다.

-

위 예제에서는 gltf.scene에만 track 메서드를 사용했습니다. 이것만으로 지금 ResourceTracker는 포함된 모든 요소를 추적하겠죠. 화면에 뭔가를 더 추가하려면 해당 자원을 추적할지 말지를 먼저 결정해야 합니다.

-

특정 도구를 캐릭터의 자식 요소로 추가해 손에 쥐어 주는 경우를 예로 들 수 있습니다. 그냥 도구만 추가해서는 해당 요소를 추적할 수 없을 테니까요. 모르긴 해도 꽤나 흔한 경우일 거라 생각합니다.

-

처음에 ResourceTracker를 작성했을 때는 모든 것을 track이 아니라 dispose 메서드 안에서 해결하려고 했습니다. 하지만 캐릭터의 손에 도구를 쥐어 주는 경우를 생각해보니 track을 통해 등록한 자원을 해제하는 게 확장성 면에서도 그렇고 더 나은 방법 같더군요. 씬 그래프 전체를 해제시키는 것보다는 불러온 파일만 해제시키는 게 나을 테니까요.

-

ResourceTracker를 만들긴 했지만 100% 만족스럽진 않습니다. 3D 엔진에서 자원을 이런 식으로 관리하는 건 흔한 일이 아니거든요. 어떤 자원이 올라올지 추측하는 게 아니라 미리 알고 있어야 하는 쪽이 맞습니다. Three.js의 파일 로더가 불러온 자원의 주소값을 전부 반환하도록 바뀐다면 좋겠지만, 지금은 장면(scene)을 불러올 때 다른 선택지가 없기에 이 해결책이 최선이겠죠.

-

이 예시가 Three.js에서 자원을 해제하는 데 조금이나마 도움이 되었으면 합니다.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/custom-buffergeometry.html b/manual/ko/custom-buffergeometry.html deleted file mode 100644 index 843a18640b1092..00000000000000 --- a/manual/ko/custom-buffergeometry.html +++ /dev/null @@ -1,431 +0,0 @@ - - - 사용자 지정 BufferGeometry - - - - - - - - - - - - - - -
-
-

사용자 지정 BufferGeometry

-
-
-
-

BufferGeometry는 Three.js 내의 모든 geometry를 나타냅니다(r125에서부터 Geometry가 제거되었습니다). 좀 더 자세히 말하면 특정 BufferAttribute라고 부르는 속성의 집합이죠.

-

BufferAttribute는 -위치(positions), 법선(normals), 색(colors), uv 데이터의 배열이고, 이들을 모으면 각 꼭지점에 -대한 평행 배열 형식의 데이터가 됩니다.

-
- -

그림을 보면 총 4개의 속성(attribute), position, normal, color, uv가 있습니다. -이들은 평행 배열로 각 속성의 N 번째 데이터 묶음이 한 꼭지점의 데이터를 나타냅니다. 표시한 -index = 4 위치의 꼭지점 데이터를 보세요. 이 묶음이 하나의 꼭지점을 정의합니다.

-

이해를 돕기 위해 정육면체의 한 모서리를 강조해보겠습니다.

-
- -

이 경우 맞닿은 면의 색이 다르기에, 이 모서리는 각 면에 다른 법선을 제공해야 합니다. -마찬가지로 UV도 달라야 하죠. -하나의 꼭지점은 위 4개 속성의 묶음이고, 때문에 속성이 달라진다면 그건 다른 꼭지점이 -되는 것이죠.

-

이전과 마찬가지로 BufferGeometry로 정육면체를 만들어보겠습니다. 굳이 정육면체를 쓰는 -이유는 모서리의 꼭지점을 공유하는 듯해도 사실 그렇지 않기 때문이죠. 필요한 꼭지점을 -전부 생성한 후, 꼭지점 데이터를 평행 배열로 변환해 BufferAttribute를 만들고, 이를 -BufferGeometry에 추가해야 합니다.

-

먼저 정육면체를 만드는 데 필요한 데이터를 정렬합니다. -아까 말했듯 꼭지점의 속성 중 하나라도 다르다면 별도의 꼭지점으로 분리해야 합니다. 정육면체의 -경우는 총 꼭지점 36개가 필요하죠. 면 6개, 면 하나당 삼각형 2개, 삼각형 하나 당 꼭지점 3개, -총 36개입니다.

-
const vertices = [
-  // 앞쪽
-  { pos: [-1, -1,  1], norm: [ 0,  0,  1], uv: [0, 0], },
-  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
-  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
-
-  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
-  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 0,  0,  1], uv: [1, 1], },
-  // 오른쪽
-  { pos: [ 1, -1,  1], norm: [ 1,  0,  0], uv: [0, 0], },
-  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
-
-  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
-  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
-  { pos: [ 1,  1, -1], norm: [ 1,  0,  0], uv: [1, 1], },
-  // 뒤쪽
-  { pos: [ 1, -1, -1], norm: [ 0,  0, -1], uv: [0, 0], },
-  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
-  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
-
-  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
-  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
-  { pos: [-1,  1, -1], norm: [ 0,  0, -1], uv: [1, 1], },
-  // 왼쪽
-  { pos: [-1, -1, -1], norm: [-1,  0,  0], uv: [0, 0], },
-  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
-  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
-
-  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
-  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
-  { pos: [-1,  1,  1], norm: [-1,  0,  0], uv: [1, 1], },
-  // 상단
-  { pos: [ 1,  1, -1], norm: [ 0,  1,  0], uv: [0, 0], },
-  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
-
-  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
-  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
-  { pos: [-1,  1,  1], norm: [ 0,  1,  0], uv: [1, 1], },
-  // 하단
-  { pos: [ 1, -1,  1], norm: [ 0, -1,  0], uv: [0, 0], },
-  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
-  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
-
-  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
-  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
-  { pos: [-1, -1, -1], norm: [ 0, -1,  0], uv: [1, 1], },
-];
-
-

다음으로 이 배열을 3개의 평행 배열로 변환합니다.

-
const positions = [];
-const normals = [];
-const uvs = [];
-for (const vertex of vertices) {
-  positions.push(...vertex.pos);
-  normals.push(...vertex.norm);
-  uvs.push(...vertex.uv);
-}
-
-

이제 BufferGeometry를 만듭니다. 그리고 각 배열로 BufferAttribute 인스턴스를 생성한 -뒤 BufferGeometry에 추가합니다.

-
  const geometry = new THREE.BufferGeometry();
-  const positionNumComponents = 3;
-  const normalNumComponents = 3;
-  const uvNumComponents = 2;
-  geometry.setAttribute(
-      'position',
-      new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
-  geometry.setAttribute(
-      'normal',
-      new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
-  geometry.setAttribute(
-      'uv',
-      new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
-
-

이때 정확히 Three.js가 원하는 속성 이름을 써야 합니다(사용자 지정 쉐이더를 만들 때와는 -달리). 이 경우에는 position, normal, uv이죠. 꼭지점 색을 지정하려면 color 속성을 -지정해야 합니다.

-

아까 positions, normals, uvs, 3개의 자바스크립트 순수 배열을 생성했습니다. -그리고 위에서 해당 배열을 형식화 배열, -Float32Array로 변환했죠. BufferAttribute는 순수 배열이 아니라 형식화 배열을 -인자로 받습니다. 또한 하나의 꼭지점에 몇 개의 요소를 사용할 지 지정해줘야 하죠. -위치(position)과 법선(normal)의 경우 x, y, z 총 3개이고, UV는 u, v 총 2개입니다.

-

- -

-

데이터가 너무 많네요. 크게 구조를 바꾸긴 어렵지만, 꼭지점을 인덱스로 참조하게끔 바꾸면 -조금 나을 듯합니다. 정육면체 데이터를 다시 봅시다. 각 면은 2개의 삼각형, 삼각형 하나에는 -꼭지점이 3개 있으므로 면 하나에는 총 6개의 꼭지점이 있습니다. 하지만 이 중 꼭지점 2개는 -완전히 같죠(같은 위치, 같은 법선, 같은 uv). 중복된 꼭지점을 제거하고 인덱스로 데이터를 -불러오도록 바꿀 수 있겠네요. 먼저 중복된 꼭지점을 제거하겠습니다.

-
const vertices = [
-  // 앞쪽
-  { pos: [-1, -1,  1], norm: [ 0,  0,  1], uv: [0, 0], }, // 0
-  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], }, // 1
-  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], }, // 2
--
--  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
--  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 0,  0,  1], uv: [1, 1], }, // 3
-  // 오른쪽
-  { pos: [ 1, -1,  1], norm: [ 1,  0,  0], uv: [0, 0], }, // 4
-  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], }, // 5
--
--  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
--  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], }, // 6
-  { pos: [ 1,  1, -1], norm: [ 1,  0,  0], uv: [1, 1], }, // 7
-  // 뒤쪽
-  { pos: [ 1, -1, -1], norm: [ 0,  0, -1], uv: [0, 0], }, // 8
-  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], }, // 9
--
--  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
--  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
-  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], }, // 10
-  { pos: [-1,  1, -1], norm: [ 0,  0, -1], uv: [1, 1], }, // 11
-  // 왼쪽
-  { pos: [-1, -1, -1], norm: [-1,  0,  0], uv: [0, 0], }, // 12
-  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], }, // 13
--
--  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
--  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
-  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], }, // 14
-  { pos: [-1,  1,  1], norm: [-1,  0,  0], uv: [1, 1], }, // 15
-  // 상단
-  { pos: [ 1,  1, -1], norm: [ 0,  1,  0], uv: [0, 0], }, // 16
-  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], }, // 17
--
--  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
--  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
-  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], }, // 18
-  { pos: [-1,  1,  1], norm: [ 0,  1,  0], uv: [1, 1], }, // 19
-  // 하단
-  { pos: [ 1, -1,  1], norm: [ 0, -1,  0], uv: [0, 0], }, // 20
-  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], }, // 21
--
--  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
--  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
-  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], }, // 22
-  { pos: [-1, -1, -1], norm: [ 0, -1,  0], uv: [1, 1], }, // 23
-];
-
-

이제 꼭지점 24개만 남았습니다. 중복되는 꼭지점은 없죠. 이제 BufferGeometry.setIndex -메서드에 각 꼭지점 데이터의 인덱스값 36개-삼각형이 12개이므로-를 넘겨줍니다.

-
geometry.setAttribute(
-    'position',
-    new THREE.BufferAttribute(positions, positionNumComponents));
-geometry.setAttribute(
-    'normal',
-    new THREE.BufferAttribute(normals, normalNumComponents));
-geometry.setAttribute(
-    'uv',
-    new THREE.BufferAttribute(uvs, uvNumComponents));
-
-+geometry.setIndex([
-+   0,  1,  2,   2,  1,  3,  // 앞쪽
-+   4,  5,  6,   6,  5,  7,  // 오른쪽
-+   8,  9, 10,  10,  9, 11,  // 뒤쪽
-+  12, 13, 14,  14, 13, 15,  // 왼쪽
-+  16, 17, 18,  18, 17, 19,  // 상단
-+  20, 21, 22,  22, 21, 23,  // 하단
-+]);
-
-

- -

-

BufferGeometry에 법선을 지정하지 않았다면 computeVertexNormals -메서드를 호출해 자동으로 법선을 지정할 수 있습니다. 다만 데이터가 조금이라도 다르다면 -꼭지점을 공유할 수 없기에 구체나 원통 같은 물체를 만들려 하는 경우에는 이음새가 보일 수 있어요.

-
-
-
-
BufferGeometry
-
-
-
-
Geometry
-
-
- -

위 두 원통은 computeVertexNormals 메서드를 호출해 법선을 생성했습니다. 자세히 보면 -왼쪽 원통에 이음매가 있는 게 보일 텐데, 이는 원통의 끝과 시작점의 UV가 달라 꼭지점을 -공유할 수 없기 때문입니다. 그다지 큰 문제는 아니지만, 알아두어 나쁠 건 없죠. 이를 해결하려면 -법선을 직접 지정해야 합니다.

-

아까는 처음에 순수 배열을 썼지만, 처음부터 형식화 배열을 -사용할 수도 있습니다. 다만 형식화 배열은 초기화할 때 미리 크기를 지정해줘야 합니다. -그렇게 어려운 일은 아니지만, 순수 배열은 push 메서드로 요소를 추가하고 바뀐 -길이를 length 속성으로 확인할 수 있습니다. 하지만 형식화 배열을 사용하면 어디서 -요소를 추가했는지 직접 일일이 기록해야 하죠.

-

예제의 경우는 사전에 정해진 데이터를 사용하기에 배열 길이를 미리 구하는 게 어렵지 -않습니다.

-
-const positions = [];
--const normals = [];
--const uvs = [];
-+const numVertices = vertices.length;
-+const positionNumComponents = 3;
-+const normalNumComponents = 3;
-+const uvNumComponents = 2;
-+const positions = new Float32Array(numVertices * positionNumComponents);
-+const normals = new Float32Array(numVertices * normalNumComponents);
-+const uvs = new Float32Array(numVertices * uvNumComponents);
-+let posNdx = 0;
-+let nrmNdx = 0;
-+let uvNdx = 0;
-for (const vertex of vertices) {
--  positions.push(...vertex.pos);
--  normals.push(...vertex.norm);
--  uvs.push(...vertex.uv);
-+  positions.set(vertex.pos, posNdx);
-+  normals.set(vertex.norm, nrmNdx);
-+  uvs.set(vertex.uv, uvNdx);
-+  posNdx += positionNumComponents;
-+  nrmNdx += normalNumComponents;
-+  uvNdx += uvNumComponents;
-}
-
-geometry.setAttribute(
-    'position',
--    new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
-+    new THREE.BufferAttribute(positions, positionNumComponents));
-geometry.setAttribute(
-    'normal',
--    new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
-+    new THREE.BufferAttribute(normals, normalNumComponents));
-geometry.setAttribute(
-    'uv',
--    new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
-+    new THREE.BufferAttribute(uvs, uvNumComponents));
-
-geometry.setIndex([
-   0,  1,  2,   2,  1,  3,  // 앞쪽
-   4,  5,  6,   6,  5,  7,  // 오른쪽
-   8,  9, 10,  10,  9, 11,  // 뒤쪽
-  12, 13, 14,  14, 13, 15,  // 왼쪽
-  16, 17, 18,  18, 17, 19,  // 상단
-  20, 21, 22,  22, 21, 23,  // 하단
-]);
-
-

- -

-

꼭지점의 일부를 수정하고 싶다면 형식화 배열을 사용하는 게 좋습니다.

-

뭔가 꼭지점을 동적으로 수정하는 예제를 찾아보려고 노력했는데, 찾기가 어렵네요. -그냥 구체를 만들고 구체의 각 사분면(quad)을 중심으로부터 안팎으로 움직여보겠습니다.

-

아래는 구체의 위치값과 인덱스를 생성하는 코드입니다. 사분면 내의 꼭지점은 서로 -공유할 수 있지만, 사분면은 각각 움직여야 하므로 사분면끼리는 공유하지 않도록 -합니다.

-

구체의 정점을 구하기 위해 좀 더 복잡한 방법을 쓸 수도 있지만, 귀찮으니 그냥 -Object3D 3개를 계층 구조로 배열해 쓰겠습니다. 이 방식에 대해서는 많은 물체를 -최적화하는 방법에서 더 자세히 다루겠습니다.

-
function makeSpherePositions(segmentsAround, segmentsDown) {
-  const numVertices = segmentsAround * segmentsDown * 6;
-  const numComponents = 3;
-  const positions = new Float32Array(numVertices * numComponents);
-  const indices = [];
-
-  const longHelper = new THREE.Object3D();
-  const latHelper = new THREE.Object3D();
-  const pointHelper = new THREE.Object3D();
-  longHelper.add(latHelper);
-  latHelper.add(pointHelper);
-  pointHelper.position.z = 1;
-  const temp = new THREE.Vector3();
-
-  function getPoint(lat, long) {
-    latHelper.rotation.x = lat;
-    longHelper.rotation.y = long;
-    longHelper.updateMatrixWorld(true);
-    return pointHelper.getWorldPosition(temp).toArray();
-  }
-
-  let posNdx = 0;
-  let ndx = 0;
-  for (let down = 0; down < segmentsDown; ++down) {
-    const v0 = down / segmentsDown;
-    const v1 = (down + 1) / segmentsDown;
-    const lat0 = (v0 - 0.5) * Math.PI;
-    const lat1 = (v1 - 0.5) * Math.PI;
-
-    for (let across = 0; across < segmentsAround; ++across) {
-      const u0 = across / segmentsAround;
-      const u1 = (across + 1) / segmentsAround;
-      const long0 = u0 * Math.PI * 2;
-      const long1 = u1 * Math.PI * 2;
-
-      positions.set(getPoint(lat0, long0), posNdx);  posNdx += numComponents;
-      positions.set(getPoint(lat1, long0), posNdx);  posNdx += numComponents;
-      positions.set(getPoint(lat0, long1), posNdx);  posNdx += numComponents;
-      positions.set(getPoint(lat1, long1), posNdx);  posNdx += numComponents;
-
-      indices.push(
-        ndx, ndx + 1, ndx + 2,
-        ndx + 2, ndx + 1, ndx + 3,
-      );
-      ndx += 4;
-    }
-  }
-  return { positions, indices };
-}
-
-

만든 함수를 다음처럼 호출합니다.

-
const segmentsAround = 24;
-const segmentsDown = 16;
-const { positions, indices } = makeSpherePositions(segmentsAround, segmentsDown);
-
-

여기서 반환된 위치값(positions)은 구체의 위치값을 기반으로 합니다. 이는 법선의 값(normals)과 -같으니 법선을 따로 구할 필요 없이 위치값을 복사해서 쓰면 됩니다.

-
const normals = positions.slice();
-
-

다음으로 각 속성을 지정합니다.

-
const geometry = new THREE.BufferGeometry();
-const positionNumComponents = 3;
-const normalNumComponents = 3;
-
-+const positionAttribute = new THREE.BufferAttribute(positions, positionNumComponents);
-+positionAttribute.setUsage(THREE.DynamicDrawUsage);
-geometry.setAttribute(
-    'position',
-+    positionAttribute);
-geometry.setAttribute(
-    'normal',
-    new THREE.BufferAttribute(normals, normalNumComponents));
-geometry.setIndex(indices);
-
-

예제에서 차이점이 있는 부분을 표시해 두었습니다. 위치 속성에 참조값을 넘겨주었고, 이 -속성이 동적이라고 명시했습니다. 이는 Three.js에게 해당 속성을 자주 변경될 수 있음을 -알려주는 역할이죠.

-

이제 render 함수에서 매 프레임마다 법선을 기준으로 위치값을 변경해줍니다.

-
const temp = new THREE.Vector3();
-
-...
-
-for (let i = 0; i < positions.length; i += 3) {
-  const quad = (i / 12 | 0);
-  const ringId = quad / segmentsAround | 0;
-  const ringQuadId = quad % segmentsAround;
-  const ringU = ringQuadId / segmentsAround;
-  const angle = ringU * Math.PI * 2;
-  temp.fromArray(normals, i);
-  temp.multiplyScalar(THREE.MathUtils.lerp(1, 1.4, Math.sin(time + ringId + angle) * .5 + .5));
-  temp.toArray(positions, i);
-}
-positionAttribute.needsUpdate = true;
-
-

마지막으로 positionAttribute.needsUpdate 속성을 활성화해 변화를 감지하도록 합니다.

-

- -

-

이 글이 BufferGeometry로 사용자 지정 geometry를 만들고, BufferAttribute를 다루는 -데 도움이 되었으면 좋겠습니다.

-

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/debugging-glsl.html b/manual/ko/debugging-glsl.html deleted file mode 100644 index aedf886464ea5c..00000000000000 --- a/manual/ko/debugging-glsl.html +++ /dev/null @@ -1,77 +0,0 @@ - - - GLSL 디버깅 - - - - - - - - - - - - - - -
-
-

GLSL 디버깅

-
-
-
-

이 사이트에서 자바스크립트 자체에 대해 다루지 않듯, 아직까지 이 사이트에는 GLSL에 대한 글이 없습니다. GLSL은 간단히 다루기 어려운 주제이기에 GLSL을 배우고 싶다면 이 시리즈를 참고하기 바랍니다.

-

GLSL에 대해 어느 정도 안다면 이 글이 GLSL 디버깅에 도움이 될 겁니다.

-

GLSL 쉐이더를 만들었는데 아무것도 나타나지 않을 때, 저는 보통 쉐이더를 fragment 쉐이더로 바꾼 뒤 특정 색상만 반환하게 합니다. 예를 들어 쉐이더의 가장 마지막에 다음과 같은 코드를 넣는 것이죠.

-
void main() {
-
-  ...
-
-  gl_FragColor = vec4(1, 0, 0, 1);  // 빨강
-}
-
-

만약 저렇게 해서 요소가 보인다면 fragment 쉐이더에 문제가 있는 겁니다. 텍스처가 깨진 건지, 균등 변수(uniform)를 지정하지 않은 건지, 균등 변수에 잘못된 값을 지정한 건지는 알 수 없지만 적어도 어디서 문제를 찾아야 할지는 알 수 있죠.

-

어디가 틀렸는지 확인하기 위해 몇 가지 요소를 실제로 렌더링해볼 수 있습니다. 예를 들어 fragment 쉐이더의 법선(normal)을 사용한다면 저는 다음과 같은 코드를 추가할 겁니다.

-
gl_FragColor = vec4(vNormal * 0.5 + 0.5, 1);
-
-

법선의 값은 -1 에서 +1 사이이기에 0.5를 곱한 뒤 0.5를 더하면 값을 0.0 에서 0.1로 만들어 색으로 표현할 수 있습니다.

-

실행이 잘 된 예제에 이 쉐이더를 적용해보면 법선이 보통(normally) 어떻게 생겼는지 알 수 있습니다. 만약 법선이 정상적으로 보이지 않는다면 그게 다음 문제를 찾을 단서가 되겠죠. fragment 쉐이더에서 법선의 값을 변경하는 경우 같은 방법을 이용해 결과값을 렌더링할 수 있습니다.

-
- -

마찬가지로 텍스처를 사용하는 경우에는 텍스처 좌표가 있으니 그걸로 다음과 같이 활용할 수 있습니다.

-
gl_FragColor = vec4(fract(vUv), 0, 1);
-
-

텍스처 좌표를 사용하는 경우, fract를 실행한 값은 0부터 1 사이의 값이 아닐 수 있습니다. 대상 물체가 텍스처보다 크고 texture.repeat을 사용한 경우 1보다 큰 숫자가 나올 수 있겠죠.

-
- -

fragment 쉐이더의 다른 값으로도 비슷한 방법을 사용할 수 있습니다. 해당 값의 범위를 알아낸 뒤 해당 값을 0.0부터 1.0 사이의 값으로 변환해 gl_FragColor에 지정하는 거죠.

-

텍스처를 확인하려면 CanvasTextureDataTexture를 사용해보기 바랍니다.

-

만약 gl_FragColor에 빨간색을 지정했는데도 아무것도 보이지 않는다면, vertex 쉐이더 관련 물체의 방향 문제일 수 있습니다. 특정 행렬 좌표(matrix)가 잘못되었거나 어떤 속성의 값이 잘못되었거나 설정이 이상할 수 있죠.

-

그럼 먼저 행렬 좌표를 살펴봐야 합니다. renderer.render(scene, camera)를 한 번만 호출한 뒤 콘솔에서 객체를 펼쳐 보는 것이죠. 카메라의 전역 행렬 좌표나 투사 행렬 좌표에 NaN이 있는 걸까요? 장면 객체를 펼쳐 children 속성을 확인하니 전역 행렬 좌표는 멀쩡해 보입니다(NaN이 없음). 또한 다른 행렬 좌표의 4가지 값들도 모두 멀쩡해 보입니다. 만약 장면의 크기가 50x50x50인데 어떤 좌표값이 552352623.123이라면 확실히 뭔가 잘못된 겁니다.

-
- -

fragment 쉐이더와 마찬가지로 vertex 쉐이더에서도 값을 fragment 쉐이더에 넘겨주는 방식으로 값을 시각화할 수 있습니다. 양 쉐이더에 동일하게 변수를 생성한 뒤, 의심되는 값을 넘겨주는 거죠. 저라면 fragment 쉐이더가 그 값을 표시하도록 의심되는 값을 vNormal에 0.0에서 1.0 사이의 값으로 바꿔 지정할 겁니다. 그런 다음 결과를 보고 예상과 일치하는지 확인하는 것이죠.

-

이 방법이 통하지 않는다면 어떤 vertex 쉐이더가 geometry를 가장 간단하게 표시할 수 있을까요? 아주 간단하게 다음처럼 할 수 있을 겁니다.

-
gl_Position = projection * modelView * vec4(position.xyz, 1);
-
-

저 코드가 잘 작동한다면 변경사항을 조금씩 적용해보기 바랍니다.

-

추가로 크롬의 확장 프로그램 중 쉐이더 에디터나 다른 브라우저의 비슷한 프로그램을 찾아 사용하는 방법도 있습니다. 이는 다른 쉐이더가 어떻게 작동하는지 볼 때도 유용하죠. 또한 위 변경 사항을 바로 적용해볼 수 있다는 것도 장점입니다.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/debugging-javascript.html b/manual/ko/debugging-javascript.html deleted file mode 100644 index 51d267dda6a848..00000000000000 --- a/manual/ko/debugging-javascript.html +++ /dev/null @@ -1,369 +0,0 @@ - - - 자바스크립트 디버깅 - - - - - - - - - - - - - - -
-
-

자바스크립트 디버깅

-
-
-
-

이 글은 Three.js에 한정된 글이 아닙니다. 보다는 자바스크립트를 디버깅하는 일반적인 방법이라고 하는 편이 적당하죠. Three.js를 배우려는 분들 중에는 자바스크립트도 처음 접하는 분들이 더러 있는데, 이 글이 그런 분들에게 도움이 되었으면 좋겠네요.

-

디버깅은 방대한 주제라 여기서 모든 부분을 다루기는 어렵습니다. 이 글에서는 자바스크립트 입문자의 경우를 가정해 몇 가지 중요한 부분을 짚고 넘어갈 겁니다. 디버깅은 나중에 무엇을 배우든 도움이 되니 시간을 들여 꼭 살펴보기 바랍니다.

-

브라우저 개발자 도구

-

크롬, 파이어폭스, 사파리, 엣지 등 모든 브라우저에는 개발자 도구가 있습니다.

-

크롬에서는 아이콘을 클릭해 "도구 더보기 -> 개발자 도구"를 선택하면 개발자 도구를 열 수 있습니다. 기본 단축키도 거기에 같이 표시되죠.

-
- -

파이어폭스에서는 아이콘을 클린한 뒤 "웹 개발자 -> 도구 표시/숨기기"를 선택하면 됩니다.

-
- -

사파리는 먼저 고급 설정에서 개발자 메뉴를 활성화해야 합니다.

-
- -

그리고 개발자 메뉴에서 "Show/Connect Web Inspector"를 선택하면 되죠.

-
- -

크롬의 경우는 PC 버젼 크롬을 이용해 안드로이드 기기의 크롬을 디버깅할 수 있습니다. 사파리에서도 macOS 컴퓨터에서 아이폰이나 아이패드의 사파리를 디버깅할 수 있죠.

-

저는 크롬이 더 익숙하기에 크롬을 기준으로 설명하겠습니다. 다른 브라우저들도 다 비슷한 기능이 있으니 그다지 어렵지 않게 배운 걸 써먹을 수 있을 거예요.

-

캐시 끄기

-

브라우저는 내려 받은 데이터를 재사용합니다. 사용자 입장에서는 사이트를 두 번 이상 방문했을 때 파일을 다시 다운 받지 않아도 되니 환영할 일이죠.

-

하지만 개발자의 입장에서는 이 기능이 불편할 수 있습니다. 파일을 수정한 뒤 페이지를 새로고침해도 브라우저가 캐시로 저장된 파일을 사용해 수정된 내용이 반영되지 않기 때문이죠.

-

웹 개발을 하는 동안 캐싱을 비활성화 하면 이 문제를 해결할 수 있습니다. 이러면 브라우저가 항상 파일의 최신 버젼을 받아 사용하죠.

-

먼저 개발자 도구의 메뉴에서 설정을 선택한 뒤

-
- -

"Disable Cache (while DevTools is open)"을 선택합니다(개발자 도구가 열렸을 때 캐시 비활성화).

-
- -

자바스크립트 콘솔

-

개발자 도구에는 console이라는 탭이 있습니다. 여기에 각종 경고나 오류 메시지가 출력되죠.

-

Three.js를 사용하는 경우 1개 내지 2개의 메시지가 보이는 게 정상입니다.

-
- -

다른 메시지가 보인다면 이를 확인해봐야 합니다. 예를 들어 아래와 같은 경우

-
- -

오타가 났습니다. "three"를 "threee"로 잘못 썼죠.

-

뿐만 아니라 console.log로 직접 메시지를 출력할 수도 있습니다.

-
console.log(someObject.position.x, someObject.position.y, someObject.position.z);
-
-

살펴보고 싶은 객체를 출력할 수도 있습니다. 예를 들어 glTF 불러오기의 예제에서 root 장면(scene) 요소를 출력해보죠.

-
  {
-    const gltfLoader = new GLTFLoader();
-    gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
-      const root = gltf.scene;
-      scene.add(root);
-+      console.log(root);
-
-

이제 자바스크립트 콘솔에서 해당 객체를 펼쳐 볼 수 있습니다.

-
- -

이외에도 console.error를 이용해 빨간 에러 메시지, console.warn을 이용해 노란 경고 메시지 등을 띄울 수 있습니다.

-

화면에 데이터 띄우기

-

이는 아주 간단하지만 사람들이 쉽게 떠올리지 못하는 방법입니다. <div><pre> 태그를 이용해 데이터를 화면에 출력하는 것이죠.

-

가장 쉬운 방법은 HTML 요소를 몇 개 만들고

-
<canvas id="c"></canvas>
-+<div id="debug">
-+  <div>x:<span id="x"></span></div>
-+  <div>y:<span id="y"></span></div>
-+  <div>z:<span id="z"></span></div>
-+</div>
-
-

캔버스 위에 올라가도록 스타일을 지정하는 겁니다(캔버스가 화면을 꽉 채웠다고 가정합니다).

-
<style>
-#debug {
-  position: absolute;
-  left: 1em;
-  top: 1em;
-  padding: 1em;
-  background: rgba(0, 0, 0, 0.8);
-  color: white;
-  font-family: monospace;
-}
-</style>
-
-

그런 다음 요소들을 참조해 각 요소에 텍스트를 지정합니다.

-
// 초기화 시
-const xElem = document.querySelector('#x');
-const yElem = document.querySelector('#y');
-const zElem = document.querySelector('#z');
-
-// 렌더링 루프나 업데이트 시
-xElem.textContent = someObject.position.x.toFixed(3);
-yElem.textContent = someObject.position.y.toFixed(3);
-zElem.textContent = someObject.position.z.toFixed(3);
-
-

런타임에 값을 체크할 때 유용합니다.

-

- -

-

일회용 로그 버퍼(one-time log buffer)*를 만드는 것도 한 방법입니다. 일회용 로그 버퍼라는 이름은 제가 대충 지은 것이지만, 많은 게임들이 이 방법을 사용합니다. 일회용이라는 말처럼 이 방법은 지정한 메시지를 한 프레임 동안만 보여줍니다. 데이터를 출력할 부분에서 직접 매 프레임마다 메시지를 버퍼에 추가해야 하죠. 메시지마다 HTML 요소를 따로 만들었던 위 방법에 비하면 훨씬 경제적입니다.

-

※ 일회용 로그 버퍼: 원문에서는 clearing logger라고 썼습니다. 역주.

-

먼저 위 HTML 예제를 아래처럼 수정합니다.

-
<canvas id="c"></canvas>
-<div id="debug">
-  <pre></pre>
-</div>
-
-

그리고 버퍼의 추가/초기화를 관리하는 간단한 클래스를 만듭니다.

-
class ClearingLogger {
-  constructor(elem) {
-    this.elem = elem;
-    this.lines = [];
-  }
-  log(...args) {
-    this.lines.push([...args].join(' '));
-  }
-  render() {
-    this.elem.textContent = this.lines.join('\n');
-    this.lines = [];
-  }
-}
-
-

간단한 예제를 하나 만들어봅시다. 화면을 클릭했을 때 포인터 위치에 mesh를 만들고 2초 동안 무작위 방향으로 움직인 후 사라지도록 하겠습니다. 먼저 반응형 디자인에서 썼던 예제를 가져옵니다.

-

아래는 화면을 클릭할 때마다 Mesh를 추가하는 코드입니다.

-
const geometry = new THREE.SphereGeometry();
-const material = new THREE.MeshBasicMaterial({ color: 'red' });
-
-const things = [];
-
-function rand(min, max) {
-  if (max === undefined) {
-    max = min;
-    min = 0;
-  }
-  return Math.random() * (max - min) + min;
-}
-
-function createThing() {
-  const mesh = new THREE.Mesh(geometry, material);
-  scene.add(mesh);
-  things.push({
-    mesh,
-    timer: 2,
-    velocity: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5)),
-  });
-}
-
-canvas.addEventListener('click', createThing);
-
-

아래는 생성한 mesh를 움직이고, 메시지를 출력한 뒤 일정 시간이 지났을 때 해당 요소를 제거하는 코드입니다.

-
const logger = new ClearingLogger(document.querySelector('#debug pre'));
-
-let then = 0;
-function render(now) {
-  now *= 0.001;  // 초 단위로 변환
-  const deltaTime = now - then;
-  then = now;
-
-  ...
-
-  logger.log('fps:', (1 / deltaTime).toFixed(1));
-  logger.log('num things:', things.length);
-  for (let i = 0; i < things.length;) {
-    const thing = things[i];
-    const mesh = thing.mesh;
-    const pos = mesh.position;
-    logger.log(
-        'timer:', thing.timer.toFixed(3),
-        'pos:', pos.x.toFixed(3), pos.y.toFixed(3), pos.z.toFixed(3));
-    thing.timer -= deltaTime;
-    if (thing.timer <= 0) {
-      // 해당 요소를 제거합니다. i를 증가시키지 않았다는 점에 유의하세요.
-      things.splice(i, 1);
-      scene.remove(mesh);
-    } else {
-      mesh.position.addScaledVector(thing.velocity, deltaTime);
-      ++i;
-    }
-  }
-
-  renderer.render(scene, camera);
-  logger.render();
-
-  requestAnimationFrame(render);
-}
-
-

예제를 빠르게 클릭하면 많은 데이터가 한 번에 나타날 겁니다.

-

- -

-

쿼리 파라미터(Query Parameters)

-

웹에서는 쿼리 파라미터(?, &)나 앵커(#)로 데이터를 넘겨줄 수 있습니다. 이들은 search, 해시(hash)라고 불리기도 하죠.

-
    https://domain/path/?query#anchor
-
-

예를 들어 이전 예제를 URL에 ?debug=true가 있을 때만 디버깅 요소가 보이도록 해보겠습니다.

-

먼저 쿼리를 파싱하는 코드를 만듭니다.

-
/**
-  * 쿼리 파라미터를 키/값 객체로 반환합니다.
-  * 예를 들어 쿼리 파라미터가 다음과 같다면
-  *
-  *    abc=123&def=456&name=gman
-  *
-  * `getQuery()`는 다음과 같은 객체를 반환합니다.
-  *
-  *    {
-  *      abc: '123',
-  *      def: '456',
-  *      name: 'gman',
-  *    }
-  */
-function getQuery() {
-  return Object.fromEntries(new URLSearchParams(window.location.search).entries());
-}
-
-

다음으로 디버깅 요소가 미리 보이지 않도록 합니다.

-
<canvas id="c"></canvas>
-+<div id="debug" style="display: none;">
-  <pre></pre>
-</div>
-
-

앞서 작성했던 예제에서 쿼리 파라미터를 읽어 ?debug=true일 때만 디버깅 요소가 보이도록 합니다.

-
const query = getQuery();
-const debug = query.debug === 'true';
-const logger = debug
-   ? new ClearingLogger(document.querySelector('#debug pre'))
-   : new DummyLogger();
-if (debug) {
-  document.querySelector('#debug').style.display = '';
-}
-
-

?debug=true가 아닐 때 사용할 DummyLogger도 만듭니다.

-
class DummyLogger {
-  log() {}
-  render() {}
-}
-
-

아래와 같은 url로 접근하면 아무것도 보이지 않습니다.

-

debug-js-params.html

-

대신 아래 url을 쓰면

-

debug-js-params.html?debug=true

-

디버깅 요소가 제대로 보입니다.

-

파라미터를 여러 개 전달할 때는 아무페이지.html?키=값&다른키=다른값처럼 각 항목을 '&'로 구분하면 됩니다. 파라미터를 사용하면 다양한 값을 지정할 수 있습니다. speed=0.01을 넘겨 받아 앱의 속도를 느리게 할 수도 있고 showHelpers=true 같은 값을 넘겨 받아 다른 글에서 다뤘던 조명, 그림자, 카메라의 절두체 등의 헬퍼 객체를 보이게 할 수도 있죠.

-

디버거 사용법을 익혀라

-

브라우저에는 전부 디버거가 있어 프로그램을 줄 단위로 실행하며 모든 변수를 검사할 수 있습니다.

-

디버거의 사용법은 너무 큰 주제이기에 이 글에서 전부 설명하기 어려우니 아래 링크를 참고하시기 바랍니다.

- -

디버거 등에서 NaN을 확인해라

-

NaN은 Not a Number(숫자가 아님)의 줄임말입니다. 자바스크립트가 수학적으로 연산할 수 없는 식이 주어졌을 때 반환하는 값이죠.

-

아래는 간단한 예입니다.

-
- -

저도 무언가를 만들다 화면에 아무것도 보이는 게 없으면 NaN이 있는지 확인합니다. 만약 NaN이 있다면 해당 부분부터 다시 살펴 보기 시작하죠.

-

glTF 파일 불러오기에서 처음 패스를 그렸을 때 SplineCurve 클래스로 2D 곡선을 만들었던 것, 기억하나요?

-

처음에 이 곡선을 이용해 자동차를 움직였었죠.

-
curve.getPointAt(zeroToOnePointOnCurve, car.position);
-
-

이때 curve.getPointAt은 내부적으로 두 번째 인자로 넘겨준 객체의 set 메서드를 호출합니다. 이 경우 인자로 넘겨준 객체는 car.position, Vector3이죠. Vector3set 메서드에는 x, y, z 세 가지 인자를 넘겨줘야 합니다. 하지만 SplineCurve는 2D이기에 car.position.set 메서드에 x와 y 값만 넘겨줍니다.

-

이러면 x, y의 값은 넘겨 받은 값으로 지정되나, z는 undefined로 지정될 겁니다.

-

디버거에서 자동차의 matrixWorld를 살펴 보니 NaN으로 지정된 값이 많이 보입니다.

-
- -

행렬 좌표(matrix)에 NaN이 있다는 것은 position, rotation, scale 등의 속성이나 행렬 좌표에 영향을 미치는 함수에 잘못된 데이터가 지정되었다는 것을 의미합니다. 여기서부터 추적해 올라가면 어디가 문제인지 쉽게 찾을 수 있겠죠.

-

자바스크립트 숫자에는 NaN뿐만 아니라 Infinity라는 값도 있습니다. 이 역시 많은 버그의 주범이 될 수 있죠.

-

코드를 까봐라!

-

Three.js는 오픈 소스입니다. 두려워 말고 코드를 한 번 까보세요! 깃허브에서 소스 코드를 살펴 보거나, 디버거에서 함수를 차례대로 추적해 볼 수도 있습니다. 이때 개발 단계에서는 three.min.js가 아닌 three.js를 쓰는 것을 추천합니다. 왜냐하면 three.min.js는 용량을 줄이기 위해 난독화와 압축이 적용된 형태거든요. three.js가 용량이 더 크긴 하지만 디버깅에는 훨씬 유리합니다. 저도 문제가 있을 때는 대부분 three.js를 사용해 코드를 단계별로 살펴보는 편입니다.

-

requestAnimationFrame을 렌더링 가장 마지막에 넣어라

-

다른 사람의 코드를 보다보면 아래와 같은 형식을 자주 봅니다.

-
function render() {
-   requestAnimationFrame(render);
-
-   // -- 주절주절 --
-
-   renderer.render(scene, camera);
-}
-requestAnimationFrame(render);
-
-

하지만 저는 이 시리즈의 처음에서부터 requestAnimationFrame을 아래에 배치했습니다.

-
function render() {
-   // -- 주절주절 --
-
-   renderer.render(scene, camera);
-
-   requestAnimationFrame(render);
-}
-requestAnimationFrame(render);
-
-

이런 방식을 사용한 가장 중요한 이유는, 이래야 에러가 났을 때 코드를 멈출 수 있기 때문입니다. requestAnimationFrame을 상단에서 실행하면 에러가 나기 전에 이미 다른 프레임을 요청한 것이므로, 에러가 반복해 나타날 수 있습니다. 개인적인 생각이긴 하지만, 에러를 단순히 무시해버리는 것보다 문제를 찾아 고치는 게 훨씬 나을 겁니다. 에러 때문에 뭔가 제대로 실행되지 않는데 프로그램이 멈추지 않는다면 문제가 있는지조차 모를 수 있으니까요.

-

단위를 확인하라!

-

이는 예를 들어 언제 도(degree)를 사용해야 하는지, 언제 라디안(호도, radian)을 사용해야 하는지 알아야 한다는 의미입니다. 아쉽게도 Three.js는 모든 부분에서 같은 단위를 사용하지 않습니다. 당장 생각나기로는 카메라의 시야각(fov, field of view)이 도를 사용하는군요. 나머지 각도는 전부 라디안을 사용합니다.

-

길이 단위도 중요한 요소입니다. 최근까지도 3D 앱은 길이 단위를 자유롭게 선택할 수 있었습니다. 어떤 앱은 1칸 = 1cm를 썼고 어떤 앱은 1칸 = 1피트를 썼죠. 물론 지금도 길이 단위를 얼마든지 자유롭게 설정할 수 있습니다. Three.js는 기본적으로 1칸 = 1미터로 가정하는데, 물리 기반 렌더링 같은 경우는 조명의 세기를 미터로 설정하니 특히 더 신경을 써야 합니다. 증강현실(AR)이나 가상현실(VR) 같은 경우에도 사용자와 컨트롤러 사이의 거리를 알아야 하니 실제 세계의 단위를 잘 적용해야 하죠.

-

질문할 때 최소한으로, 완성된, 테스트할 수 있는 예제를 만들어라

-

Three.js에 관해 질문할 때는 MCVE에 맞춰 최소한으로(Minimal), 완성된(Complete), 테스트할 수 있는(Verifiable) 예제(Example)를 포함해야 합니다.

-

최소한으로 만드는 게 가장 중요합니다. 예를 들어 여러분이 glTF 파일 불러오기에서 경로를 자동차를 따라가게 하는 데 어려움을 겪는다고 해보죠. 이 예제에는 아주 많은 요소가 있습니다. 대충 목록을 만들어보죠.

-
    -
  1. HTML 한 묶음
  2. -
  3. CSS 약간
  4. -
  5. 조명
  6. -
  7. 그림자
  8. -
  9. 그림자를 조작하는 lil-gui
  10. -
  11. .gltf 파일을 불러오는 코드
  12. -
  13. 캔버스를 리사이징하는 코드
  14. -
  15. 자동차가 경로를 따라 움직이도록 하는 코드
  16. -
-

꽤 많은 양의 코드네요. 만약 오로지 경로를 따라가는 부분에 대해서만 질문할 거라면 <canvas><script>를 제외한 나머지 HTML은 없어도 될 겁니다. .gltf 파일도 마찬가지로 불필요하죠. 조명과 그림자를 없애고 MeshBasicMaterial을 사용할 수도, lil-gui를 제거할 수도 있습니다. 차라리 GridHelper를 쓰는 게 훨씬 간단하겠네요. 마지막으로 물체가 경로를 따라 움직이는 부분만 질문하니 자동차 대신 육면체를 써도 될 겁니다.

-

아래는 위 요소를 모두 고려한 예제입니다. 원래 271줄이었던 코드를 135줄로 줄였죠. 21개 점으로 이루어진 복잡한 경로 대신 3개 혹은 4개의 점만 사용하는 간단한 경로를 사용해 코드를 더 줄일 수도 있을 겁니다.

-

- -

-

OrbitController를 그대로 둔 건 카메라를 움직이는 등 단순히 다른 사람의 편의를 위해서지만, 경우에 따라서는 저것도 제거할 수 있을 겁니다.

-

MCVE의 장점은 이렇게 예제를 만들다 문제가 해결되기도 한다는 겁니다. 불필요한 요소를 제거하고 에러를 재현할 수 있는 가장 작은 코드를 짜다 보면 문제의 원인이 밝혀지는 경우가 꽤 많거든요.

-

거기다 MCVE는 답변자에 대한 예의이기도 합니다. 최소한의 예제를 만들어 다른 사람이 여러분의 문제 해결을 돕기 쉽도록 배려하는 것이죠. 이 과정에서 배우는 것도 많고요.

-

또한 스택 오버플로우에서 질문글을 올릴 때 코드의 일부분(snippet)을 올리는 것도 굉장히 중요합니다. 물론 JSFiddle이나 Codepen 또는 비슷한 사이트를 써 여러분이 만든 MVCE를 테스트하게끔 할 수도 있죠. 하지만 스택 오버플로우에서 글 쓰기를 눌러보면 질문 자체에 코드 일부를 붙여넣는 게 필수 조건라는 것을 알 수 있습니다. 코드의 일부분을 집어 넣어야만 해당 조건이 만족되죠.

-

혹시나 해서 말해두지만, 이 사이트의 예제들로 질문할 때는 코드의 모든 부분을 확인해야 합니다. HTML, CSS, 자바스크립트를 전부 스니펫(snippet) 에디터에 넣으면 되죠. 추가로 궁금한 부분과 관련된 코드만 남기고 나머지는 없애 코드를 최소한으로 만드는 것만 기억하기 바랍니다.

-

이 정도만 따르면 질문에 대한 답변을 받는 게 그다지 어렵지 않을 겁니다.

-

MeshBasicMaterial을 사용하라

-

MeshBasicMaterial은 조명의 영향을 받지 않습니다. 때문에 재질(material)을 이걸로 바꾸면 보이지 않았던 물체가 보이는 경우가 있죠. 만약 어떤 물체가 MeshBasicMaterial을 적용했을 때는 잘 나타나는데 다른 재질로 바꾸면 나타나지 않는다면, 해당 문제는 재질이나 조명 또는 다른 외부 요소 때문일 겁니다.

-

카메라의 nearfar 설정을 확인하라

-

카메라에 관한 글에서 다뤘듯, PerspectiveCamera에는 nearfar 설정이 있습니다. 다른 것보다 먼저 장면 요소를 전부 담을 수 있도록 이 값들을 설정하세요. 임시로라도 near를 0.001, far를 1000000 등 임의의 큰 값으로 설정한다면 깊이에 따른 해상도 문제가 나타날 겁니다. 오직 카메라에 가까운 물체만 제대로 보이겠죠.

-

장면이 카메라 앞에 있는지 확인하라

-

장면이 화면에 나타나지 않는 경우, 장면이 카메라 앞에 없는 게 원인일 때도 있습니다. 만약 카메라를 조작할 수 없다면 OrbitController 같은 액션을 추가해 카메라를 돌려 보기 바랍니다. 또는 제가 이 글에서 다뤘던 것처럼 카메라가 장면 전체를 담도록 해보세요. 제가 저 글에서 쓴 코드는 장면의 요소를 찾아 해당 요소를 전부 담도록 카메라의 위치와 near, far 값을 조정합니다. 저 코드에서 디버거를 사용하거나, console.log를 추가해 요소의 크기나 장면의 중점을 출력해 볼 수도 있죠.

-

뭐든 카메라 앞에 배치해봐라

-

이건 문제가 생겼는데 어떤 점이 문제일지 모를 때, 하나씩 천천히 요소를 추가해보라는 말입니다. 만약 코드를 실행했는데 장면에 아무것도 보이지 않는다면 카메라 바로 앞에 간단한 요소를 배치해보세요. 구체나 육면체를 만들어 MeshBasicMaterial 같이 단순한 재질을 지정해 화면에 제대로 뜨는지 확인하는 겁니다. 그런 다음 시간을 들여 다른 요소들을 하나하나 추가해 나가는 거죠. 이러다보면 버그가 다시 나타나거나 어떤 게 문제인지 알 수 있을 겁니다.

-
-

여기까지 자바스크립트를 디버깅하는 몇 가지 팁을 살펴봤습니다. 다음 글에서는 GLSL을 디버깅하는 몇 가지 팁에 대해 알아보겠습니다.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/fog.html b/manual/ko/fog.html deleted file mode 100644 index 30847d540ce113..00000000000000 --- a/manual/ko/fog.html +++ /dev/null @@ -1,269 +0,0 @@ - - - 안개(Fog) - - - - - - - - - - - - - - -
-
-

안개(Fog)

-
-
-
-

※ 이 글은 Three.js의 튜토리얼 시리즈로서, -먼저 Three.js의 기본 구조에 관한 글을 -읽고 오길 권장합니다.

-

※ 카메라에 대해 잘 모른다면, 먼저 카메라에 관한 글을 -먼저 읽기 바랍니다.

-

3D 엔진에서 안개란, 일반적으로 카메라로부터의 거리에 따라 특정 색상으로 -점차 변화하는 것을 말합니다. Three.js에서는 FogFogExp2 객체를 -생성한 뒤, 장면(scene)의 fog 속성에 지정해 안개를 사용합니다.

-

Fog는 인자로 nearfar값을 받는데, 이는 카메라로부터의 거리값입니다. -near값보다 가까운 공간은 안개의 영향이 전혀 없고, far값보다 먼 공간은 -완전히 안개에 뒤덮입니다. nearfar 사이의 공간에 있는 물체 또는 물체의 -일부는 점차 안개의 색으로 변화하죠.

-

FogExp2는 카메라에서 멀어질수록 안개의 강도가 강해집니다.

-

두 가지 안개 모두 마찬가지로, 안개를 사용하려면 장면의 속성에 지정해야 합니다.

-
const scene = new THREE.Scene();
-{
-  const color = 0xFFFFFF;  // 하양
-  const near = 10;
-  const far = 100;
-  scene.fog = new THREE.Fog(color, near, far);
-}
-
-

FogExp2의 경우는 다음처럼 쓸 수 있죠.

-
const scene = new THREE.Scene();
-{
-  const color = 0xFFFFFF;
-  const density = 0.1;
-  scene.fog = new THREE.FogExp2(color, density);
-}
-
-

FogExp2가 더 현실적이긴 하나, 보통 안개의 범위를 특정하기 쉬운 -Fog를 더 많이 사용합니다.

-
-
-
-
THREE.Fog
-
-
-
-
THREE.FogExp2
-
-
- -

한 가지 알아둬야 하는 건 안개는 렌더링되는 물체라는 점입니다. -안개는 물체의 픽셀을 렌더링할 때 같이 렌더링되는데, 이 말은 장면에 -특정 색상의 안개 효과를 주려면 안개와 배경색 둘 다 같은 색으로 -지정해야 한다는 겁니다. 배경색은 scene.background -속성을 THREE.Color 인스턴스로 지정해 바꿀 수 있습니다.

-
scene.background = new THREE.Color('#F00');  // 빨강
-
-
-
-
-
파란 안개, 빨간 배경
-
-
-
-
파란 안개, 파란 배경
-
-
- -

아래는 이전에 사용했던 예제에 안개를 추가한 것입니다. 장면을 생성한 뒤 -안개를 추가하고, 장면의 배경색을 바꾸기만 했죠.

-
const scene = new THREE.Scene();
-
-+{
-+  const near = 1;
-+  const far = 2;
-+  const color = 'lightblue';
-+  scene.fog = new THREE.Fog(color, near, far);
-+  scene.background = new THREE.Color(color);
-+}
-
-

아래 예제의 카메라는 near값이 0.1, far값이 5입니다. 카메라의 위치는 -z = 2이죠. 정육면체의 크기는 1칸이고, z축의 원점에 있습니다. 여기서 -안개를 near = 1, far = 2로 설정하면 정육면체가 중간부터 사라지기 -시작하겠죠.

-

- -

-

인터페이스를 추가해 안개를 조정할 수 있도록 하겠습니다. 이번에도 lil-gui를 -사용할 거예요. lil-gui는 객체와 객체의 속성 키값을 받아 자동으로 인터페이스를 -생성합니다. 단순히 안개의 nearfar 제어하도록 설정할 수도 있지만, near값이 -far값보다 큰 경우는 없기에 헬퍼를 만들어 near값을 항상 far보다 같거나 -작게, far값을 항상 near보다 같거나 크게 설정하도록 하겠습니다.

-
/**
- * 이 클래스의 인스턴스를 lil-gui에 넘겨
- * near나 far 속성을 조정할 때 항상
- * near는 never >= far, far는 never <= near가 되도록 합니다
- **/
-class FogGUIHelper {
-  constructor(fog) {
-    this.fog = fog;
-  }
-  get near() {
-    return this.fog.near;
-  }
-  set near(v) {
-    this.fog.near = v;
-    this.fog.far = Math.max(this.fog.far, v);
-  }
-  get far() {
-    return this.fog.far;
-  }
-  set far(v) {
-    this.fog.far = v;
-    this.fog.near = Math.min(this.fog.near, v);
-  }
-}
-
-

방금 만든 클래스를 아래처럼 활용합니다.

-
{
-  const near = 1;
-  const far = 2;
-  const color = 'lightblue';
-  scene.fog = new THREE.Fog(color, near, far);
-  scene.background = new THREE.Color(color);
-+
-+  const fogGUIHelper = new FogGUIHelper(scene.fog);
-+  gui.add(fogGUIHelper, 'near', near, far).listen();
-+  gui.add(fogGUIHelper, 'far', near, far).listen();
-}
-
-

nearfar 인자는 각 안개 속성의 최솟값과 최댓값입니다.

-

마지막 2줄의 .listen() 메서드를 호출하면 lil-gui가 변화를 감지합니다. -near 속성을 바꿀 때 동시에 far 속성을 재할당하고, far 속성을 바꿀 때도 -동시에 near를 재할당하는데, 이 메서드를 호출하면 조작한 속성 외의 다른 -속성의 변화도 UI에 업데이트됩니다.

-

여기에 안개의 색까지 조정할 수 있으면 금상첨화겠네요. 하지만 아까 설명했듯 -안개의 색을 바꾸려면 배경색도 같이 바꿔야 합니다. 헬퍼 클래스에 가상 속성을 -하나 만들어 lil-gui가 이 속성을 변경할 때 배경색과 안개색을 같은 값으로 -바꿔주면 어떨까요?

-

lil-gui의 색상 타입은 4가지입니다. 하나는 CSS의 6자리 16진수 문자열(hex string, 예: #f8f8f8)이고, -하나는 hue(색상), saturation(채도), value 객체(예: { h: 60, s: 1, v: 0 }), -하나는 RGB 배열(예: [ 255, 128, 64 ]) 또는 RGBA 색상 배열(예: [ 127, 200, 75, 0.3 ])이죠.

-

lil-gui가 하나의 값만 조작하도록 하는 게 제일 간단하니, 16진수 문자열을 사용하겠습니다. -다행히 THREE.Color에는 getHexString 메서드가 있어 색상을 -문자열로 쉽게 바꿀 수 있죠. 앞에 '#'만 덧붙이면 됩니다.

-
/**
- * 이 클래스의 인스턴스를 lil-gui에 넘겨
- * near나 far 속성을 조정할 때 항상
- * near는 never >= far, far는 never <= near가 되도록 합니다
- **/
-+// 또 lil-gui가 color 속성을 조작할 때 안개와 배경색을 동시에 변경합니다
-class FogGUIHelper {
-*  constructor(fog, backgroundColor) {
-    this.fog = fog;
-+    this.backgroundColor = backgroundColor;
-  }
-  get near() {
-    return this.fog.near;
-  }
-  set near(v) {
-    this.fog.near = v;
-    this.fog.far = Math.max(this.fog.far, v);
-  }
-  get far() {
-    return this.fog.far;
-  }
-  set far(v) {
-    this.fog.far = v;
-    this.fog.near = Math.min(this.fog.near, v);
-  }
-+  get color() {
-+    return `#${this.fog.color.getHexString()}`;
-+  }
-+  set color(hexString) {
-+    this.fog.color.set(hexString);
-+    this.backgroundColor.set(hexString);
-+  }
-}
-
-

이번에는 gui.addColor 메서드를 호출합니다. 색상 UI를 생성하는 메서드로, -방금 추가한 가상 속성을 조작하도록 설정합니다.

-
{
-  const near = 1;
-  const far = 2;
-  const color = 'lightblue';
-  scene.fog = new THREE.Fog(color, near, far);
-  scene.background = new THREE.Color(color);
-
-*  const fogGUIHelper = new FogGUIHelper(scene.fog, scene.background);
-  gui.add(fogGUIHelper, 'near', near, far).listen();
-  gui.add(fogGUIHelper, 'far', near, far).listen();
-+  gui.addColor(fogGUIHelper, 'color');
-}
-
-

- -

-

near를 1.9 정도, far를 2.0 정도로 설정하면 안개의 경계가 굉장히 -선명해질 겁니다. 정육면체들이 카메라에서 2칸 떨어져 있으므로 near를 -1.1, far를 2.9 정도로 설정하면 경계가 가장 부드러운 것이라고 할 수 -있죠.

-

추가로 재질(material)에는 불린 타입의 fog 속성이 있습니다. -해당 재질로 렌더링되는 물체가 안개의 영향을 받을지의 여부를 결정하는 속성이죠. -"안개 효과를 없애버리면 그만 아닌가?" 생각할 수 있지만, 3D 운전 시뮬레이터를 -만드는 경우를 상상해봅시다. 차 밖은 안개가 자욱하더라도 차 안에서 볼 때 차 내부는 -깔끔해야 할 수도 있죠.

-

안개가 짙은 날, 집 안에서 창 밖을 바라보는 장면이 더 와닿을지도 모르겠네요. -안개가 카메라로부터 2미터 이후부터 끼기 시작하고(near = 2), 4미터 이후에는 -완전히 안개에 덮히도록(far = 4) 설정합니다. 방은 2미터이고, 집은 최소 4미터입니다. -여기서 집 안의 재질이 안개의 영향을 받도록 놔둔다면 방 끝에서 창 밖을 바라볼 -때 방 안도 안개가 낀 것처럼 보이겠죠.

-
-
-
-
모든 재질의 fog: true
-
-
- -

방 끝 쪽 천장과 벽에 안개가 낀 것이 보일 겁니다. 집 내부 재질의 fog 옵션을 끄면 -안개를 없앨 수 있죠.

-
-
-
-
집 밖 물체의 재질만 fog: true
-
-
- -

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/fundamentals.html b/manual/ko/fundamentals.html deleted file mode 100644 index 20e551e487caaf..00000000000000 --- a/manual/ko/fundamentals.html +++ /dev/null @@ -1,406 +0,0 @@ - - - Three.js란? - - - - - - - - - - - - - - -
-
-

Three.js란?

-
-
-
-

Three.js 입문하신 것을 환영합니다! -Three.js는 웹페이지에 3D 객체를 쉽게 -렌더링하도록 도와주는 자바스크립트 3D 라이브러리입니다.

-

대부분의 경우 Three.js는 3D 객체를 렌더링하는 데 WebGL을 사용합니다. -때문에 Three.js = WebGL이라고 착각하기 쉽죠. -하지만 WebGL은 점, 선, 삼각형만을 그리는 아주 단순한 시스템입니다. -WebGL로 직접 무언가를 만들려면 상당히 많은 양의 코드를 짜야 하죠. -만약 씬(scenes), 광원, 그림자, 물체, 텍스처 등 3차원 세계를 구현한다면 머리도 꽤 복잡하겠거니와 -코드 자체도 굉장히 복잡할 겁니다. -Three.js는 이런 3D 요소들의 처리를 도와 직관적인 코드를 짤 수 있도록 해줍니다.

-

본 튜토리얼은 ES2015 문법을 적극적으로 사용할 것이므로, -여러분이 이미 최신 자바스크립트에 능숙하다는 가정 아래에 진행합니다. -자세한 내용에 대해서는 먼저 알아야 할 것을 참고하세요. -대부분의 모던 브라우저가 자동 업데이트 기능을 사용하므로, -특이한 환경이 아니라면 예시에 나온 코드를 실행하는 데 문제가 없을 것입니다. -만약 구형 브라우저에서도 코드가 실행되길 원한다면, 바벨(Babel) -같은 트랜스파일러를 찾아보세요. 물론 너무 오래된 엔진을 사용하는 브라우저에서는 -Three.js를 아예 구동하지 못할 수 있습니다.

-

새로운 프로그래밍 언어를 배울 때, "Hello World!"를 출력하며 시작했던 것을 기억하나요? -Three.js는 3차원 세계를 다루므로, 우리는 정육면체(cube)를 3차원으로 구현하는 것으로 튜토리얼을 -시작하고자 합니다. 이른바 "Hello Cube"라는 것이죠!

-

하지만 먼저 Three.js 앱의 구조부터 간략히 살펴보죠. Three.js 앱을 만들려면 -다양한 객체를 생성해 연결해야 합니다. 아래는 Three.js 앱의 구조를 도식화한 것입니다.

-
- -

위 도표에서 중요한 것들을 추려보죠.

-
    -
  • 먼저 Renderer가 있습니다. Three.js의 핵심 객체이죠. Renderer는 -SceneCamera 객체를 넘겨 받아 카메라의 절두체(frustum) -안 3D 씬의 일부를 평면(2차원) 이미지로 렌더링합니다.

    -
  • -
  • 씬 그래프(Scene graph)Scene 또는 -다수의 Mesh, Light, Group, Object3D, Camera로 이루어진 -트리 구조와 유사합니다. Scene은 씬 그래프의 최상위 노드로서 배경색(background color), -안개(fog) 등의 요소를 포함합니다. Scene에 포함된 객체들 또한 부모/자식의 -트리 구조로 이루어지며, 이는 각 객체의 유래와 방향성을 나타냅니다. 쉽게 말해 자식 객체의 -위치(position)와 방향(orientation)이 부모 기준이라는 거죠. 예를 들어 자동차의 바퀴가 -자동차 객체의 자식 객체라면, 자동차 객체의 방향을 움직일 때, 바퀴 객체의 방향 또한 같이 -움직입니다(더 자세한 내용은 씬 그래프에 관한 글에서 확인할 수 있습니다)

    -

    Camera가 도표에서 반쯤 나간 것이 보이나요? 이는 의도된 것으로, 다른 객체와 달리 -Camera는 굳이 씬 그래프에 포함될 필요가 없음을 보여주기 위함입니다. 물론 다른 -객체와 마찬가지로 Camera 또한 다른 객체의 자식 객체가 될 수 있습니다. 이러면 -부모 객체에 따라 Camera 또한 움직이겠죠. 씬 그래프에 관한 글 -마지막에 여러개의 Camera를 넣는 예제가 있으니 참고하시기 바랍니다.

    -
  • -
  • Mesh는 어떤 Material로 하나의 Geometry를 그리는 객체입니다. -Material, Geometry는 재사용이 가능하여 여러개의 Mesh가 하나의 -Material 또는 Geometry를 동시에 참조할 수 있습니다. 파란색 정육면체 2개를 -그린다고 해보죠. 일단 두 정육면체의 위치가 달라야 하니, 2개의 Mesh가 -필요합니다. 그리고 정점(vertex, 꼭지점) 데이터를 가진 한 개의 Geometry와 -채색을 위한 하나의 Material이 필요하겠죠. 이때 각 Mesh는 객체를 복사할 -필요 없이, 같은 Geometry 그리고 Material을 참조할 수 있습니다.

    -
  • -
  • Geometry는 기하학 객체의 정점 데이터입니다. 구(sphere), 정육면체(cube), -면(plane), 개, 고양이, 사람, 나무, 건물 등 아주 다양한 것이 될 수 있죠. Three.js는 -기본적으로 몇 가지의 내장(built-in) 기하학 객체를 제공합니다. -물론 직접 기하학 객체를 만들 수도 있고, -파일에서 기하학 객체를 불러올 수도 있죠.

    -
  • -
  • Material은 기하학 객체를 그리는 데 사용하는 표면 속성입니다. -색이나 밝기 등을 지정할 수 있죠. 하나의 Material는 여러개의 Texture를 사용할 수 -있습니다. 기하학 객체의 표면을 이미지로 덮어씌울 때 주로 사용하죠.

    -
  • -
  • Texture는 이미지나 파일에서 로드한 이미지, -canvas로 생성한 이미지 -또는 다른 Scene 객체에서 렌더링한 결과물에 해당합니다.

    -
  • -
  • Light여러 종류의 광원에 해당합니다.

    -
  • -
-

이제 기본적인 구조에 대해 배웠으니 아래와 같은 구조의 "Hello Cube"를 만들어 봅시다.

-
- -

먼저, Three.js를 로드합니다.

-
<script type="module">
-import * as THREE from 'three';
-</script>
-
-

type="module" 속성을 지정하는 것을 잊지 마세요. 이러면 import 키워드를 -통해 Three.js 모듈을 불러올 수 있습니다. 다른 방법으로 로드할 수도 있지만 -모듈 로드가 r106 이후 빌드에서 추천하는 방식입니다. 모듈로 로드하면 다른 의존성 -스크립트를 알아서 로드하기 때문에 <script> 태그를 일일이 작성하는 수고를 덜 -수 있습니다.

-

그리고 <canvas> 태그를 작성합니다.

-
<body>
-  <canvas id="c"></canvas>
-</body>
-
-

이제 Three.js에게 렌더링을 맡겨보죠.

-
<script type="module">
-import * as THREE from 'three';
-
-+function main() {
-+  const canvas = document.querySelector('#c');
-+  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+  ...
-</script>
-
-

canvas 요소(element)를 참조한 이후 WebGLRenderer를 생성했습니다. -이 렌더러(renderer)는 여러분이 입력한 데이터를 실제로 canvas에 그려주는

-

만약 Three.js에 canvas 요소를 넘겨주지 않으면, Three.js는 canvas 요소를 -자동으로 생성합니다. 그리고 여러분이 직접 이 canvas 요소를 문서(document) -에 삽입해야 하죠. 어디에 canvas 요소를 넣을지는 경우에 따라 다를 테니, -예제에서는 자바스크립트에서 요소를 참조하도록 하는 편이 호환성 면에서 -더 낫다고 판단했습니다. 만약 canvas 요소를 동적으로 삽입한다면 여러분이 -직접 코드를 고쳐야 할 테니까요.

-

다음으로 카메라가 필요하니 PerspectiveCamera(원근 카메라) 객체를 생성해봅시다.

-
const fov = 75;
-const aspect = 2;  // the canvas default
-const near = 0.1;
-const far = 5;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-
-

fovfield of view(시야각)의 줄임말입니다. 예제의 경우 수직면 75도로 -설정했습니다. 알아둬야 할 건 Three.js의 대부분이 각도 단위로 호도(radians)를 -사용하는데, 원근 카메라만 특이하게 도(degrees)를 인자로 받는다는 점입니다.

-

aspect는 canvas의 가로 세로 비율입니다. 이는 다른 글 -에서 자세히 다루겠지만, 기본 설정으로 canvas의 크기는 300x150이니 비율도 -300/150, 2로 설정했습니다.

-

nearfar는 카메라 앞에 렌더링되는 공간 범위를 지정하는 요소입니다. -이 공간 바깥에 있는 요소는 화면에서 잘려나가며, 렌더링되지 않을 것입니다.

-

위에서 살펴본 4가지 속성은 하나의 "절두체"를 만듭니다. "절두체"는 -끝부분이 잘려나간 피라미드처럼 생긴 3차원 모양인데, 구, 정육면체, -각기둥(prism)처럼 3차원 모양의 하나로 생각하면 됩니다.

-

-

nearfar 평면의 높이는 시야각(fov), 너비는 시야각과 aspect에 의해 -결정됩니다.

-

그리고 앞서 말했듯 절두체 안에 있는 요소만 렌더링되며, 바깥에 있는 요소는 -렌더링되지 않습니다.

-

기본 설정으로 카메라는 -Z 축 +Y 축, 즉 아래를 바라봅니다. 정육면체를 원점에 -놓을 예정이니 카메라를 조금 뒤로 움직여 화면 안에 들어오도록 해보죠.

-
camera.position.z = 2;
-
-

우리가 원하는 결과물을 다음처럼 그려볼 수 있습니다.

-

-

카메라는 z = 2 위치에서 -Z 방향을 바라봅니다. 절두체는 카메라 앞 0.1 칸에서 -5칸까지를 차지하죠. 또한 아래를 바라보는 형태이기 때문에, 시야각은 canvas -크기의 영향을 받습니다. 앞서 만든 canvas는 두 배 더 크기 때문에 실제로는 시야각이 -위에서 정의한 75도 보다는 훨씬 넓을 것입니다.

-

이제 Scene을 만듭니다. Three.js에서 Scene이란 씬 그래프에서 가장 상단에 -위치한 요소입니다. 뭔가를 화면에 렌더링하고 싶다면 먼저 Scene에 추가해야 하죠. -여기서는 간단하게 다룰 것이므로 자세한 내용은 이 글을 -참고하세요.

-
const scene = new THREE.Scene();
-
-

다음으로 간단한 정육면체를 만들어보죠. Three.js에서 렌더링되는 대부분의 3D 요소는 -정점 데이터가 정의된 기하학 객체를 필요로 합니다. 지금은 정육면체를 만들어야 하니 -BoxGeometry 생성자를 호출하겠습니다.

-
const boxWidth = 1;
-const boxHeight = 1;
-const boxDepth = 1;
-const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-
-

다음으로 Material을 만들고 색을 지정합니다. 색을 지정할 때는 CSS처럼 -숫자 형태의 hex 코드를 이용합니다(# 대신 0x. 역주).

-
const material = new THREE.MeshBasicMaterial({color: 0x44aa88});
-
-

그리고 앞서 만든 Geometry(물체의 형태)와 Material(물체의 색, 밝기, 질감 등)을 -이용해 Mesh를 만듭니다. MeshGeometry, Material 외에도 물체의 위치, 방향, -크기 등을 담은 객체입니다.

-
const cube = new THREE.Mesh(geometry, material);
-
-

마지막으로 완성된 정육면체 MeshScene에 넣어줍니다.

-
scene.add(cube);
-
-

rendererrender 메서드에 SceneCamera를 넘겨주면 화면을 렌더링할 -수 있습니다.

-
renderer.render(scene, camera);
-
-

아래는 전체 코드입니다.

-

- -

-

물론 위 예제는 3D 정육면체라고 하긴 어렵습니다. 코드 상으로 Three.js는 -분명 3D 정육면체를 그렸지만, 카메라가 -Z 방향을 바라보고, 정육면체도 -Z 축에 맞추어 정렬되어 있기 때문에 한 면만 보입니다.

-

한 번 이 물체에 애니메이션을 주고 3D처럼 보이는지 확인해보죠. -애니메이션을 구현하기 위해 requestAnimationFrame 루프 안에서 렌더링 함수를 호출합니다.

-
function render(time) {
-  time *= 0.001;  // convert time to seconds
-
-  cube.rotation.x = time;
-  cube.rotation.y = time;
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-requestAnimationFrame(render);
-
-

requestAnimationFrame은 브라우저에 애니메이션 프레임을 요청하는 함수입니다. -인자로 실행할 함수를 전달하면 되죠(이 경우는 render 함수). 브라우저는 넘겨받은 -함수를 실행하고, 페이지에 변화가 있다면 페이지를 다시 렌더링합니다. 위 예제에서는 -Three.js의 renderer.render 메서드를 호출해 씬을 렌더링하도록 했죠.

-

requestAnimationFrame은 매개변수로 넘겨받은 함수에 페이지가 로드된 이후의 시간값을 -밀리초 단위로 넘겨줍니다. 전 초 단위가 더 익숙하기 때문에, 밀리초 단위를 초 단위로 -변경하였습니다.

-

그리고 정육면체의 X, Y축 회전값을 현재 시간값으로 설정합니다(이 회전값은 라디안(radians) -단위를 사용합니다). 360˚도는 2π 라디안이니 큐브는 각 축마다 -약 6.28초에 한 바퀴를 돌게 됩니다.

-

그리고 씬을 렌더링 한 후, 브라우저에 재귀적으로 애니메이션 프레임을 요청해 이 -애니메이션이 반복되도록 합니다.

-

마지막으로 루프 바깥에서 requestAnimationFrame을 한 번 호출해 루프를 -시작합니다.

-

- -

-

아까보단 낫지만 아직 3D 물체라고 부르기엔 뭔가 부족합니다. 광원을 추가해 그림자가 -지도록 하면 어떨까요? 나중에 이 글에서 자세히 다루겠지만, -Three.js에는 다양한 종류의 광원이 있습니다. 다 살펴보기 힘드니 지금은 예시로 -DirectionalLight를 사용해보도록 하죠.

-
{
-  const color = 0xFFFFFF;
-  const intensity = 3;
-  const light = new THREE.DirectionalLight(color, intensity);
-  light.position.set(-1, 2, 4);
-  scene.add(light);
-}
-
-

DirectionalLight에는 위치(position)목표(target) 속성이 있습니다. -기본값은 0, 0, 0 이죠. 먼저 position-1, 2, 4로 설정해 카메라보다 -약간 동서쪽, Z축으로는 약간 위로 보냅니다. target은 기본값 0, 0, 0 그대로 -두어 공간의 중앙을 비추도록 합니다.

-

그리고 material도 바꿔야 합니다. MeshBasicMaterial은 광원에 반응하지 않으니, -광원에 반응하는 MeshPhongMaterial로 바꿉니다(phong은 광원 반사 모델을 처음 개발한 사람 이름. 역주).

-
-const material = new THREE.MeshBasicMaterial({color: 0x44aa88});  // greenish blue
-+const material = new THREE.MeshPhongMaterial({color: 0x44aa88});  // greenish blue
-
-

다음은 현재까지의 프로그램 구조를 도식화 한 것이고,

-
- -

아래는 결과물입니다.

-

- -

-

이제 3D라고 불러도 어색하지 않네요.

-

재미를 위해서 한 번 정육면체를 2개만 더 만들어 봅시다.

-

미리 만들어 놨던 Geometry를 사용하고, Material만 바꿔 다른 색을 -가진 큐브 2개를 만들겠습니다.

-

먼저 함수를 하나 만들어보겠습니다. 이 함수는 넘겨받은 색상값으로 -새로운 Material을 만들고, 넘겨받은 Geometry와 조합해 새로운 -Mesh를 만듭니다. 그리고 씬에 추가한 후 넘겨받은 X축 값을 통해 물체를 이동시키죠.

-
function makeInstance(geometry, color, x) {
-  const material = new THREE.MeshPhongMaterial({color});
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  cube.position.x = x;
-
-  return cube;
-}
-
-

다음으로 3가지 다른 색상과 X축 값으로 이 함수를 호출해 결과물을 배열로 -저장합니다.

-
const cubes = [
-  makeInstance(geometry, 0x44aa88,  0),
-  makeInstance(geometry, 0x8844aa, -2),
-  makeInstance(geometry, 0xaa8844,  2),
-];
-
-

마지막으로 render 함수에서 3개의 정육면체를 회전시킵니다. 동적인 -효과를 위해 각 큐브마다 약간 다른 값을 주도록 해보죠.

-
function render(time) {
-  time *= 0.001;  // convert time to seconds
-
-  cubes.forEach((cube, ndx) => {
-    const speed = 1 + ndx * .1;
-    const rot = time * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
-  ...
-
-

결과물은 다음과 같습니다.

-

- -

-

아까 위에서 좌표로 도식한 그림과 위 결과물을 비교해보면, 예상대로 -X축으로 -2, +2만큼 이동한 정육면체들의 일부가 절두체에서 약간 벗어났습니다. -또한 가운데 정육면체에 비해 굉장히 굴절되어 보이는데, 이는 우리가 설정한 시야각이 -너무 좁은 탓입니다.

-

위 프로그램은 구조는 다음과 같습니다.

-
- -

그림을 보면 각 Mesh 객체는 같은 BoxGeometry를 참조합니다. 그러나 -각 Mesh는 다른 MeshPhongMaterial을 참조하므로 다른 색을 띄죠.

-

이 인트로가 Three.js를 시작하는 데 도움이 되었으면 합니다. -다음 장에서는 코드를 반응형으로 만들어 다양한 상황에 적용할 수 있도록 해 볼 것입니다.

-
-

es6 모듈, Three.js, 프로젝트 구조

-

Three.js r106 릴리즈 이후에서는 three.js를 es6 모듈로 사용하길 권장합니다.

-

-es6 모듈은 js 파일이나 인라인 <script type="module"> 태그 안에서 -import 키워드를 사용해 로드할 수 있습니다. -

-
<script type="module">
-import * as THREE from 'three';
-
-...
-
-</script>
-
-

-경로는 절대경로나 상대경로를 사용해야 하며, 상대경로는 <img>, <a> 태그와 -달리 ./ 또는 ../로 시작해야 합니다. -

-

-만약 절대경로가 같다면 같은 script는 한 번만 로드됩니다. Three.js 기반 프로젝트의 폴더 구조를 -잘 구성해야 하는 가장 큰 이유이죠. -

-
someFolder
- |
- ├-build
- | |
- | +-three.module.js
- |
- +-examples
-   |
-   +-jsm
-     |
-     +-controls
-     | |
-     | +-OrbitControls.js
-     | +-TrackballControls.js
-     | +-...
-     |
-     +-loaders
-     | |
-     | +-GLTFLoader.js
-     | +-...
-     |
-     ...
-
-

-이유인 즉 OrbitControls.js -같은 코드는 다음처럼 상대경로로 모듈을 로드하기 때문에 -

-
import * as THREE from '../../../build/three.module.js';
-
-

-같은 폴더 구조를 사용함으로써 모든 예제 스크립트가 같은 three.module.js 파일을 -참조하도록 할 수 있죠. -

-
import * as THREE from './someFolder/build/three.module.js';
-import {OrbitControls} from './someFolder/addons/controls/OrbitControls.js';
-
-

아래는 CDN을 사용하는 예시입니다. three.modules.js의 경로가 /build/three.modules.js -로 끝나야 한다는 것을 명심하세요.

-
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@<version>/build/three.module.js';
-import {OrbitControls} from 'https://cdn.jsdelivr.net/npm/three@<version>/addons/controls/OrbitControls.js';
-
-
-
-
-
- - - - - - - - diff --git a/manual/ko/game.html b/manual/ko/game.html deleted file mode 100644 index 7df5ab41148927..00000000000000 --- a/manual/ko/game.html +++ /dev/null @@ -1,1654 +0,0 @@ - - - 로 게임 만들기 - - - - - - - - - - - - - - -
-
-

로 게임 만들기

-
-
-
-

제가 꽤 많이 받았던 질문 중 하나가 Three.js로 게임을 만드는 방법에 관한 것이었습니다. 기초적인 것이긴 해도 부디 이 글에 여러분이 원했던 내용이 있다면 좋겠네요.

-

글을 쓰는 현재를 기준으로, 아마 이 글이 이 시리즈에서 가장 긴 글이 될 것 같습니다. 예제로 쓴 코드가 지나치게 전문적으로 보일 수도 있지만 그건 예제를 만들며 문제가 생길 때마다 이전에 제가 실제로 만들었던 게임의 코드를 가져와서 그렇습니다. 또한 왜 이런 해결책을 썼는지 최대한 적으려고 했으니 길 수밖에 없죠. 물론 만들려는 게임의 규모가 작다면 이런 해결책이 전부 필요 없을 수 있습니다. 하지만 예제로 구현한 것도 굉장히 간단한 게임에 속합니다. 통상적으로 3D 캐릭터가 2D 캐릭터보다 더 복잡하니 처리해줘야 할 것이 많을 수밖에 없죠.

-

팩맨(PacMan)을 2D로 구현한다면 팩맨이 코너를 돌 때 바로 90도 꺾기만 하면 됩니다. 프레임 사이에 따로 처리해줘야 할 것이 없죠. 하지만 3D의 세계에서는 바로 방향을 틀기보다 몇 프레임에 걸쳐 서서히 방향을 트는 게 일반적입니다. 아주 간단한 차이점이지만, 이것 때문에 작업이 훨씬 복잡해집니다.

-

이 글에서 다룰 내용은 Three.js에 관한 것이라고 보기 어렵습니다. 왜냐하면 Three.js는 게임 엔진이 아니기 때문이죠. Three.js는 3D 라이브러리입니다. 3D 요소를 계열화하는 씬 그래프와 3D 요소를 렌더링하도록 도와주는 기능 등을 제공하죠. 하지만 게임과 관련한 기능은 지원하지 않습니다. 충돌(collision), 물리(physics), 입력 시스템, 패스 파인딩(path finding) 등등.. 이런 기능은 직접 만들어야 합니다.

-

결국 이 글의 미완성 게임을 만드는 데 꽤 많은 코드를 썼습니다. 아까 말했듯 제가 코드를 너무 지나치게 짰을 수도 있고, 더 간단한 해결책이 있을 수도 있으나, 저는 글을 마무리한 지금도 충분히 많은 코드를 썼는지, 설명을 빠뜨린 것이 없는지 걱정됩니다.

-

이 글에서 쓴 방법은 대부분 유니티(Unity) 엔진의 영향을 크게 받았습니다. 하지만 유니티를 잘 모른다고 해서 이 글을 읽는 게 어렵진 않을 겁니다. 1000개의 기능이 있다면 그 중 10개 정도 밖에 쓰지 않았거든요.

-

먼저 Three.js 부분부터 시작해봅시다. 게임에 쓸 모델들부터 찾아보죠.

-

opengameart.org 사이트에서 quaternius 작가의 움직이는 기사 모델을 찾았습니다.

-
- -

같은 작가가 만든 작품 중에 움직이는 동물들도 있더군요.

-
- -

이 모델들로 꽤 괜찮은 게임을 만들 수 있을 것 같습니다. 모델들을 불러와보죠.

-

glTF 파일 불러오기에 대해서는 이전에 다뤘었습니다. 동일한 방법을 사용하지만 이번에는 모델이 여러 개이기도 하고, 모델을 전부 불러오기 전에 게임을 시작해선 안 됩니다.

-

이런 경우를 대비해 Three.js는 LoadingManager를 제공합니다. LoadingManager의 인스턴스를 생성해 다른 로더(loader)에 넘겨주기면 되죠. LoadingManageronProgressonLoad 속성에 콜백 함수를 지정하면 되는데, onLoad는 모든 파일을 불러온 뒤 호출하고, onProgress는 각 파일을 불러왔을 때 호출합니다. onProgress를 이용하면 프로그래스 바를 보여줄 수 있죠.

-

glTF 파일 불러오기 예제를 가져와 카메라 절두체(frustum)를 조정하는 코드를 지우고 아래 코드를 추가합니다.

-
const manager = new THREE.LoadingManager();
-manager.onLoad = init;
-const models = {
-  pig:    { url: 'resources/models/animals/Pig.gltf' },
-  cow:    { url: 'resources/models/animals/Cow.gltf' },
-  llama:  { url: 'resources/models/animals/Llama.gltf' },
-  pug:    { url: 'resources/models/animals/Pug.gltf' },
-  sheep:  { url: 'resources/models/animals/Sheep.gltf' },
-  zebra:  { url: 'resources/models/animals/Zebra.gltf' },
-  horse:  { url: 'resources/models/animals/Horse.gltf' },
-  knight: { url: 'resources/models/knight/KnightCharacter.gltf' },
-};
-{
-  const gltfLoader = new GLTFLoader(manager);
-  for (const model of Object.values(models)) {
-    gltfLoader.load(model.url, (gltf) => {
-      model.gltf = gltf;
-    });
-  }
-}
-
-function init() {
-  // 나중에 작성할 예정
-}
-
-

위 코드는 models 객체에 있는 파일을 불러오고, LoadingManager가 파일을 전부 불러왔을 때 init 함수를 호출합니다. models 객체를 전역으로 선언한 건 나중에 GLTFLoader의 콜백을 이용해 각 모델의 정보를 사용할 때 불러온 각 모델에 접근할 수 있도록 하기 위함입니다.

-

모든 모델과 모델의 애니메이션 데이터는 현재 약 6.6MB입니다. 꽤 용량이 크네요. 여러분의 서버가 압축을 지원(이 사이트의 웹 서버가 이 기능을 지원합니다)한다면 용량은 약 1.4MB까지 줄어들 겁니다. 6.6MB와 비교하면 확실히 적은 데이터지만 절대 작은 데이터는 아닙니다. 프로그래스 바를 만들어 사용자에게 얼마나 기다려야 하는지를 표시해준다면 좋겠네요.

-

onProgress에 콜백 함수를 지정해줍시다. 이 콜백 함수는 호출할 때 3개의 매개변수를 받습니다. 각각 마지막에 불러온 파일의 url, 그리고 불러온 파일의 개수, 전체 파일의 개수입니다.

-

프로그래스 바는 HTML로 간단히 구현하도록 하죠.

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="loading">
-+    <div>
-+      <div>...loading...</div>
-+      <div class="progress"><div id="progressbar"></div></div>
-+    </div>
-+  </div>
-</body>
-
-

#progressbar를 참조한 뒤 width를 퍼센트(%) 단위로 표시해 현재 진행율을 보여줄 겁니다. 콜백에서 이 스타일만 처리해주면 되겠네요.

-
const manager = new THREE.LoadingManager();
-manager.onLoad = init;
-
-+const progressbarElem = document.querySelector('#progressbar');
-+manager.onProgress = (url, itemsLoaded, itemsTotal) => {
-+  progressbarElem.style.width = `${ itemsLoaded / itemsTotal * 100 | 0 }%`;
-+};
-
-

이미 모든 모델을 불러왔을 때 init 함수를 호출하게 해놓았으니, 여기서 #loading 요소를 숨겨 프로그래스 바를 없앱니다.

-
function init() {
-+  // 프로그래스 바를 숨깁니다.
-+  const loadingElem = document.querySelector('#loading');
-+  loadingElem.style.display = 'none';
-}
-
-

아래는 프로그래스 바를 꾸미기 위한 CSS입니다. #loading은 페이지 전체를 꽉 채우고 자식 요소를 가운데 정렬시킵니다. 그리고 .progress는 프로그래스 바가 들어갈 영역을 정의하죠. 프로그래스 바에 간단한 애니메이션도 넣어줬습니다.

-
#loading {
-  position: absolute;
-  left: 0;
-  top: 0;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  text-align: center;
-  font-size: xx-large;
-  font-family: sans-serif;
-}
-#loading>div>div {
-  padding: 2px;
-}
-.progress {
-  width: 50vw;
-  border: 1px solid black;
-}
-#progressbar {
-  width: 0;
-  transition: width ease-out .5s;
-  height: 1em;
-  background-color: #888;
-  background-image: linear-gradient(
-    -45deg,
-    rgba(255, 255, 255, .5) 25%,
-    transparent 25%,
-    transparent 50%,
-    rgba(255, 255, 255, .5) 50%,
-    rgba(255, 255, 255, .5) 75%,
-    transparent 75%,
-    transparent
-  );
-  background-size: 50px 50px;
-  animation: progressanim 2s linear infinite;
-}
-
-@keyframes progressanim {
-  0% {
-    background-position: 50px 50px;
-  }
-  100% {
-    background-position: 0 0;
-  }
-}
-
-

이제 프로그래스 바가 생겼으니 모델을 처리할 차례입니다. 이 모델들에는 애니메이션이 있는데, 이 애니메이션을 제어할 수 없다면 애니메이션이 있는 의미가 없겠죠. Three.js는 기본적으로 애니메이션들을 배열 형태로 저장합니다. 하지만 배열이 아닌 이름 형태로 저장하는 게 나중에 쓰기에 편하니 각 모델에 animation 속성을 만들겠습니다. 물론 각 애니메이션의 이름은 고유한 값이어야 하겠죠.

-
+function prepModelsAndAnimations() {
-+  Object.values(models).forEach(model => {
-+    const animsByName = {};
-+    model.gltf.animations.forEach((clip) => {
-+      animsByName[clip.name] = clip;
-+    });
-+    model.animations = animsByName;
-+  });
-+}
-
-function init() {
-  // 프로그래스 바를 숨깁니다.
-  const loadingElem = document.querySelector('#loading');
-  loadingElem.style.display = 'none';
-
-+  prepModelsAndAnimations();
-}
-
-

이제 애니메이션이 들어간 모델을 화면에 띄워봅시다.

-

이전 glTF 파일 예제와 달리 이번에는 각 모델을 하나 이상 배치할 계획입니다. 그러니 파일을 불러온 뒤 바로 장면에 넣는 대신 각 glTF의 씬 그래프(scene), 이 경우에는 움직이는 캐릭터를 복사해야 합니다. 다행히 Three.js에는 SkeletonUtil.clone이라는 함수가 있어 이를 쉽게 구현할 수 있죠. 먼저 해당 모듈을 불러오겠습니다.

-
import * as THREE from 'three';
-import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
-+import { SkeletonUtils } from 'three/addons/utils/SkeletonUtils.js';
-
-

그리고 아까 불러왔던 모델을 복사합니다.

-
function init() {
-  // 프로그래스 바를 숨깁니다.
-  const loadingElem = document.querySelector('#loading');
-  loadingElem.style.display = 'none';
-
-  prepModelsAndAnimations();
-
-+  Object.values(models).forEach((model, ndx) => {
-+    const clonedScene = SkeletonUtils.clone(model.gltf.scene);
-+    const root = new THREE.Object3D();
-+    root.add(clonedScene);
-+    scene.add(root);
-+    root.position.x = (ndx - 3) * 3;
-+  });
-}
-
-

위 코드에서는 불러온 각 모델의 gltf.scene을 복사해 새로운 Object3D의 자식으로 추가했습니다. 부모를 따로 만든 건 모델의 애니메이션이 모델의 각 요소의 위치값에 영향을 미치기에 코드로 직접 위치값을 수정하기도 어렵고, 제대로 반영도 안 될 것이기 때문입니다.

-

각 모델의 애니메이션을 재생하려면 AnimationMixer를 써야 합니다. AnimationMixer는 하나 이상의 AnimationAction으로 이루어지고, 각 AnimationAction에는 하나의 AnimationClip이 있습니다. AnimationAction에는 여러 액션(action)을 이어서 재생하거나, 다른 애니메이션으로 부드럽게 전환하기 등 다양한 설정이 있죠. 당장은 첫 번째 AnimationClip으로 액션을 만들어봅시다. 설정을 바꾸지 않는다면 해당 애니메이션 클립(clip)을 반복해 재생할 겁니다.

-
+const mixers = [];
-
-function init() {
-  // 프로그래스 바를 숨깁니다.
-  const loadingElem = document.querySelector('#loading');
-  loadingElem.style.display = 'none';
-
-  prepModelsAndAnimations();
-
-  Object.values(models).forEach((model, ndx) => {
-    const clonedScene = SkeletonUtils.clone(model.gltf.scene);
-    const root = new THREE.Object3D();
-    root.add(clonedScene);
-    scene.add(root);
-    root.position.x = (ndx - 3) * 3;
-
-+    const mixer = new THREE.AnimationMixer(clonedScene);
-+    const firstClip = Object.values(model.animations)[0];
-+    const action = mixer.clipAction(firstClip);
-+    action.play();
-+    mixers.push(mixer);
-  });
-}
-
-

애니메이션을 시작하기 위해 play 메서드를 호출했습니다. 그리고 생성한 AnimationMixer들을 전부 mixers 배열에 넣었죠. 마지막으로 렌더링 루프에서 각 AnimationMixerAnimationMixer.update 메서드에 바로 직전 프레임과 현재 프레임의 시간값을 넘겨주어야 합니다.

-
+let then = 0;
-function render(now) {
-+  now *= 0.001;  // 초 단위로 변환
-+  const deltaTime = now - then;
-+  then = now;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-+  for (const mixer of mixers) {
-+    mixer.update(deltaTime);
-+  }
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-

이제 각 모델과 모델의 첫 번째 애니메이션이 보일 겁니다.

-

- -

-

모든 애니메이션을 확인할 수 있도록 예제를 수정해봅시다. 애니메이션 클립을 전부 액션으로 만들어 재생할 수 있도록 만들겠습니다.

-
-const mixers = [];
-+const mixerInfos = [];
-
-function init() {
-  // 프로그래스 바를 숨깁니다.
-  const loadingElem = document.querySelector('#loading');
-  loadingElem.style.display = 'none';
-
-  prepModelsAndAnimations();
-
-  Object.values(models).forEach((model, ndx) => {
-    const clonedScene = SkeletonUtils.clone(model.gltf.scene);
-    const root = new THREE.Object3D();
-    root.add(clonedScene);
-    scene.add(root);
-    root.position.x = (ndx - 3) * 3;
-
-    const mixer = new THREE.AnimationMixer(clonedScene);
--    const firstClip = Object.values(model.animations)[0];
--    const action = mixer.clipAction(firstClip);
--    action.play();
--    mixers.push(mixer);
-+    const actions = Object.values(model.animations).map((clip) => {
-+      return mixer.clipAction(clip);
-+    });
-+    const mixerInfo = {
-+      mixer,
-+      actions,
-+      actionNdx: -1,
-+    };
-+    mixerInfos.push(mixerInfo);
-+    playNextAction(mixerInfo);
-  });
-}
-
-+function playNextAction(mixerInfo) {
-+  const { actions, actionNdx } = mixerInfo;
-+  const nextActionNdx = (actionNdx + 1) % actions.length;
-+  mixerInfo.actionNdx = nextActionNdx;
-+  actions.forEach((action, ndx) => {
-+    const enabled = ndx === nextActionNdx;
-+    action.enabled = enabled;
-+    if (enabled) {
-+      action.play();
-+    }
-+  });
-+}
-
-

위 코드에서는 각 모델마다 액션 배열과 AnimationMixer를 객체로 만들어 저장했습니다. 액션 배열은 모델의 각 AnimationClip마다 AnimationAction을 하나씩 생성해 배열로 만든 것이죠. 그리고 하나의 액션을 제외한 나머지 액션의 enabled 속성을 끄는 playNextAction을 호출했습니다.

-

데이터 형식이 바뀌었으니 렌더링 루프의 업데이트 쪽 코드도 수정해야 합니다.

-
-for (const mixer of mixers) {
-+for (const { mixer } of mixerInfos) {
-  mixer.update(deltaTime);
-}
-
-

숫자키 1-8번을 눌러 각 모델의 애니메이션을 선택할 수 있도록 리스너를 지정합니다.

-
window.addEventListener('keydown', (e) => {
-  const mixerInfo = mixerInfos[e.keyCode - 49];
-  if (!mixerInfo) {
-    return;
-  }
-  playNextAction(mixerInfo);
-});
-
-

이제 예제를 클릭한 뒤 숫자키 1-8번을 누르면 각 번호에 해당하는 모델의 애니메이션이 바뀔 겁니다.

-

- -

-

Three.js 관련 내용은 여기까지입니다. 여태까지 다수의 파일을 불러오는 법, 텍스처가 씌워진 모델을 복사하는 법, 해당 모델의 애니메이션을 재생하는 법을 알아봤죠. 실제 게임에서는 AnimationAction 객체로 다양한 동작을 직접 처리해줘야 합니다.

-

자, 이제 게임의 기본 틀을 만들어봅시다.

-

최신 게임을 만들 때는 보통 Entity Component System(ECS)을 많이 사용합니다. Entity Component System에서는 게임의 요소를 여러 개의 컴포넌트(component)로 이루어진 엔티티(entity)라 부르죠. 새로운 엔티티를 생성할 때는 모든 코드를 새로 쓰는 것이 아닌, 미리 만들어 놓은 컴포넌트들을 엮어 생성합니다.

-

예제에서는 엔티티를 GameObject라 부르겠습니다. 이는 단순히 컴포넌트 배열과 THREE.Object3D를 합친 것입니다.

-
function removeArrayElement(array, element) {
-  const ndx = array.indexOf(element);
-  if (ndx >= 0) {
-    array.splice(ndx, 1);
-  }
-}
-
-class GameObject {
-  constructor(parent, name) {
-    this.name = name;
-    this.components = [];
-    this.transform = new THREE.Object3D();
-    parent.add(this.transform);
-  }
-  addComponent(ComponentType, ...args) {
-    const component = new ComponentType(this, ...args);
-    this.components.push(component);
-    return component;
-  }
-  removeComponent(component) {
-    removeArrayElement(this.components, component);
-  }
-  getComponent(ComponentType) {
-    return this.components.find(c => c instanceof ComponentType);
-  }
-  update() {
-    for (const component of this.components) {
-      component.update();
-    }
-  }
-}
-
-

GameObject.update 메서드를 호출하면 각 컴포넌트의 update 메서드를 호출합니다.

-

name 속성은 단순히 디버깅을 위한 것입니다. 콘솔에서 GameObject를 봤을 때 어떤 요소인지 쉽게 확인할 수 있겠죠.

-

생소해 보일 수 있는 것들 몇 가지만 집고 넘어가겠습니다.

-

GameObject.addComponent는 컴포넌트를 생성할 때 사용합니다. GameObject 안에서 컴포넌트를 생성하는 게 최선인지는 모르겠으나, 개인적으로 컴포넌트가 GameObject 밖에 존재하는 건 의미가 없어 보였습니다. 그래서 생성한 컴포넌트를 자동으로 GameObject의 배열에 추가하고, GameObject 자체도 컴포넌트의 constructor에 넘겨줄 수 있으면 편하겠다고 생각했죠. 쉽게 말해 지금은 다음처럼 컴포넌트를 추가하지만,

-
const gameObject = new GameObject(scene, 'foo');
-gameObject.addComponent(TypeOfComponent);
-
-

위와 같은 방식을 선호하지 않는다면 다음처럼 추가할 수도 있습니다.

-
const gameObject = new GameObject(scene, 'foo');
-const component = new TypeOfComponent(gameObject);
-gameObject.addComponent(component);
-
-

첫 번째 코드가 짧고 자동화됐다는 면에서 더 좋을까요, 아니면 기존 형식을 해쳐서 더 별로일까요? 저는 어떻다고 판단하기가 어렵네요.

-

GameObject.getComponent는 컴포넌트의 타입을 이용해 컴포넌트를 찾습니다. 이는 하나의 GameObject가 같은 타입의 컴포넌트를 두 개 이상 사용할 수 없다는 이야기죠. 물론 두 개 이상 사용한다고 에러가 나거나 하진 않겠지만, 별도의 API를 추가하지 않는 한 저 메서드는 항상 같은 타입 중 첫 번째 컴포넌트만을 반환할 겁니다.

-

컴포넌트가 다른 컴포넌트를 찾는 건 흔한 일입니다. 그리고 컴포넌트를 찾을 때는 잘못 참조하는 일이 없도록 타입을 체크해야 하죠. 그냥 각 컴포넌트에 고유한 이름 속성을 주고 그 이름으로 해당 컴포넌트를 찾을 수도 있습니다. 이렇게 하면 같은 타입의 컴포넌트를 여러 개 쓸 수 있으니 확장성 면에서 유리할 겁니다. 하지만 이 방법은 그다지 일관성이 없습니다. 이번에도 어떤 쪽이 더 좋다고 판단하기가 어렵네요.

-

아래는 컴포넌트의 기초 클래스입니다.

-
// 모든 컴포넌트의 기초
-class Component {
-  constructor(gameObject) {
-    this.gameObject = gameObject;
-  }
-  update() {
-  }
-}
-
-

컴포넌트에 기초 클래스가 필요할까요? 자바스크립트는 타입이 느슨한 언어이기에 굳이 기초 클래스를 쓸 필요는 없습니다. 각 컴포넌트의 constructor에서 첫 번째 인자가 GameObject이기만 하면 되죠. 만약 GameObject를 나중에 참조할 필요가 없다면 굳이 저장하지 않아도 될 겁니다. 하지만 저는 왠지 이 형식이 더 좋아 보이네요. 기초 클래스를 두면 부모의 GameObject에 쉽게 접근할 수 있을 뿐만 아니라 다른 컴포넌트를 쉽게 찾을 수 있고, 어떤 차이점이 있는지도 쉽게 알 수 있을 테니까요.

-

GameObject를 다루려면 GameObject를 관리하는 클래스를 만드는 게 좋을 듯합니다. 얼핏 GameObject를 배열 형식으로 갖고 있어도 괜찮지 않나 싶을 수 있으나, 실제로 게임을 플레이할 때는 요소가 추가되기도 하고, 없어지기도 합니다. 예를 들어 총 GameObject는 총을 발사할 때마다 총알 GameObject를 추가할 겁니다. 몬스터 GameObject가 누군가에 의해 죽는다면 해당 GameObject는 사라지겠죠. GameObject를 배열로 저장한다면 십중팔구 다음과 같은 식의 코드를 쓸 겁니다.

-
for (const gameObject of globalArrayOfGameObjects) {
-  gameObject.update();
-}
-
-

위 반복문은 globalArrayOfGameObjects에 GameObject가 추가되거나 제거됐을 경우, 특정 컴포넌트의 update 메서드에서 에러를 던지거나 예상 밖의 동작을 할 수 있습니다.

-

이런 일을 방지하기 위해 안전 장치를 추가해보도록 하죠.

-
class SafeArray {
-  constructor() {
-    this.array = [];
-    this.addQueue = [];
-    this.removeQueue = new Set();
-  }
-  get isEmpty() {
-    return this.addQueue.length + this.array.length > 0;
-  }
-  add(element) {
-    this.addQueue.push(element);
-  }
-  remove(element) {
-    this.removeQueue.add(element);
-  }
-  forEach(fn) {
-    this._addQueued();
-    this._removeQueued();
-    for (const element of this.array) {
-      if (this.removeQueue.has(element)) {
-        continue;
-      }
-      fn(element);
-    }
-    this._removeQueued();
-  }
-  _addQueued() {
-    if (this.addQueue.length) {
-      this.array.splice(this.array.length, 0, ...this.addQueue);
-      this.addQueue = [];
-    }
-  }
-  _removeQueued() {
-    if (this.removeQueue.size) {
-      this.array = this.array.filter(element => !this.removeQueue.has(element));
-      this.removeQueue.clear();
-    }
-  }
-}
-
-

위 클래스는 SafeArray의 요소를 더하거나 제거할 수 있도록 해줍니다. 원본 배열의 반복되는 동안 원본 배열을 변경하지 않는다는 게 차이점이죠. 대신 반복 중간에 추가된 요소는 addQueue에, 제거된 요소는 removeQueue에 들어간 뒤, 반복문이 돌아가지 않을 때 원본 배열에 제거/추가됩니다.

-

아래는 위 클래스를 이용한 GameObject의 관리 클래스입니다.

-
class GameObjectManager {
-  constructor() {
-    this.gameObjects = new SafeArray();
-  }
-  createGameObject(parent, name) {
-    const gameObject = new GameObject(parent, name);
-    this.gameObjects.add(gameObject);
-    return gameObject;
-  }
-  removeGameObject(gameObject) {
-    this.gameObjects.remove(gameObject);
-  }
-  update() {
-    this.gameObjects.forEach(gameObject => gameObject.update());
-  }
-}
-
-

여태까지 만든 요소로 첫 컴포넌트를 만들어봅시다. 이 컴포넌트는 아까 만들었던 것과 같은 Three.js glTF 객체를 관리할 겁니다. 간단히 애니메이션의 이름을 받아 해당 애니메이션을 재생하는 setAnimation 메서드만 새로 만들도록 하겠습니다.

-
class SkinInstance extends Component {
-  constructor(gameObject, model) {
-    super(gameObject);
-    this.model = model;
-    this.animRoot = SkeletonUtils.clone(this.model.gltf.scene);
-    this.mixer = new THREE.AnimationMixer(this.animRoot);
-    gameObject.transform.add(this.animRoot);
-    this.actions = {};
-  }
-  setAnimation(animName) {
-    const clip = this.model.animations[animName];
-    // 모든 액션을 끕니다.
-    for (const action of Object.values(this.actions)) {
-      action.enabled = false;
-    }
-    // 해당 클립에 해당하는 액션을 생성 또는 가져옵니다.
-    const action = this.mixer.clipAction(clip);
-    action.enabled = true;
-    action.reset();
-    action.play();
-    this.actions[animName] = action;
-  }
-  update() {
-    this.mixer.update(globals.deltaTime);
-  }
-}
-
-

이 클래스는 아까 했던 것처럼 불러온 씬 그래프를 복사하고, AnimationMixer를 만듭니다. 클래스의 setAnimation 메서드는 해당 클립에 대한 액션이 존재하지 않는다면 새로 생성하고, 다른 액션을 전부 끄는 역할을 합니다.

-

이 코드는 globals.deltaTime을 사용합니다. 이 전역 객체도 만들어야겠죠.

-
const globals = {
-  time: 0,
-  deltaTime: 0,
-};
-
-

그리고 렌더링 루프에서 전역 객체를 업데이트하도록 합니다.

-
let then = 0;
-function render(now) {
-  // 초 단위로 변환
-  globals.time = now * 0.001;
-  // 시간값이 너무 크지 않도록 제한합니다.
-  globals.deltaTime = Math.min(globals.time - then, 1 / 20);
-  then = globals.time;
-
-

위 코드에서는 시간값의 범위가 1/20초를 넘지 않도록 했습니다. 이는 사용자가 탭을 숨기거나 했을 경우 시간값이 너무 커지지 않게 하기 위한 것이죠. 만약 이렇게 제한을 두지 않는다면 사용자가 탭을 몇 초, 또는 몇 분 숨겼다가 다시 탭을 열었을 때 프레임 간 시간값이 너무 커질 테고, 아래와 같이 시간으로 속력을 계산하는 경우 캐릭터가 순간이동하는 것처럼 보일 수 있습니다.

-
position += velocity * deltaTime;
-
-

deltaTime의 최댓값을 설정하면 이런 문제를 막을 수 있죠.

-

이제 플레이어 컴포넌트를 만들어봅시다.

-
class Player extends Component {
-  constructor(gameObject) {
-    super(gameObject);
-    const model = models.knight;
-    this.skinInstance = gameObject.addComponent(SkinInstance, model);
-    this.skinInstance.setAnimation('Run');
-  }
-}
-
-

플레이어 컴포넌트는 초기화 시에 'Run'을 인자로 setAnimation을 호출합니다. 개인적으로 미리 어떤 애니메이션이 있는지 보려고 이전 예제를 수정해 애니메이션의 이름을 출력하도록 했죠.

-
function prepModelsAndAnimations() {
-  Object.values(models).forEach(model => {
-+    console.log('------->:', model.url);
-    const animsByName = {};
-    model.gltf.animations.forEach((clip) => {
-      animsByName[clip.name] = clip;
-+      console.log('  ', clip.name);
-    });
-    model.animations = animsByName;
-  });
-}
-
-

아래는 실제로 자바스크립트 개발자 콘솔에 출력된 결과입니다.

-
 ------->:  resources/models/animals/Pig.gltf
-    Idle
-    Death
-    WalkSlow
-    Jump
-    Walk
- ------->:  resources/models/animals/Cow.gltf
-    Walk
-    Jump
-    WalkSlow
-    Death
-    Idle
- ------->:  resources/models/animals/Llama.gltf
-    Jump
-    Idle
-    Walk
-    Death
-    WalkSlow
- ------->:  resources/models/animals/Pug.gltf
-    Jump
-    Walk
-    Idle
-    WalkSlow
-    Death
- ------->:  resources/models/animals/Sheep.gltf
-    WalkSlow
-    Death
-    Jump
-    Walk
-    Idle
- ------->:  resources/models/animals/Zebra.gltf
-    Jump
-    Walk
-    Death
-    WalkSlow
-    Idle
- ------->:  resources/models/animals/Horse.gltf
-    Jump
-    WalkSlow
-    Death
-    Walk
-    Idle
- ------->:  resources/models/knight/KnightCharacter.gltf
-    Run_swordRight
-    Run
-    Idle_swordLeft
-    Roll_sword
-    Idle
-    Run_swordAttack
-

운 좋게도 동물들의 애니메이션 이름이 전부 똑같네요. 나중에 편할 듯합니다. 뭐, 그건 나중 얘기고, 지금은 플레이어의 애니메이션 중 Run만 신경씁시다.

-

이제 만든 컴포넌트를 써 보겠습니다. 먼저 init 함수를 약간 수정합니다. init 함수는 GameObject를 만들고 거기에 Player 컴포넌트를 추가하는 역할을 할 겁니다.

-
const globals = {
-  time: 0,
-  deltaTime: 0,
-};
-+const gameObjectManager = new GameObjectManager();
-
-function init() {
-  // 프로그래스 바를 숨깁니다.
-  const loadingElem = document.querySelector('#loading');
-  loadingElem.style.display = 'none';
-
-  prepModelsAndAnimations();
-
-+  {
-+    const gameObject = gameObjectManager.createGameObject(scene, 'player');
-+    gameObject.addComponent(Player);
-+  }
-}
-
-

렌더링 루프에서 gameObjectManager.update를 호출하도록 합니다.

-
let then = 0;
-function render(now) {
-  // 초 단위로 변환
-  globals.time = now * 0.001;
-  // 시간값이 너무 크지 않도록 제한합니다.
-  globals.deltaTime = Math.min(globals.time - then, 1 / 20);
-  then = globals.time;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
--  for (const { mixer } of mixerInfos) {
--    mixer.update(deltaTime);
--  }
-+  gameObjectManager.update();
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-

예제를 실행하면 플레이어 하나만 보일 겁니다.

-

- -

-

단순히 Entity Component System을 구현하는 데만 너무 많은 코드를 쓴 게 아닌가 싶지만, 이 정도가 대부분의 게임이 갖춰야할 기본입니다.

-

이제 사용자 입력 시스템을 추가해봅시다. 단순히 키보드 이벤트에 직접 코드를 작성하기보다 클래스를 만들어 코드의 다른 부분에서도 왼쪽, 오른쪽을 확인할 수 있도록 하겠습니다. 이러면 왼쪽, 오른쪽 등 다양한 키를 다양한 방법으로 지정할 수 있겠죠. 먼저 키보드 이벤트부터 지정합시다.

-
/**
- * 키 또는 버튼의 상태를 추적합니다.
- *
- * 왼쪽 방향키가 눌렸는지 확인하려면
- *
- *     inputManager.keys.left.down
- *
- * 을 확인하고, 현재 프레임에서 왼쪽 키를 눌렀는지 확인하려면
- *
- *     inputManager.keys.left.justPressed
- *
- * 를 확인하면 됩니다.
- *
- * 현재 등록된 키는 'left', 'right', 'a', 'b', 'up', 'down' 입니다.
- **/
-class InputManager {
-  constructor() {
-    this.keys = {};
-    const keyMap = new Map();
-
-    const setKey = (keyName, pressed) => {
-      const keyState = this.keys[keyName];
-      keyState.justPressed = pressed && !keyState.down;
-      keyState.down = pressed;
-    };
-
-    const addKey = (keyCode, name) => {
-      this.keys[name] = { down: false, justPressed: false };
-      keyMap.set(keyCode, name);
-    };
-
-    const setKeyFromKeyCode = (keyCode, pressed) => {
-      const keyName = keyMap.get(keyCode);
-      if (!keyName) {
-        return;
-      }
-      setKey(keyName, pressed);
-    };
-
-    addKey(37, 'left');
-    addKey(39, 'right');
-    addKey(38, 'up');
-    addKey(40, 'down');
-    addKey(90, 'a');
-    addKey(88, 'b');
-
-    window.addEventListener('keydown', (e) => {
-      setKeyFromKeyCode(e.keyCode, true);
-    });
-    window.addEventListener('keyup', (e) => {
-      setKeyFromKeyCode(e.keyCode, false);
-    });
-  }
-  update() {
-    for (const keyState of Object.values(this.keys)) {
-      if (keyState.justPressed) {
-        keyState.justPressed = false;
-      }
-    }
-  }
-}
-
-

위 코드는 키가 눌렸는지, 뗐는지를 추적합니다. 특정 키를 눌렀는지 확인하려면 예를 들어 inputManager.keys.left.down을 체크하면 되고, 해당 객체에 justPressed를 체크하면 사용자가 해당 프레임에서 키를 눌렀는지 확인할 수 있습니다. 예를 들어 점프를 구현할 경우 유저가 키를 누르고 있는지를 추적할 이유는 없겠죠. 단순히 해당 프레임에서 키를 눌렀는지만 확인하면 될 겁니다.

-

이제 InputManager의 인스턴스를 생성합니다.

-
const globals = {
-  time: 0,
-  deltaTime: 0,
-};
-const gameObjectManager = new GameObjectManager();
-+const inputManager = new InputManager();
-
-

그리고 렌더링 루프에서 update 메서드를 호출하도록 합니다.

-
function render(now) {
-
-  ...
-
-  gameObjectManager.update();
-+  inputManager.update();
-
-  ...
-}
-
-

gameObjectManager.update 전에 이 메서드를 호출하면 justPressed가 항상 false일 테니 gameObjectManager.update 뒤에 메서드를 호출하도록 했습니다.

-

이제 Player 컴포넌트에 사용자 입력을 추가해봅시다.

-
+const kForward = new THREE.Vector3(0, 0, 1);
-const globals = {
-  time: 0,
-  deltaTime: 0,
-+  moveSpeed: 16,
-};
-
-class Player extends Component {
-  constructor(gameObject) {
-    super(gameObject);
-    const model = models.knight;
-    this.skinInstance = gameObject.addComponent(SkinInstance, model);
-    this.skinInstance.setAnimation('Run');
-+    this.turnSpeed = globals.moveSpeed / 4;
-  }
-+  update() {
-+    const { deltaTime, moveSpeed } = globals;
-+    const { transform } = this.gameObject;
-+    const delta = (inputManager.keys.left.down  ?  1 : 0) +
-+                  (inputManager.keys.right.down ? -1 : 0);
-+    transform.rotation.y += this.turnSpeed * delta * deltaTime;
-+    transform.translateOnAxis(kForward, moveSpeed * deltaTime);
-+  }
-}
-
-

위 코드에서는 플레이어를 앞으로 움직이기 위해 Object3D.transformOnAxis를 사용했습니다. Object3D.transformOnAxis는 지역 공간을 기준으로 하기에 해당 객체가 장면의 루트(root) 요소에 속할 때만 정상적으로 작동합니다. 1

-

또한 전역 객체에 moveSpeed를 추가했고 이를 turnSpeed의 기준으로 삼았습니다. 이는 캐릭터가 목표를 향해 상대적으로 빠르게 돌도록 만든 것으로, 이 turnSpeed의 값이 너무 작다면 캐릭터는 목표 주위를 빙빙 돌기만 하고 절대 목표에 닿지는 못할 겁니다. 물론 위 값은 어떤 수학적 공식을 사용한 것이 아닙니다. 그냥 대충 때려 넣은 것이죠.

-

이대로도 예제는 잘 작동할 테지만 플레이어가 화면을 벗어나면 캐릭터가 어디 있는지 찾기가 어려울 겁니다. 일단 화면에서 벗어난 뒤 일정 시간이 지나면 플레이어를 다시 중점으로 순간이동시키기로 합시다. Three.js의 Frustum 클래스를 이용하면 특정 점이 카메라의 절두체(frustum) 안에 있는지 알 수 있습니다.

-

먼저 카메라로 절두체를 만들어야 합니다. 플레이어 컴포넌트에서 이걸 처리할 수도 있지만, 다른 요소도 이 방법을 써야 할 수 있으니 카메라의 절두체를 관리하는 새로운 컴포넌트를 만들겠습니다.

-
class CameraInfo extends Component {
-  constructor(gameObject) {
-    super(gameObject);
-    this.projScreenMatrix = new THREE.Matrix4();
-    this.frustum = new THREE.Frustum();
-  }
-  update() {
-    const { camera } = globals;
-    this.projScreenMatrix.multiplyMatrices(
-        camera.projectionMatrix,
-        camera.matrixWorldInverse);
-    this.frustum.setFromProjectionMatrix(this.projScreenMatrix);
-  }
-}
-
-

다음으로 init 함수에서 방금 만든 컴포넌트로 새로운 GameObject를 추가합니다.

-
function init() {
-  // 프로그래스 바를 숨깁니다.
-  const loadingElem = document.querySelector('#loading');
-  loadingElem.style.display = 'none';
-
-  prepModelsAndAnimations();
-
-+  {
-+    const gameObject = gameObjectManager.createGameObject(camera, 'camera');
-+    globals.cameraInfo = gameObject.addComponent(CameraInfo);
-+  }
-
-  {
-    const gameObject = gameObjectManager.createGameObject(scene, 'player');
-    gameObject.addComponent(Player);
-  }
-}
-
-

Player 컴포넌트에 방금 만든 GameObject를 사용하는 코드를 추가합니다.

-
class Player extends Component {
-  constructor(gameObject) {
-    super(gameObject);
-    const model = models.knight;
-    this.skinInstance = gameObject.addComponent(SkinInstance, model);
-    this.skinInstance.setAnimation('Run');
-    this.turnSpeed = globals.moveSpeed / 4;
-+    this.offscreenTimer = 0;
-+    this.maxTimeOffScreen = 3;
-  }
-  update() {
--    const { deltaTime, moveSpeed } = globals;
-+    const { deltaTime, moveSpeed, cameraInfo } = globals;
-    const { transform } = this.gameObject;
-    const delta = (inputManager.keys.left.down  ?  1 : 0) +
-                  (inputManager.keys.right.down ? -1 : 0);
-    transform.rotation.y += this.turnSpeed * delta * deltaTime;
-    transform.translateOnAxis(kForward, moveSpeed * deltaTime);
-
-+    const { frustum } = cameraInfo;
-+    if (frustum.containsPoint(transform.position)) {
-+      this.offscreenTimer = 0;
-+    } else {
-+      this.offscreenTimer += deltaTime;
-+      if (this.offscreenTimer >= this.maxTimeOffScreen) {
-+        transform.position.set(0, 0, 0);
-+      }
-+    }
-  }
-}
-
-

예제를 실행하기 전에 모바일 환경을 위한 터치 인터페이스를 추가하겠습니다. 먼저 터치 이벤트를 받을 HTML 요소를 만듭니다.

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="ui">
-+    <div id="left"><img src="../resources/images/left.svg"></div>
-+    <div style="flex: 0 0 40px;"></div>
-+    <div id="right"><img src="../resources/images/right.svg"></div>
-+  </div>
-  <div id="loading">
-    <div>
-      <div>...loading...</div>
-      <div class="progress"><div id="progressbar"></div></div>
-    </div>
-  </div>
-</body>
-
-

버튼의 스타일도 작성합니다.

-
#ui {
-  position: absolute;
-  left: 0;
-  top: 0;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  justify-items: center;
-  align-content: stretch;
-}
-#ui>div {
-  display: flex;
-  align-items: flex-end;
-  flex: 1 1 auto;
-}
-.bright {
-  filter: brightness(2);
-}
-#left {
-  justify-content: flex-end;
-}
-#right {
-  justify-content: flex-start;
-}
-#ui img {
-  padding: 10px;
-  width: 80px;
-  height: 80px;
-  display: block;
-}
-
-

제가 사용한 방법은 하나의 div 요소, #ui로 화면 전체를 채우고, 해당 요소의 자식으로 화면의 대략 반을 차지하는 #left#right를 각각 양쪽에, 가운데에는 40px짜리 구분선을 넣어 화면 전체가 이벤트를 감지하도록 한 것입니다. 이러면 사용자가 왼쪽 화살표를 누른 뒤 왼쪽에서 오른쪽으로 손가락을 움직였을 때 InputManagerkeys.leftkeys.right를 업데이트할 수 있겠죠. 굳이 작은 화살표를 누르느라 고생하지 않아도 되니 이 편이 훨씬 나을 겁니다.

-
class InputManager {
-  constructor() {
-    this.keys = {};
-    const keyMap = new Map();
-
-    const setKey = (keyName, pressed) => {
-      const keyState = this.keys[keyName];
-      keyState.justPressed = pressed && !keyState.down;
-      keyState.down = pressed;
-    };
-
-    const addKey = (keyCode, name) => {
-      this.keys[name] = { down: false, justPressed: false };
-      keyMap.set(keyCode, name);
-    };
-
-    const setKeyFromKeyCode = (keyCode, pressed) => {
-      const keyName = keyMap.get(keyCode);
-      if (!keyName) {
-        return;
-      }
-      setKey(keyName, pressed);
-    };
-
-    addKey(37, 'left');
-    addKey(39, 'right');
-    addKey(38, 'up');
-    addKey(40, 'down');
-    addKey(90, 'a');
-    addKey(88, 'b');
-
-    window.addEventListener('keydown', (e) => {
-      setKeyFromKeyCode(e.keyCode, true);
-    });
-    window.addEventListener('keyup', (e) => {
-      setKeyFromKeyCode(e.keyCode, false);
-    });
-
-+    const sides = [
-+      { elem: document.querySelector('#left'),  key: 'left'  },
-+      { elem: document.querySelector('#right'), key: 'right' },
-+    ];
-+
-+    const clearKeys = () => {
-+      for (const {key} of sides) {
-+          setKey(key, false);
-+      }
-+    };
-+
-+    const handleMouseMove = (e) => {
-+      e.preventDefault();
-+      // this is needed because we call preventDefault();
-+      // we also gave the canvas a tabindex so it can
-+      // become the focus
-+      canvas.focus();
-+      window.addEventListener('pointermove', handleMouseMove);
-+      window.addEventListener('pointerup', handleMouseUp);
-+
-+      for (const {elem, key} of sides) {
-+        let pressed = false;
-+        const rect = elem.getBoundingClientRect();
-+        const x = e.clientX;
-+        const y = e.clientY;
-+        const inRect = x >= rect.left && x < rect.right &&
-+                       y >= rect.top && y < rect.bottom;
-+        if (inRect) {
-+          pressed = true;
-+        }
-+        setKey(key, pressed);
-+      }
-+    };
-+
-+    function handleMouseUp() {
-+      clearKeys();
-+      window.removeEventListener('pointermove', handleMouseMove, {passive: false});
-+      window.removeEventListener('pointerup', handleMouseUp);
-+    }
-+
-+    const uiElem = document.querySelector('#ui');
-+    uiElem.addEventListener('pointerdown', handleMouseMove, {passive: false});
-+
-+    uiElem.addEventListener('touchstart', (e) => {
-+      // prevent scrolling
-+      e.preventDefault();
-+    }, {passive: false});
-  }
-  update() {
-    for (const keyState of Object.values(this.keys)) {
-      if (keyState.justPressed) {
-        keyState.justPressed = false;
-      }
-    }
-  }
-}
-
-

이제 화살표 키나 화면을 터치해 캐릭터를 움직일 수 있을 겁니다.

-

- -

-

물론 플레이어가 화면 밖으로 나갔을 때 카메라를 움직이거나, "화면 밖 = 죽음"이라는 설정을 넣을 수도 있습니다. 하지만 이것까지 다룬다면 안 그래도 긴 글이 더 길어질 테니 이 방법으로 만족하겠습니다.

-

이제 동물을 추가해봅시다. Player와 비슷한 방법으로 Animal 컴포넌트를 만듭니다.

-
class Animal extends Component {
-  constructor(gameObject, model) {
-    super(gameObject);
-    const skinInstance = gameObject.addComponent(SkinInstance, model);
-    skinInstance.mixer.timeScale = globals.moveSpeed / 4;
-    skinInstance.setAnimation('Idle');
-  }
-}
-
-

위 코드에서는 AnimationMixer.timeScale을 설정해 애니메이션 속도가 이동 속도에 비례하도록 만들었습니다. 이러면 이동 속도와 같이 애니메이션 속도가 빨라지고 느려지겠죠.

-

다음으로 init 함수에서 각 동물을 배치합니다.

-
function init() {
-  // 프로그래스 바를 숨깁니다.
-  const loadingElem = document.querySelector('#loading');
-  loadingElem.style.display = 'none';
-
-  prepModelsAndAnimations();
-  {
-    const gameObject = gameObjectManager.createGameObject(camera, 'camera');
-    globals.cameraInfo = gameObject.addComponent(CameraInfo);
-  }
-
-  {
-    const gameObject = gameObjectManager.createGameObject(scene, 'player');
-    globals.player = gameObject.addComponent(Player);
-    globals.congaLine = [gameObject];
-  }
-
-+  const animalModelNames = [
-+    'pig',
-+    'cow',
-+    'llama',
-+    'pug',
-+    'sheep',
-+    'zebra',
-+    'horse',
-+  ];
-+  animalModelNames.forEach((name, ndx) => {
-+    const gameObject = gameObjectManager.createGameObject(scene, name);
-+    gameObject.addComponent(Animal, models[name]);
-+    gameObject.transform.position.x = (ndx + 1) * 5;
-+  });
-}
-
-

동물들을 배치하고 끝내면 심심하니 뭔가를 추가해야겠네요.

-

동물이 플레이어를 따라 기차놀이*를 하게 해봅시다. 플레이어가 동물에 가까이 갔을 때만 기차에 합류하도록 하겠습니다. 이를 구현하려면 아래와 같은 모션(상태, state)이 필요할 겁니다.

-

※ 원문은 "conga line"입니다. 기차놀이와 유사한 꼬리잇기 놀이로, 우리에게 더 익숙한 "기차놀이"로 의역했습니다. 역주.

-
    -
  • 가만히 서 있는 모션(Idle):

    -

    플레이어가 가까워지기 전까지의 모션입니다.

    -
  • -
  • 기차의 끝에 갈 때까지 기다리는 모션(Wait for End of Line):

    -

    플레이어가 동물과 닿더라도 기차의 끝에 합류해야 하므로 그 전까지 기다리는 모션입니다.

    -
  • -
  • 따라붙기(Go to Last):

    -

    자신이 따라갈 대상이 있던 위치로 이동함과 동시에 따라갈 대상이 어디 있는지 기록합니다.

    -
  • -
  • 따라가기(Follow):

    -

    자신이 따라가는 대상의 현재 위치를 기록함과 동시에 대상이 있었던 위치로 이동합니다.

    -
  • -
-

이런 상태를 다룰 방법은 아주 다양합니다. 보통은 유한 상태 기계(Finite State Machine)와 이런 상태를 다룰 헬퍼 클래스를 사용하죠.

-
class FiniteStateMachine {
-  constructor(states, initialState) {
-    this.states = states;
-    this.transition(initialState);
-  }
-  get state() {
-    return this.currentState;
-  }
-  transition(state) {
-    const oldState = this.states[this.currentState];
-    if (oldState && oldState.exit) {
-      oldState.exit.call(this);
-    }
-    this.currentState = state;
-    const newState = this.states[state];
-    if (newState.enter) {
-      newState.enter.call(this);
-    }
-  }
-  update() {
-    const state = this.states[this.currentState];
-    if (state.update) {
-      state.update.call(this);
-    }
-  }
-}
-
-

위 클래스는 앞서 말한 헬퍼 클래스를 간단히 구현한 것입니다. 클래스는 생성 시 상태들의 객체를 받고, 각 상태에는 enter, update, exit이라는 메서드가 있습니다. 상태를 바꾸려면 FiniteStateMachine.transition을 호출할 때 새로운 이름을 넘겨주면 되죠. 만약 현재 상태에 exit 메서드가 있다면 해당 메서드를 호출합니다. 그리고 새로운 상태에 enter 메서드가 있을 경우 enter 메서드를 호출합니다. 마지막으로 매 프레임마다 FiniteStateMachine.update를 호출하면 각 상태의 update 메서드를 호출합니다.

-

이제 이 클래스를 활용해 동물들의 상태를 바꿔봅시다.

-
// 매개변수 obj1과 obj2이 가깝다면 true를 반환합니다.
-function isClose(obj1, obj1Radius, obj2, obj2Radius) {
-  const minDist = obj1Radius + obj2Radius;
-  const dist = obj1.position.distanceTo(obj2.position);
-  return dist < minDist;
-}
-
-// v 의 값이 -min과 +min 사이가 되도록 합니다.
-function minMagnitude(v, min) {
-  return Math.abs(v) > min
-      ? min * Math.sign(v)
-      : v;
-}
-
-const aimTowardAndGetDistance = function() {
-  const delta = new THREE.Vector3();
-
-  return function aimTowardAndGetDistance(source, targetPos, maxTurn) {
-    delta.subVectors(targetPos, source.position);
-    // 바라볼 방향을 계산합니다.
-    const targetRot = Math.atan2(delta.x, delta.z) + Math.PI * 1.5;
-    // 더 가까운 방향으로 회전합니다.
-    const deltaRot = (targetRot - source.rotation.y + Math.PI * 1.5) % (Math.PI * 2) - Math.PI;
-    // maxTurn보다 빠른 속도로 돌지 않도록 합니다.
-    const deltaRotation = minMagnitude(deltaRot, maxTurn);
-    // rotation 값을 0에서 Math.PI * 2 사이로 유지합니다.
-    source.rotation.y = THREE.MathUtils.euclideanModulo(
-        source.rotation.y + deltaRotation, Math.PI * 2);
-    // 목표까지의 거리를 반환합니다.
-    return delta.length();
-  };
-}();
-
-class Animal extends Component {
-  constructor(gameObject, model) {
-    super(gameObject);
-+    const hitRadius = model.size / 2;
-    const skinInstance = gameObject.addComponent(SkinInstance, model);
-    skinInstance.mixer.timeScale = globals.moveSpeed / 4;
-+    const transform = gameObject.transform;
-+    const playerTransform = globals.player.gameObject.transform;
-+    const maxTurnSpeed = Math.PI * (globals.moveSpeed / 4);
-+    const targetHistory = [];
-+    let targetNdx = 0;
-+
-+    function addHistory() {
-+      const targetGO = globals.congaLine[targetNdx];
-+      const newTargetPos = new THREE.Vector3();
-+      newTargetPos.copy(targetGO.transform.position);
-+      targetHistory.push(newTargetPos);
-+    }
-+
-+    this.fsm = new FiniteStateMachine({
-+      idle: {
-+        enter: () => {
-+          skinInstance.setAnimation('Idle');
-+        },
-+        update: () => {
-+          // 플레이어가 근처에 있는지 확인합니다.
-+          if (isClose(transform, hitRadius, playerTransform, globals.playerRadius)) {
-+            this.fsm.transition('waitForEnd');
-+          }
-+        },
-+      },
-+      waitForEnd: {
-+        enter: () => {
-+          skinInstance.setAnimation('Jump');
-+        },
-+        update: () => {
-+          // 기차의 가장 마지막에 있는 gameObject를 가져옵니다.
-+          const lastGO = globals.congaLine[globals.congaLine.length - 1];
-+          const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
-+          const targetPos = lastGO.transform.position;
-+          aimTowardAndGetDistance(transform, targetPos, deltaTurnSpeed);
-+          // 기차의 마지막에 있는 요소가 근처에 있는지 확인합니다.
-+          if (isClose(transform, hitRadius, lastGO.transform, globals.playerRadius)) {
-+            this.fsm.transition('goToLast');
-+          }
-+        },
-+      },
-+      goToLast: {
-+        enter: () => {
-+          // 따라갈 대상을 기록합니다. remember who we're following
-+          targetNdx = globals.congaLine.length - 1;
-+          // 기차의 마지막에 스스로를 추가합니다.
-+          globals.congaLine.push(gameObject);
-+          skinInstance.setAnimation('Walk');
-+        },
-+        update: () => {
-+          addHistory();
-+          // 기록된 위치 중 가장 나중 위치로 이동합니다.
-+          const targetPos = targetHistory[0];
-+          const maxVelocity = globals.moveSpeed * globals.deltaTime;
-+          const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
-+          const distance = aimTowardAndGetDistance(transform, targetPos, deltaTurnSpeed);
-+          const velocity = distance;
-+          transform.translateOnAxis(kForward, Math.min(velocity, maxVelocity));
-+          if (distance <= maxVelocity) {
-+            this.fsm.transition('follow');
-+          }
-+        },
-+      },
-+      follow: {
-+        update: () => {
-+          addHistory();
-+          // 가장 오래된 위치값을 지우고 자기 자신의 위치값을 추가합니다.
-+          const targetPos = targetHistory.shift();
-+          transform.position.copy(targetPos);
-+          const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
-+          aimTowardAndGetDistance(transform, targetHistory[0], deltaTurnSpeed);
-+        },
-+      },
-+    }, 'idle');
-+  }
-+  update() {
-+    this.fsm.update();
-+  }
-}
-
-

한 번에 너무 많은 코드를 보여준 듯하지만 위 코드는 방금 언급한 역할을 합니다. 각 상태에 대한 코드를 보고 어떤 식으로 작동하는지 분석해보기 바랍니다.

-

여기에 몇 가지 요소를 추가해야 합니다. 플레이어가 자기 자신을 전역 객체(globals)에 추가해 다른 동물이 자신의 위치를 추적하도록 해야 하고, 또 기차의 머리를 플레이어의 GameObject로 지정해야 합니다.

-
function init() {
-
-  ...
-
-  {
-    const gameObject = gameObjectManager.createGameObject(scene, 'player');
-+    globals.player = gameObject.addComponent(Player);
-+    globals.congaLine = [gameObject];
-  }
-
-}
-
-

각 모델의 크기도 계산해야 합니다.

-
function prepModelsAndAnimations() {
-+  const box = new THREE.Box3();
-+  const size = new THREE.Vector3();
-  Object.values(models).forEach(model => {
-+    box.setFromObject(model.gltf.scene);
-+    box.getSize(size);
-+    model.size = size.length();
-    const animsByName = {};
-    model.gltf.animations.forEach((clip) => {
-      animsByName[clip.name] = clip;
-      // 이런 부분은 .blend 파일에서 수정하는 게 좋습니다.
-      if (clip.name === 'Walk') {
-        clip.duration /= 2;
-      }
-    });
-    model.animations = animsByName;
-  });
-}
-
-

그리고 플레이어가 자기 자신의 크기를 기록하도록 합니다.

-
class Player extends Component {
-  constructor(gameObject) {
-    super(gameObject);
-    const model = models.knight;
-+    globals.playerRadius = model.size / 2;
-
-

이제 와 생각해보니 플레이어가 아니라 기차의 머리를 바라보게 하는 편이 더 나았겠네요. 이건 나중에 돌아와 고치도록 하겠습니다.

-

예제를 처음 만들었을 때는 동물들이 모두 같은 크기의 경계 원(radius)을 썼지만, 이렇게 하고 보니 말과 퍼그(강아지)의 크기가 같은 게 말이 안 된다는 생각이 들었습니다. 그래서 각 모델의 크기에 따라 경계 원을 따로 지정했죠. 그리고 상태를 보여주면 좋겠다는 생각이 들어 상태를 보여 줄 StateDisplayHelper 컴포넌트를 추가했습니다.

-

또한 PolarGridHelper를 써 각 캐릭터의 경계 원이 보이도록 했고, HTML 요소를 3D로 정렬하기에서 썼던 방법으로 각 캐릭터의 상태를 HTML로 보여주도록 했습니다.

-

먼저 각 요소를 담을 HTML을 추가합니다.

-
<body>
-  <canvas id="c"></canvas>
-  <div id="ui">
-    <div id="left"><img src="../resources/images/left.svg"></div>
-    <div style="flex: 0 0 40px;"></div>
-    <div id="right"><img src="../resources/images/right.svg"></div>
-  </div>
-  <div id="loading">
-    <div>
-      <div>...loading...</div>
-      <div class="progress"><div id="progressbar"></div></div>
-    </div>
-  </div>
-+  <div id="labels"></div>
-</body>
-
-

CSS도 작성합니다.

-
#labels {
-  position: absolute;  /* 기준 요소 위로 올라가도록 합니다. */
-  left: 0;             /* 기준 요소 왼쪽 위로 정렬합니다. */
-  top: 0;
-  color: white;
-  width: 100%;
-  height: 100%;
-  overflow: hidden;
-  pointer-events: none;
-}
-#labels>div {
-  position: absolute;  /* 기준 요소를 기준으로 합니다. */
-  left: 0;             /* 기준 요소의 왼쪽 위로 정렬합니다. */
-  top: 0;
-  font-size: large;
-  font-family: monospace;
-  user-select: none;   /* 텍스트를 선택할 수 없도록 합니다. */
-  text-shadow:         /* 글자에 검은 윤곽선을 넣습니다. */
-    -1px -1px 0 #000,
-     0   -1px 0 #000,
-     1px -1px 0 #000,
-     1px  0   0 #000,
-     1px  1px 0 #000,
-     0    1px 0 #000,
-    -1px  1px 0 #000,
-    -1px  0   0 #000;
-}
-
-

아래는 StateDisplayHelper 컴포넌트입니다.

-
const labelContainerElem = document.querySelector('#labels');
-
-class StateDisplayHelper extends Component {
-  constructor(gameObject, size) {
-    super(gameObject);
-    this.elem = document.createElement('div');
-    labelContainerElem.appendChild(this.elem);
-    this.pos = new THREE.Vector3();
-
-    this.helper = new THREE.PolarGridHelper(size / 2, 1, 1, 16);
-    gameObject.transform.add(this.helper);
-  }
-  setState(s) {
-    this.elem.textContent = s;
-  }
-  setColor(cssColor) {
-    this.elem.style.color = cssColor;
-    this.helper.material.color.set(cssColor);
-  }
-  update() {
-    const { pos } = this;
-    const { transform } = this.gameObject;
-    const { canvas } = globals;
-    pos.copy(transform.position);
-
-    /**
-     * 해당 위치값을 정규화하면 x와 y 값은 -1에서 +1 사이의 값이 됩니다.
-     * x = -1 이면 왼쪽, y = -1 이면 오른쪽이죠.
-     **/
-    pos.project(globals.camera);
-
-    // 정규화한 위치값을 CSS 위치값으로 변환합니다.
-    const x = (pos.x *  .5 + .5) * canvas.clientWidth;
-    const y = (pos.y * -.5 + .5) * canvas.clientHeight;
-
-    // HTML 요소를 해당 위치로 옮깁니다.
-    this.elem.style.transform = `translate(-50%, -50%) translate(${ x }px, ${ y }px)`;
-  }
-}
-
-

동물 컴포넌트를 생성할 때 위 컴포넌트를 추가하도록 합니다.

-
class Animal extends Component {
-  constructor(gameObject, model) {
-    super(gameObject);
-+    this.helper = gameObject.addComponent(StateDisplayHelper, model.size);
-
-     ...
-
-  }
-  update() {
-    this.fsm.update();
-+    const dir = THREE.MathUtils.radToDeg(this.gameObject.transform.rotation.y);
-+    this.helper.setState(`${ this.fsm.state }:${ dir.toFixed(0) }`);
-  }
-}
-
-

추가로 lil-gui를 이용해 위 디버깅 요소들를 켜고 끌 수 있도록 합니다.

-
import * as THREE from 'three';
-import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
-import { SkeletonUtils } from 'three/addons/utils/SkeletonUtils.js';
-+import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
-
-
+const gui = new GUI();
-+gui.add(globals, 'debug').onChange(showHideDebugInfo);
-+showHideDebugInfo();
-
-const labelContainerElem = document.querySelector('#labels');
-+function showHideDebugInfo() {
-+  labelContainerElem.style.display = globals.debug ? '' : 'none';
-+}
-+showHideDebugInfo();
-
-class StateDisplayHelper extends Component {
-
-  ...
-
-  update() {
-+    this.helper.visible = globals.debug;
-+    if (!globals.debug) {
-+      return;
-+    }
-
-    ...
-  }
-}
-
-

게임의 가장 기본적인 틀을 완성했네요.

-

- -

-

원래 처음에는 지렁이 게임을 만들려고 했습니다. 동물이 기차에 붙어 기차가 길어질수록 장애물을 피하기 어려워지는 게임이죠. 예제에 몇 가지 장애물 놓거나 화면 둘레에 벽을 세우기도 했습니다.

-

하지만 예제에서 사용한 동물은 이에 적합하지 않습니다. 예제의 동물들은 대부분 위에서 봤을 때 길고 폭이 얇거든요. 아래는 얼룩말을 위에서 본 것입니다.

-
- -

예제는 원 모양의 경계로 요소끼리의 충돌을 감지하기에 아래와 같이 울타리에 닿는 경우도 충돌로 감지할 겁니다.

-
- -

동물과 동물이 부딪히는 경우에도 마찬가지입니다. 특히 게임에서는 이래서 좋을 게 없죠.

-

2D 사각형을 만들어 충돌을 감지하는 것도 생각했으나, 바로 너무 많은 코드를 써야 한다는 걸 깨달았습니다. 예제의 각 모델에 다른 크기의 사각형을 추가하는 데는 그다지 많은 코드가 들어가지 않습니다. 하지만 이렇게 몇 가지 모델에 사각형을 추가해보면 곧 충돌을 감지하는 코드를 손봐야 할 필요가 생길 겁니다. 먼저 각 모델이 서로 충돌하는지 확인해야 하니 각 모델의 경계 정육면체나 경계 구체, 또는 모델과 같은 방향으로 정렬된 경계 육면체를 검사해야 합니다. 각 모델의 경계가 충돌했다는 건 두 모델이 어쩌면 서로 충돌했을 수도 있다는 이야기이기에, 각 모델이 실제로 충돌했는지 검사하기 위해 해당 모델들을 다시 검사해야 합니다. 대체로 경계 구체를 검사하는 것만 해도 꽤 많은 작업이 필요합니다. 가능하다면 각 요소가 근접했는지의 여부만 검사하는 등 더 특수한 방법을 사용하는 게 더 경제적이죠.

-

또한 충돌 여부를 검사하기만 하는 것으로 끝나는 것이 아니라 충돌 시스템도 구축해야 합니다. 그때 그때 각 모델에게 "너 다른 애랑 충돌했니?" 이렇게 물어보는 것보다 시스템이 직접 충돌 여부를 이벤트 등으로 알려주는 게 더 편할 테니까요. 충돌 시스템은 충돌과 관련한 이벤트나 콜백을 사용합니다. 이 방법의 장점은 모든 충돌을 한 번만 검사하기에 각 모델이 "내가 다른 애랑 충돌했나?" 이렇게 검사를 따로 할 필요가 없다는 거죠. 연산량을 훨씬 줄일 수 있습니다.

-

사각형을 확인하는 정도의 간단한 충돌 시스템을 만드는 코드는 100-300 줄 정도를 넘지 않을 겁니다. 하지만 예제와 비교하면 여전히 많은 코드이니 지금은 이대로 남겨 두겠습니다.

-

시도해봄직한 다른 방법은 다른 캐릭터 중 위에서 바라봤을 때 가장 원형에 가까운 캐릭터를 찾는 겁니다*. 인간형 캐릭터의 경우는 대부분 잘 작동할 테고, 동물과 동물의 경우도 일부 경우는 잘 작동할 겁니다. 하지만 동물과 울타리의 경우는 감지하지 못하겠죠. 원래 화면 주위에 울타리나 덤불, 둥근 막대를 둘러보려고 했으나 이러려면 120에서 200개 정도의 요소를 더 만들어야 하고, 위에서 언급한 최적화 문제에 부딪쳤을 겁니다.

-

※ 시야각 때문에 카메라의 중심에서 벗어날수록 머리 위가 아닌 옆이 보이는 걸 이용한 방법. 역주.

-

이런 여러 문제 때문에 대부분의 게임들이 기존에 쓰던 방법을 사용합니다. 그리고 이 방법들 중에는 물리 라이브러리에서 쓰는 것들도 있죠. 물리 라이브러리는 요소가 서로 충돌하는지 확인하는 기능이 필수기에, 제가 위에서 사용했던 방법을 사용하기도 합니다.

-

Three.js의 예제 중 ammo.js를 사용한 것을 보면 이런 해결 방법을 찾는 데 도움이 될지도 모르겠네요.

-

또 다른 방법은 장애물을 일정한 격자(grid)에 놓고 플레이어와 동물이 해당 격자만 참조하게 하는 겁니다. 성능 면에서 굉장히 좋은 방법인데, 이 또한 여러분이 직접 연습할 수 있는 😜 요소로 남겨 두면 좋겠다는 생각이 들더군요.

-

덧붙여 대부분의 게임 시스템에는 코루틴(coroutine)이라는 것이 있습니다. 코루틴은 특정 작업을 하는 동안 멈췄다가 나중에 다시 시작하는 루틴(routine)을 말하죠.

-

플레이어 위에 음표를 띄워 노래로 동물들을 꼬시는 것처럼 해보겠습니다. 구현할 수 있는 방법은 아주 많지만, 예제에서는 코루틴을 사용해 이를 구현하겠습니다.

-

먼저 코루틴을 관리하는 클래스를 만듭니다.

-
function* waitSeconds(duration) {
-  while (duration > 0) {
-    duration -= globals.deltaTime;
-    yield;
-  }
-}
-
-class CoroutineRunner {
-  constructor() {
-    this.generatorStacks = [];
-    this.addQueue = [];
-    this.removeQueue = new Set();
-  }
-  isBusy() {
-    return this.addQueue.length + this.generatorStacks.length > 0;
-  }
-  add(generator, delay = 0) {
-    const genStack = [generator];
-    if (delay) {
-      genStack.push(waitSeconds(delay));
-    }
-    this.addQueue.push(genStack);
-  }
-  remove(generator) {
-    this.removeQueue.add(generator);
-  }
-  update() {
-    this._addQueued();
-    this._removeQueued();
-    for (const genStack of this.generatorStacks) {
-      const main = genStack[0];
-      // 다른 코루틴이 해당 요소를 제거했을 경우
-      if (this.removeQueue.has(main)) {
-        continue;
-      }
-      while (genStack.length) {
-        const topGen = genStack[genStack.length - 1];
-        const { value, done } = topGen.next();
-        if (done) {
-          if (genStack.length === 1) {
-            this.removeQueue.add(topGen);
-            break;
-          }
-          genStack.pop();
-        } else if (value) {
-          genStack.push(value);
-        } else {
-          break;
-        }
-      }
-    }
-    this._removeQueued();
-  }
-  _addQueued() {
-    if (this.addQueue.length) {
-      this.generatorStacks.splice(this.generatorStacks.length, 0, ...this.addQueue);
-      this.addQueue = [];
-    }
-  }
-  _removeQueued() {
-    if (this.removeQueue.size) {
-      this.generatorStacks = this.generatorStacks.filter(genStack => !this.removeQueue.has(genStack[0]));
-      this.removeQueue.clear();
-    }
-  }
-}
-
-

위 클래스는 다른 코루틴이 실행되는 동안 요소를 안전하게 제거/추가하도록 SafeArray와 비슷한 구조로 만들었습니다. 또한 이 클래스는 중첩된 코루틴도 처리합니다.

-

코루틴을 만들려면 자바스크립트의 제너레이터 함수를 만들어야 합니다. 제너레이터 함수는 function*이라는 키워드로 생성하죠(별표를 붙여야 합니다!).

-

제너레이터 함수는 yield 키워드로 실행 순서를 양보(yield)할 수 있습니다.

-
function* count0To9() {
-  for (let i = 0; i < 10; ++i) {
-    console.log(i);
-    yield;
-  }
-}
-
-

이 함수를 아까 만든 CoroutineRunner에 추가하면 한 프레임, 또는 runner.update를 호출할 때마다 0부터 9까지의 숫자를 차례대로 출력할 겁니다.

-
const runner = new CoroutineRunner();
-runner.add(count0To9);
-while(runner.isBusy()) {
-  runner.update();
-}
-
-

코루틴은 동작이 끝났을 때 자동으로 제거됩니다. 코루틴이 끝나기 전에 제거하려면 제너레이터를 미리 참조한 뒤 remove 메서드를 호출해야 합니다.

-
const gen = count0To9();
-runner.add(gen);
-
-// 얼마 후
-
-runner.remove(gen);
-
-

이제 플레이어가 0.5에서 1초 사이마다 한 번씩 음표를 뱉도록 해봅시다.

-
class Player extends Component {
-  constructor(gameObject) {
-
-    ...
-
-+    this.runner = new CoroutineRunner();
-+
-+    function* emitNotes() {
-+      for (;;) {
-+        yield waitSeconds(rand(0.5, 1));
-+        const noteGO = gameObjectManager.createGameObject(scene, 'note');
-+        noteGO.transform.position.copy(gameObject.transform.position);
-+        noteGO.transform.position.y += 5;
-+        noteGO.addComponent(Note);
-+      }
-+    }
-+
-+    this.runner.add(emitNotes());
-  }
-  update() {
-+    this.runner.update();
-
-  ...
-
-  }
-}
-
-function rand(min, max) {
-  if (max === undefined) {
-    max = min;
-    min = 0;
-  }
-  return Math.random() * (max - min) + min;
-}
-
-

위 코드에서는 CoroutineRunner를 만들고 emitNotes 코루틴을 추가했습니다. 이 함수는 0.5초에서 1초 사이마다 계속해서 Note 컴포넌트를 생성합니다.

-

Note 컴포넌트를 만들려면 먼저 텍스처가 필요합니다. 음표 이미지를 불러올 수도 있지만, 캔버스로 텍스처 만들기에서 다뤘던 것처럼 캔버스를 이용해 직접 음표를 만들겠습니다.

-
function makeTextTexture(str) {
-  const ctx = document.createElement('canvas').getContext('2d');
-  ctx.canvas.width = 64;
-  ctx.canvas.height = 64;
-  ctx.font = '60px sans-serif';
-  ctx.textAlign = 'center';
-  ctx.textBaseline = 'middle';
-  ctx.fillStyle = '#FFF';
-  ctx.fillText(str, ctx.canvas.width / 2, ctx.canvas.height / 2);
-  return new THREE.CanvasTexture(ctx.canvas);
-}
-const noteTexture = makeTextTexture('♪');
-
-

위에서 만든 텍스처는 하얀색으로, 나중에 텍스처를 사용할 때 색을 따로 지정해 원하는 색의 음표를 그릴 수 있습니다.

-

이제 음표 텍스처를 만들었으니 Note 컴포넌트를 만들 차례입니다. 음표 컴포넌트는 빌보드에 관한 글에서 다뤘던 SpriteMaterialSprite를 사용합니다.

-
class Note extends Component {
-  constructor(gameObject) {
-    super(gameObject);
-    const { transform } = gameObject;
-    const noteMaterial = new THREE.SpriteMaterial({
-      color: new THREE.Color().setHSL(rand(1), 1, 0.5),
-      map: noteTexture,
-      side: THREE.DoubleSide,
-      transparent: true,
-    });
-    const note = new THREE.Sprite(noteMaterial);
-    note.scale.setScalar(3);
-    transform.add(note);
-    this.runner = new CoroutineRunner();
-    const direction = new THREE.Vector3(rand(-0.2, 0.2), 1, rand(-0.2, 0.2));
-
-    function* moveAndRemove() {
-      for (let i = 0; i < 60; ++i) {
-        transform.translateOnAxis(direction, globals.deltaTime * 10);
-        noteMaterial.opacity = 1 - (i / 60);
-        yield;
-      }
-      transform.parent.remove(transform);
-      gameObjectManager.removeGameObject(gameObject);
-    }
-
-    this.runner.add(moveAndRemove());
-  }
-  update() {
-    this.runner.update();
-  }
-}
-
-

이 컴포넌트는 Sprite를 만들고 무작위로 속도를 정해 60프레임 동안 그 속도로 이동하게 합니다. 동시에 재질의 opacity 속성을 바꿔 페이드-아웃 효과도 주죠. 반복문이 끝나면 이 컴포넌트는 위치값과 음표를 해당 GameObject에서 제거합니다.

-

정말 마지막으로, 동물의 수를 좀 늘려보겠습니다.

-
function init() {
-
-   ...
-
-  const animalModelNames = [
-    'pig',
-    'cow',
-    'llama',
-    'pug',
-    'sheep',
-    'zebra',
-    'horse',
-  ];
-+  const base = new THREE.Object3D();
-+  const offset = new THREE.Object3D();
-+  base.add(offset);
-+
-+  // 소용돌이 형태로 동물들을 배치합니다.
-+  const numAnimals = 28;
-+  const arc = 10;
-+  const b = 10 / (2 * Math.PI);
-+  let r = 10;
-+  let phi = r / b;
-+  for (let i = 0; i < numAnimals; ++i) {
-+    const name = animalModelNames[rand(animalModelNames.length) | 0];
-    const gameObject = gameObjectManager.createGameObject(scene, name);
-    gameObject.addComponent(Animal, models[name]);
-+    base.rotation.y = phi;
-+    offset.position.x = r;
-+    offset.updateWorldMatrix(true, false);
-+    offset.getWorldPosition(gameObject.transform.position);
-+    phi += arc / r;
-+    r = b * phi;
-  }
-
-

- -

-

누군가 setTimeout을 쓰면 안 되냐고 물을지도 모르겠습니다. setTimeout을 쓰지 않은 건 setTimeout은 게임의 프레임 주기와 무관하기 때문입니다. 예를 들어 예제에서는 프레임 간 시간값을 최대 1/20초로 제한했죠. 방금 구축한 코루틴 시스템도 이 제한을 따를 테지만, setTimeout을 쓰면 그렇지 않을 겁니다.

-

물론 좀 더 간단한 타이머를 만들 수도 있습니다.

-
class Player ... {
-  update() {
-    this.noteTimer -= globals.deltaTime;
-    if (this.noteTimer <= 0) {
-      // 타이머를 초기화합니다.
-      this.noteTimer = rand(0.5, 1);
-      // GameObject로 음표 컴포넌트를 만듭니다.
-    }
-  }
-
-

특정 경우에야 이 방법이 더 좋을 수도 있지만, 더 많은 요소를 추가하면 그만큼 더 많은 변수와 코루틴을 추가해야 할테고, 그럴수록 setTimeout설정하고 까먹을 확률이 높아질 겁니다.

-

동물들의 상태를 설정할 때도 아래와 같이 코루틴을 사용할 수 있습니다.

-
// 실제로 사용하지 않는 함수
-function* animalCoroutine() {
-   setAnimation('Idle');
-   while(playerIsTooFar()) {
-     yield;
-   }
-   const target = endOfLine;
-   setAnimation('Jump');
-   while(targetIsTooFar()) {
-     aimAt(target);
-     yield;
-   }
-   setAnimation('Walk')
-   while(notAtOldestPositionOfTarget()) {
-     addHistory();
-     aimAt(target);
-     yield;
-   }
-   for(;;) {
-     addHistory();
-     const pos = history.unshift();
-     transform.position.copy(pos);
-     aimAt(history[0]);
-     yield;
-   }
-}
-
-

이 방법을 써도 딱히 문제는 없었겠지만, 상태가 일정하지 않아 다시 FiniteStateMachine을 찾게 될 겁니다.

-

또 저는 코루틴을 해당 컴포넌트와 독립적으로 실행하는 게 좋은지 잘 모르겠습니다. 물론 그냥 전역에 CoroutineRunner를 만들어 모든 코루틴을 여기에 집어 넣을 수는 있죠. 하지만 이러면 코루틴을 없애기가 힘들어질 겁니다. 지금 예제는 GameObject를 제거하면 해당 컴포넌트도 제거되고, 그러면 생성한 CoroutineRunner의 메서드를 호출할 일도 없으니 코루틴도 전부 가비지 컬렉션에 들어갈 겁니다. 전역에 CoroutineRunner를 두면 컴포넌트에서 직접 이 전역 객체의 코루틴을 제거하거나 자동으로 코루틴을 제거할 다른 방법이 필요할 겁니다.

-

실제 게임 엔진이라면 더 고려해야 할 문제가 많습니다. 지금은 GameObject나 컴포넌트에 따로 순서를 지정할 수 없죠. 그냥 추가한 순서가 해당 요소의 순서가 됩니다. 대부분의 게임 엔진은 우선 순위를 정해 순서를 바꿀 수 있습니다.

-

다른 문제는 Note 컴포넌트가 장면 위 GameObject의 transform 속성을 변경한다는 겁니다. 애초에 GameObject가 transform 속성을 변경했으니 좀 더 제대로 구현하려면 GameObject가 계속 transform 속성을 관리하는 게 맞겠죠. GameObjectdispose 같은 메서드를 두고 GameObjectManager.removeGameObject에서 이 메서드를 호출했다면 어떨까요?

-

gameObjectManager.updateinputManager.update를 직접 호출하는 대신 SystemManager를 만들어 update메서드를 가진 요소를 전부 추가해 이 클래스가 메서드를 호출하도록 하는 게 더 나을 수도 있습니다. 이렇게 하면 CollisionManager 등 새로운 시스템을 만들었을 때 render 함수를 수정하는 게 아니라 SystemManager에 이 시스템을 추가하기만 하면 될 겁니다.

-

저는 이런 문제들을 전부 다루기보다 여러분의 몫으로 남겨 두고자 합니다. 부디 이 글이 여러분만의 게임 엔진을 만드는 데 도움이 되었다면 좋겠네요.

-

어쩌면 제가 게임 잼(game jam)*을 열 수도 있겠네요. 위 예제의 jsfiddle이나 codepen을 클릭해보면 코드를 바로 편집해 볼 수 있는 사이트가 열릴 겁니다. 특정 기능을 추가하거나 해서 예제를 퍼그가 기사(knight)을 끌고 다니는 게임을 만들거나, 기사의 구르기 애니메이션을 볼링공으로써 동물 볼링 게임을 만들 수도 있겠죠. 또는 동물 이어 달리기 게임이라든가요. 괜찮은 게임을 만들었다면 아래에 댓글로 링크를 남겨주시면 감사하겠습니다.

-

※ 게임 잼: 보통 24시간에서 72시간 정도의 짧은 기간 내에 팀, 또는 개인이 게임을 만드는 대회. 역주.

-
-[1]: 물론 부모의 어떤 요소도 translation, rotation, scale 속성을 바꾸지 않았다면 정상적으로 작동합니다.[돌아가기] -
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/indexed-textures.html b/manual/ko/indexed-textures.html deleted file mode 100644 index cccc23666ad5ee..00000000000000 --- a/manual/ko/indexed-textures.html +++ /dev/null @@ -1,541 +0,0 @@ - - - 피킹과 색상에 인덱스 텍스처 사용하기 - - - - - - - - - - - - - - -
-
-

피킹과 색상에 인덱스 텍스처 사용하기

-
-
-
-

※ 이 글은 HTML 요소를 3D로 정렬하기에서 이어집니다. 이전 글을 읽지 않았다면 먼저 읽고 오기 바랍니다.

-

Three.js를 쓰다보면 창의적인 해결법이 필요할 때가 있습니다. 저도 나름 시리즈를 진행하며 나름 많은 해결법을 찾고, 적어 놓았죠. 혹 필요한 게 있다면 확인해보기 바랍니다. 물론 그게 최적의 해결법이라고 단언할 수는 없지만요.

-

이전 글에서는 3D 지구본 주위에 나라 이름을 표기했습니다. 여기서 더 나아가 사용자가 나라를 선택하고 자기가 선택한 나라를 보게 한다면 어떨까요? 또 어떻게 구현할 수 있을까요?

-

가장 쉽게 떠오르는 방법은 각 나라마다 geometry를 만드는 겁니다. 이전에 했던 것처럼 피킹(picking)을 써서 구현할 수 있겠죠. 이미 각 나라의 3D geometry는 만들었으니 사용자가 mesh를 클릭했을 때 어떤 나라를 클릭했는지 알 수 있을 겁니다.

-

시험삼아 이전 글에서 윤곽선을 만들기 위해 사용했던 데이터로 각 나라마다 3D mesh를 만들어봤습니다. 결과로 15.5MB짜리 GLTF(.glb) 파일이 나왔죠. 사용자가 간단한 지구본을 보려고 15.5MB나 다운 받아야 한다니, 개인적인 의견이지만 너무 과한 듯합니다.

-

데이터를 압축할 수 있는 방법이야 많습니다. 예를 들어 특정 알고리즘을 도입해 윤곽선의 해상도를 낮출 수 있죠. 이 글에서는 시도하지 않을 텐데, 이유는 미국의 경우 데이터를 많이 줄일 수 있겠지만 캐나다나 섬이 많은 나라는 그렇지 않을 것이기 때문입니다.

-

다른 방법은 실제 데이터를 전부 압축하는 겁니다. 압축 프로그램을 돌려 압축하니 용량이 11MB까지 줄더군요. 30% 정도 줄긴 했지만 여전히 큰 파일입니다.

-

32비트 부동 소수 대신 16비트 방식으로 데이터를 저장할 수도 있습니다. 또는 드레이코 압축기 같은 프로그램을 사용하는 것만으로 충분히 데이터를 줄일 수 있을지도 모르죠. 전 따로 드레이코 압축기를 사용해보진 않았으니 여러분이 한 번 써보시고 알려주신다면 감사하겠습니다 😅.

-

이 글에서는 피킹에 관한 글 마지막에서 다뤘던 GPU 피킹을 사용해보겠습니다. 각 mesh에 id 역할을 할 고유한 색을 부여하고 해당 mesh를 클릭했을 때 해당 픽셀의 색상값으로 사용자가 어떤 mesh를 클릭했는지 알아내는 방법이죠.

-

일단 각 나라에 고유한 색상을 부여한 뒤, 이 색상값을 인덱스로 나라 배열을 만듭니다. 그리고 피킹용 텍스처를 만든 뒤 이걸로 지구본을 렌더링합니다. 이러면 사용자가 클릭한 픽셀을 확인해 어떤 나라를 클릭했는지 알 수 있겠죠.

-

먼저 약간의 코드를 작성해 아래의 텍스처를 만들었습니다.

-
- -
-

참고: 이 텍스트를 만드는 데 사용한 데이터는 이 웹사이트이며, 라이선스는 CC-BY-SA입니다.

-
-

이 이미지는 271KB 정도밖에 되지 않습니다. 나라들의 mesh가 14MB가 넘었던 것에 비하면 훨씬 낫네요. 물론 해상도를 더 낮출 수도 있지만 이 정도면 충분한 것 같네요.

-

이제 나라에 피킹을 적용해 봅시다.

-

GPU 피킹 예제의 코드를 가져와 피킹용 장면(scene)을 따로 만듭니다.

-
const pickingScene = new THREE.Scene();
-pickingScene.background = new THREE.Color(0);
-
-

피킹용 장면에 피킹용 텍스처를 입힌 지구본을 추가합니다.

-
{
-  const loader = new THREE.TextureLoader();
-  const geometry = new THREE.SphereGeometry(1, 64, 32);
-
-+  const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
-+  indexTexture.minFilter = THREE.NearestFilter;
-+  indexTexture.magFilter = THREE.NearestFilter;
-+
-+  const pickingMaterial = new THREE.MeshBasicMaterial({ map: indexTexture });
-+  pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
-
-  const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
-  const material = new THREE.MeshBasicMaterial({ map: texture });
-  scene.add(new THREE.Mesh(geometry, material));
-}
-
-

다음으로 GPUPickHelper를 통째로 가져와 몇 가지 수정합니다.

-
class GPUPickHelper {
-  constructor() {
-    // 1x1 픽셀 크기의 렌더 타겟을 생성합니다
-    this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
-    this.pixelBuffer = new Uint8Array(4);
--    this.pickedObject = null;
--    this.pickedObjectSavedColor = 0;
-  }
-  pick(cssPosition, scene, camera) {
-    const { pickingTexture, pixelBuffer } = this;
-
-    // view offset을 마우스 포인터 아래 1픽셀로 설정합니다
-    const pixelRatio = renderer.getPixelRatio();
-    camera.setViewOffset(
-      renderer.getContext().drawingBufferWidth,   // 전체 너비
-      renderer.getContext().drawingBufferHeight,  // 전체 높이
-      cssPosition.x * pixelRatio | 0,             // 사각 x 좌표
-      cssPosition.y * pixelRatio | 0,             // 사각 y 좌표
-      1,                                          // 사각 좌표 width
-      1,                                          // 사각 좌표 height
-    );
-    // 장면을 렌더링합니다
-    renderer.setRenderTarget(pickingTexture);
-    renderer.render(scene, camera);
-    renderer.setRenderTarget(null);
-    // view offset을 정상으로 돌려 원래의 화면을 렌더링하도록 합니다
-    camera.clearViewOffset();
-    // 픽셀을 감지합니다
-    renderer.readRenderTargetPixels(
-        pickingTexture,
-        0,   // x
-        0,   // y
-        1,   // width
-        1,   // height
-        pixelBuffer);
-
-+    const id =
-+        (pixelBuffer[0] << 16) |
-+        (pixelBuffer[1] <<  8) |
-+        (pixelBuffer[2] <<  0);
-+
-+    return id;
--    const id =
--        (pixelBuffer[0] << 16) |
--        (pixelBuffer[1] <<  8) |
--        (pixelBuffer[2]      );
--    const intersectedObject = idToObject[id];
--    if (intersectedObject) {
--      // 첫 번째 물체가 제일 가까우므로 해당 물체를 고릅니다
--      this.pickedObject = intersectedObject;
--      // 기존 색을 저장해둡니다
--      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
--      // emissive 색을 빨강/노랑으로 빛나게 만듭니다
--      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
--    }
-  }
-}
-
-

GPUPickHelper를 이용해 나라를 선택하도록 합니다.

-
const pickHelper = new GPUPickHelper();
-
-function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function pickCountry(event) {
-  // 아직 데이터를 불러오지 않았을 경우
-  if (!countryInfos) {
-    return;
-  }
-
-  const position = getCanvasRelativePosition(event);
-  const id = pickHelper.pick(position, pickingScene, camera);
-  if (id > 0) {
-    // 나라를 선택했을 때 해당 나라의 'selected' 속성을 바꿉니다.
-    const countryInfo = countryInfos[id - 1];
-    const selected = !countryInfo.selected;
-    // 나라를 클릭했을 때 특수키를 누르지 않았다면 다른 나라의 'selected'
-    // 속성을 전부 끕니다.
-    if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
-      unselectAllCountries();
-    }
-    numCountriesSelected += selected ? 1 : -1;
-    countryInfo.selected = selected;
-  } else if (numCountriesSelected) {
-    // 바다나 하늘을 클릭했을 경우
-    unselectAllCountries();
-  }
-  requestRenderIfNotRequested();
-}
-
-function unselectAllCountries() {
-  numCountriesSelected = 0;
-  countryInfos.forEach((countryInfo) => {
-    countryInfo.selected = false;
-  });
-}
-
-canvas.addEventListener('pointerup', pickCountry);
-
-

위 코드는 나라 배열에 속한 나라의 selected 속성을 켜고 끕니다. shift, ctrl, cmd 중 하나를 누르면 하나 이상의 나라를 선택할 수 있죠.

-

이제 선택한 나라를 보여줄 일만 남았습니다. 지금은 일단 해당 나라의 이름표를 보여주기로 하죠.

-
function updateLabels() {
-  // 아직 데이터를 불러오지 않았을 경우
-  if (!countryInfos) {
-    return;
-  }
-
-  const large = settings.minArea * settings.minArea;
-  // 카메라의 상대 방향을 나타내는 행렬 좌표를 가져옵니다.
-  normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
-  // 카메라의 위치를 가져옵니다.
-  camera.getWorldPosition(cameraPosition);
-  for (const countryInfo of countryInfos) {
--    const { position, elem, area } = countryInfo;
--    // 영역이 특정 값보다 작다면 이름표를 표시하지 않습니다.
--    if (area < large) {
-+    const { position, elem, area, selected } = countryInfo;
-+    const largeEnough = area >= large;
-+    const show = selected || (numCountriesSelected === 0 && largeEnough);
-+    if (!show) {
-      elem.style.display = 'none';
-      continue;
-    }
-
-    ...
-
-

이제 나라를 선택해 볼 수 있습니다.

-

- -

-

위 예제는 여전히 영역 크기에 따라 나라 이름을 보여주긴 하나, 특정 나라를 클릭하면 해당 나라의 이름만 보여줄 겁니다.

-

이만하면 각 나라를 피킹하는 예제로 충분해 보이지만... 선택한 나라의 색을 바꾸려면 어떻게 해야 할까요?

-

컬러 팔레트(color palette)를 사용하면 이 문제를 해결할 수 있습니다.

-

컬러 팔레트) 혹은 인덱스 팔레트는 아타리 800, Amiga, NES, 슈퍼 닌텐도, 구형 IBM PC 등 구형 시스템에서 사용하던 기법입니다. 비트맵을 색상당 8비트 혹은 32바이트 이상의 RGBA 색상으로 적용하는 대신 비트맵을 8비트 이하의 값으로 저장하는 기법이죠. 각 픽셀의 색상값은 팔레트의 인덱스 값으로, 픽셀의 색상값이 3이라면 특정 "팔레트"의 3번 색상을 사용한다는 의미입니다.

-

자바스크립트로 설명하자면 아래와 같은 형식을 생각할 수 있습니다.

-
const face7x7PixelImageData = [
-  0, 1, 1, 1, 1, 1, 0,
-  1, 0, 0, 0, 0, 0, 1,
-  1, 0, 2, 0, 2, 0, 1,
-  1, 0, 0, 0, 0, 0, 1,
-  1, 0, 3, 3, 3, 0, 1,
-  1, 0, 0, 0, 0, 0, 1,
-  0, 1, 1, 1, 1, 1, 1,
-];
-
-const palette = [
-  [255, 255, 255],  // white
-  [  0,   0,   0],  // black
-  [  0, 255, 255],  // cyan
-  [255,   0,   0],  // red
-];
-
-

이미지 데이터의 각 픽셀은 팔레트의 인덱스를 가리킵니다. 위 데이터를 위 팔레트로 해석하면 다음과 같은 이미지가 나오겠죠.

-
- -

예제의 경우 이미 각 나라별로 고유 색을 부여한 텍스처가 있습니다. 이 텍스처에 팔레트를 적용하면 각 나라에 다른 색을 부여할 수 있겠죠. 또 이 팔레트의 색을 바꾸면 각 나라의 색도 바뀔 겁니다. 그러니 팔레트의 색을 전부 검정으로 바꾼 뒤, 선택한 나라만 다른 색으로 바꾸면 해당 나라를 선택했다는 것을 시각적으로 나타낼 수 있을 겁니다.

-

컬러 팔레트 기법을 사용하려면 쉐이더를 직접 만들어야 합니다. Three.js의 내장 쉐이더를 수정해서 사용하면 조명이나 다른 기능도 나중에 사용할 수 있으니 이 방법을 사용하도록 하죠.

-

다중 애니메이션 요소 최적화하기에서 다뤘듯 재질의 onBeforeCompile 속성에 함수를 지정하면 내장 쉐이더를 수정할 수 있습니다.

-

아래는 내장 fragment 쉐이더를 수정하기 전입니다.

-
#include <common>
-#include <color_pars_fragment>
-#include <uv_pars_fragment>
-#include <map_pars_fragment>
-#include <alphamap_pars_fragment>
-#include <aomap_pars_fragment>
-#include <lightmap_pars_fragment>
-#include <envmap_pars_fragment>
-#include <fog_pars_fragment>
-#include <specularmap_pars_fragment>
-#include <logdepthbuf_pars_fragment>
-#include <clipping_planes_pars_fragment>
-void main() {
-    #include <clipping_planes_fragment>
-    vec4 diffuseColor = vec4( diffuse, opacity );
-    #include <logdepthbuf_fragment>
-    #include <map_fragment>
-    #include <color_fragment>
-    #include <alphamap_fragment>
-    #include <alphatest_fragment>
-    #include <specularmap_fragment>
-    ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );
-    #ifdef USE_LIGHTMAP
-        reflectedLight.indirectDiffuse += texture2D( lightMap, vLightMapUv ).xyz * lightMapIntensity;
-    #else
-        reflectedLight.indirectDiffuse += vec3( 1.0 );
-    #endif
-    #include <aomap_fragment>
-    reflectedLight.indirectDiffuse *= diffuseColor.rgb;
-    vec3 outgoingLight = reflectedLight.indirectDiffuse;
-    #include <envmap_fragment>
-    gl_FragColor = vec4( outgoingLight, diffuseColor.a );
-    #include <premultiplied_alpha_fragment>
-    #include <tonemapping_fragment>
-    #include <colorspace_fragment>
-    #include <fog_fragment>
-}
-
-

위 코드의 쉐이더 조각을 일일이 뒤져 보니 Three.js는 diffuseColor라는 변수로 재질(material)의 색상값을 제어합니다. 이 변수는 <color_fragment>라는 쉐이더 조각에서 선언하니 변수 선언 후에 색상값을 수정하면 되겠네요.

-

저 때 diffuseColor는 아까 만들었던 윤곽선 텍스처에서 색상을 가져온 상태일 테니, 이 색상값으로 팔레트 텍스처에서 새로운 색상값을 가져 올 수 있을 겁니다.

-

이전에 했던 것처럼 바꿀 문자열 정보를 배열로 만들어 Material.onBeforeCompile에서 쉐이더를 수정하겠습니다.

-
{
-  const loader = new THREE.TextureLoader();
-  const geometry = new THREE.SphereGeometry(1, 64, 32);
-
-  const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
-  indexTexture.minFilter = THREE.NearestFilter;
-  indexTexture.magFilter = THREE.NearestFilter;
-
-  const pickingMaterial = new THREE.MeshBasicMaterial({ map: indexTexture });
-  pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
-
-+  const fragmentShaderReplacements = [
-+    {
-+      from: '#include <common>',
-+      to: `
-+        #include <common>
-+        uniform sampler2D indexTexture;
-+        uniform sampler2D paletteTexture;
-+        uniform float paletteTextureWidth;
-+      `,
-+    },
-+    {
-+      from: '#include <color_fragment>',
-+      to: `
-+        #include <color_fragment>
-+        {
-+          vec4 indexColor = texture2D(indexTexture, vUv);
-+          float index = indexColor.r * 255.0 + indexColor.g * 255.0 * 256.0;
-+          vec2 paletteUV = vec2((index + 0.5) / paletteTextureWidth, 0.5);
-+          vec4 paletteColor = texture2D(paletteTexture, paletteUV);
-+          // diffuseColor.rgb += paletteColor.rgb;   // 하얀 윤곽선
-+          diffuseColor.rgb = paletteColor.rgb - diffuseColor.rgb;  // 검은 윤곽선
-+        }
-+      `,
-+    },
-+  ];
-
-  const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
-  const material = new THREE.MeshBasicMaterial({ map: texture });
-+  material.onBeforeCompile = function(shader) {
-+    fragmentShaderReplacements.forEach((rep) => {
-+      shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
-+    });
-+  };
-  scene.add(new THREE.Mesh(geometry, material));
-}
-
-

위 코드에서는 indexTexture, paletteTexture, paletteTextureWidth, 총 3개의 균등 변수(uniform)를 사용했습니다. indexTexture는 색상값을 불러와 인덱스로 변환하기 위한 것으로, 이때 사용한 vUv는 Three.js가 넘겨주는 텍스처 좌표이죠. 그리고 이 인덱스 값으로 컬러 팔레트에서 새로운 색상값을 가져 와 diffuseColor와 섞었습니다. 이때 diffuseColor는 검은바탕에 하얀색 윤곽선 텍스처이니 두 색을 더해도 하얀 윤곽선이 나올 겁니다. 대신 새로운 색에서 diffuseColor를 뺀다면 검은 윤곽선이 나오겠죠.

-

다음으로 렌더링 전에 팔레트 텍스처와 3개의 균등 변수를 지정해야 합니다.

-

팔레트 텍스처에는 나라당 하나의 색상과 바다의 색상(id = 0) 하나만 필요합니다. 전 세계적으로 약 240여 개의 나라가 있죠. 나라 배열을 불러올 때까지 기다렸다가 정확한 개수를 받아올 수도 있을 겁니다. 하지만 당장 숫자가 크다고 문제가 될 것 같지는 않으니 512 정도의 큰 숫자를 고르기로 합시다.

-

아래는 팔레트 텍스처를 만드는 코드입니다.

-
const maxNumCountries = 512;
-const paletteTextureWidth = maxNumCountries;
-const paletteTextureHeight = 1;
-const palette = new Uint8Array(paletteTextureWidth * 4);
-const paletteTexture = new THREE.DataTexture(
-    palette, paletteTextureWidth, paletteTextureHeight);
-paletteTexture.minFilter = THREE.NearestFilter;
-paletteTexture.magFilter = THREE.NearestFilter;
-
-

DataTexture를 쓰면 텍스처를 로우-데이터(raw data) 형식으로 넘길 수 있습니다. 예제의 경우에는 512 RGBA 색상을 넘겨주면 되겠죠. 각 값은 3바이트로, 이 바이트는 각각 red, green, blue을 0부터 255까지의 숫자로 나타냅니다.

-

일단은 무작위로 색을 지정해 잘 작동하는지 테스트해봅시다.

-
for (let i = 1; i < palette.length; ++i) {
-  palette[i] = Math.random() * 256;
-}
-// 바다의 색을 지정합니다. (index #0)
-palette.set([100, 200, 255, 255], 0);
-paletteTexture.needsUpdate = true;
-
-

palette 배열로 팔레트 텍스처를 업데이트할 때마다 장면을 업데이트해야 하니 paletteTexture.needsUpdatetrue로 설정합니다.

-

다음으로 재질에 균등 변수를 설정해줍니다.

-
const geometry = new THREE.SphereGeometry(1, 64, 32);
-const material = new THREE.MeshBasicMaterial({ map: texture });
-material.onBeforeCompile = function(shader) {
-  fragmentShaderReplacements.forEach((rep) => {
-    shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
-  });
-+  shader.uniforms.paletteTexture = { value: paletteTexture };
-+  shader.uniforms.indexTexture = { value: indexTexture };
-+  shader.uniforms.paletteTextureWidth = { value: paletteTextureWidth };
-};
-scene.add(new THREE.Mesh(geometry, material));
-
-

이제 예제를 실행하면 각 나라의 색상이 무작위로 지정된 것이 보일 겁니다.

-

- -

-

인덱싱과 팔레트 텍스처가 잘 작동하는 것을 확인했으니, 이제 팔레트를 조작해 선택한 나라의 색상만 바꾸도록 해봅시다.

-

먼저 함수를 하나 만듭니다. 이 함수는 Three.js의 Color를 매개변수로 받아 팔레트 텍스처에 지정할 수 있는 값을 반환할 겁니다.

-
const tempColor = new THREE.Color();
-function get255BasedColor(color) {
-  tempColor.set(color);
-  const base = tempColor.toArray().map(v => v * 255);
-  base.push(255); // alpha
-  return base;
-}
-
-

위 함수를 color = get255BasedColor('red')와 같은 식으로 호출하면 [255, 0, 0] 이런 식의 배열을 반환합니다.

-

다음으로 위 함수를 이용해 몇 가지 색을 만들어 팔레트를 채웁니다.

-
const selectedColor = get255BasedColor('red');
-const unselectedColor = get255BasedColor('#444');
-const oceanColor = get255BasedColor('rgb(100,200,255)');
-resetPalette();
-
-function setPaletteColor(index, color) {
-  palette.set(color, index * 4);
-}
-
-function resetPalette() {
-  // 모든 팔레트의 색상을 unselectedColor로 바꿉니다.
-  for (let i = 1; i < maxNumCountries; ++i) {
-    setPaletteColor(i, unselectedColor);
-  }
-
-  // 바다의 색을 지정합니다. (index #0)
-  setPaletteColor(0, oceanColor);
-  paletteTexture.needsUpdate = true;
-}
-
-

이제 resetPalette 함수를 이용해 나라를 선택했을 때 팔레트를 업데이트합니다.

-
function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function pickCountry(event) {
-  // 아직 데이터를 불러오지 않았을 경우
-  if (!countryInfos) {
-    return;
-  }
-
-  const position = getCanvasRelativePosition(event);
-  const id = pickHelper.pick(position, pickingScene, camera);
-  if (id > 0) {
-    const countryInfo = countryInfos[id - 1];
-    const selected = !countryInfo.selected;
-    if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
-      unselectAllCountries();
-    }
-    numCountriesSelected += selected ? 1 : -1;
-    countryInfo.selected = selected;
-+    setPaletteColor(id, selected ? selectedColor : unselectedColor);
-+    paletteTexture.needsUpdate = true;
-  } else if (numCountriesSelected) {
-    unselectAllCountries();
-  }
-  requestRenderIfNotRequested();
-}
-
-function unselectAllCountries() {
-  numCountriesSelected = 0;
-  countryInfos.forEach((countryInfo) => {
-    countryInfo.selected = false;
-  });
-+  resetPalette();
-}
-
-

이제 선택한 나라가 강조되어 보일 겁니다.

-

- -

-

잘 작동하는 것 같네요!

-

다만 지구본을 돌릴 때도 나라가 선택된다는 게 거슬립니다. 또 나라를 선택하고 지구본을 돌리면 해당 선택이 풀려버리네요.

-

마지막으로 이것까지 고쳐봅시다. 2가지 정도를 확인하면 충분할 것 같네요. 하나는 포인터를 누른 후 떼기까지 얼마나 시간이 흘렀는지를 확이하는 것이고, 다른 하나는 포인터가 움직였는지 확인하는 겁니다. 마우스를 떼는 데 시간이 얼마 안 걸렸고 포인터가 움직이지 않았다면 클릭으로 간주하는 것이죠.

-
+const maxClickTimeMs = 200;
-+const maxMoveDeltaSq = 5 * 5;
-+const startPosition = {};
-+let startTimeMs;
-+
-+function recordStartTimeAndPosition(event) {
-+  startTimeMs = performance.now();
-+  const pos = getCanvasRelativePosition(event);
-+  startPosition.x = pos.x;
-+  startPosition.y = pos.y;
-+}
-
-function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function pickCountry(event) {
-  // 아직 데이터를 불러오지 않았을 경우
-  if (!countryInfos) {
-    return;
-  }
-
-+  // 포인터를 누른 후 떼기까지 일정 시간 이상 걸렸다면
-+  // 선택 액션이 아닌 드래그 액션으로 간주합니다.
-+  const clickTimeMs = performance.now() - startTimeMs;
-+  if (clickTimeMs > maxClickTimeMs) {
-+    return;
-+  }
-+
-+  // 포인터가 움직였다면 드래그로 간주합니다.
-+  const position = getCanvasRelativePosition(event);
-+  const moveDeltaSq = (startPosition.x - position.x) ** 2 +
-+                      (startPosition.y - position.y) ** 2;
-+  if (moveDeltaSq > maxMoveDeltaSq) {
-+    return;
-+  }
-
--  const position = { x: event.clientX, y: event.clientY };
-  const id = pickHelper.pick(position, pickingScene, camera);
-  if (id > 0) {
-    const countryInfo = countryInfos[id - 1];
-    const selected = !countryInfo.selected;
-    if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
-      unselectAllCountries();
-    }
-    numCountriesSelected += selected ? 1 : -1;
-    countryInfo.selected = selected;
-    setPaletteColor(id, selected ? selectedColor : unselectedColor);
-    paletteTexture.needsUpdate = true;
-  } else if (numCountriesSelected) {
-    unselectAllCountries();
-  }
-  requestRenderIfNotRequested();
-}
-
-function unselectAllCountries() {
-  numCountriesSelected = 0;
-  countryInfos.forEach((countryInfo) => {
-    countryInfo.selected = false;
-  });
-  resetPalette();
-}
-
-+canvas.addEventListener('pointerdown', recordStartTimeAndPosition);
-canvas.addEventListener('pointerup', pickCountry);
-
-

제 기준에서는 이 정도면 충분한 듯하네요.

-

- -

-

저는 UX 전문가가 아니니 더 나은 방법이 있을 경우 알려주시면 감사하겠습니다.

-

이 글이 인덱스(indexed) 그래픽을 활용하고, Three.js의 쉐이더를 수정해 간단한 효과를 구현하는 데 도움이 되었다면 좋겠네요. 쉐이더를 작성할 때 쓴 GLSL에 대해 다루기에는 너무 내용이 방대하니 후처리에 관한 글에 있는 링크를 참고하기 바랍니다.

- -
-
-
- - - - - - - - diff --git a/manual/ko/lang.css b/manual/ko/lang.css deleted file mode 100644 index 89472eebf2ff4a..00000000000000 --- a/manual/ko/lang.css +++ /dev/null @@ -1,19 +0,0 @@ -@font-face { - font-family: 'PureunJeonnam'; - font-style: normal; - font-weight: 700; - src: url(resources/fonts/PureunJeonnam-Bold.woff) format('woff'), - url(resources/fonts/PureunJeonnam-Bold.ttf) format('truetype'); -} -@font-face { - font-family: 'PureunJeonnam'; - font-style: normal; - font-weight: 400; - src: url(resources/fonts/PureunJeonnam.woff) format('woff'), - url(resources/fonts/PureunJeonnam.ttf) format('truetype'); -} - -:root { - --article-font-family: 'PureunJeonnam', sans-serif; - --headline-font-family: 'PureunJeonnam', sans-serif; -} \ No newline at end of file diff --git a/manual/ko/lights.html b/manual/ko/lights.html deleted file mode 100644 index bb905e6033759a..00000000000000 --- a/manual/ko/lights.html +++ /dev/null @@ -1,461 +0,0 @@ - - - 조명(Lights) - - - - - - - - - - - - - - -
-
-

조명(Lights)

-
-
-
-

※ 이 글은 Three.js의 튜토리얼 시리즈로서, -먼저 Three.js의 기본 구조에 관한 글과 -개발 환경 설정하는 법을 읽고 오길 권장합니다.

-

이전 글은 텍스처에 관한 글이었죠. 이번에는 -Three.js의 다양한 조명을 어떻게 쓰는지 알아보겠습니다.

-

먼저 이전 예제에서 카메라를 수정하겠습니다. 시야각(fov, field of view)은 -45도, far면은 100칸, 카메라의 위치는 중점에서 위로 10칸, 뒤로 20칸 옮깁니다.

-
*const fov = 45;
-const aspect = 2;  // canvas 요소의 기본 비율
-const near = 0.1;
-*const far = 100;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-+camera.position.set(0, 10, 20);
-
-

다음으로 OrbitControls를 추가합니다. OrbitControls는 특정 좌표를 -중심으로 카메라를 자전 또는 공전(orbit)하도록 해줍니다. OrbitControls는 -별도 모듈이므로, 먼저 페이지에 로드해야 합니다.

-
import * as THREE from 'three';
-+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-
-

이제 OrbitControls에 카메라와, DOM 이벤트를 감지할 수 있도록 -canvas 요소를 넘겨줍니다.

-
const controls = new OrbitControls(camera, canvas);
-controls.target.set(0, 5, 0);
-controls.update();
-
-

또한 시점을 중점에서 위로 5칸 올린 후 controls.update 메서드를 -호출해 OrbitControls가 새로운 시점을 바라보게 합니다.

-

다음으로 빛을 받을 무언가를 만들어보겠습니다. 먼저 땅의 역할을 할 -평면을 만들고, 평면에 2x2 픽셀의 체크판 텍스처를 씌우겠습니다.

-
- -
- -

일단 텍스처를 불러온 뒤, 반복하도록 래핑(wrapping)을 설정해줍니다. 필터는 -NearestFilter, 텍스처가 2x2 픽셀의 체크판이니 repeat 속성을 평면의 -반으로 설정하면 체크판의 각 칸은 정확히 (장면의) 1칸이 될 겁니다.

-
const planeSize = 40;
-
-const loader = new THREE.TextureLoader();
-const texture = loader.load('resources/images/checker.png');
-texture.wrapS = THREE.RepeatWrapping;
-texture.wrapT = THREE.RepeatWrapping;
-texture.magFilter = THREE.NearestFilter;
-texture.colorSpace = THREE.SRGBColorSpace;
-const repeats = planeSize / 2;
-texture.repeat.set(repeats, repeats);
-
-

그리고 평면 geometry, 평면에 쓸 재질(material), 장면(scene)에 추가할 -mesh를 만듭니다. 평면은 기본적으로 XY축을 기준으로 하니, XZ축을 기준으로 -하려면 평면을 회전시켜야 합니다.

-
const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
-const planeMat = new THREE.MeshPhongMaterial({
-  map: texture,
-  side: THREE.DoubleSide,
-});
-const mesh = new THREE.Mesh(planeGeo, planeMat);
-mesh.rotation.x = Math.PI * -.5;
-scene.add(mesh);
-
-

정육면체와 구체도 추가해서 평면까지 총 3개의 물체를 추가하도록 하죠.

-
{
-  const cubeSize = 4;
-  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
-  const cubeMat = new THREE.MeshPhongMaterial({color: '#8AC'});
-  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
-  mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
-  scene.add(mesh);
-}
-{
-  const sphereRadius = 3;
-  const sphereWidthDivisions = 32;
-  const sphereHeightDivisions = 16;
-  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
-  const sphereMat = new THREE.MeshPhongMaterial({color: '#CA8'});
-  const mesh = new THREE.Mesh(sphereGeo, sphereMat);
-  mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
-  scene.add(mesh);
-}
-
-

빛을 받을 물체를 만들었으니 이제 조명을 가지고 놀아봅시다!

-

AmbientLight

-

먼저 AmbientLight(자연광)를 써보겠습니다.

-
const color = 0xFFFFFF;
-const intensity = 1;
-const light = new THREE.AmbientLight(color, intensity);
-scene.add(light);
-
-

이 조명도 lil-gui를 사용해 -속성을 조정할 수 있도록 만들겠습니다. lil-gui로 색상을 조정하려면 간단한 -헬퍼 클래스가 필요합니다. 이 클래스는 색상을 CSS hex(예: #FF8844) 값으로 -변경해 lil-gui에 넘겨주는 역할을 할 거예요. 그리고 lil-gui가 클래스의 -속성을 지정할 때, 이를 조명에 직접 지정하도록 합니다.

-
class ColorGUIHelper {
-  constructor(object, prop) {
-    this.object = object;
-    this.prop = prop;
-  }
-  get value() {
-    return '#' + this.object[this.prop].getHexString();
-  }
-  set value(hexString) {
-    this.object[this.prop].set(hexString);
-  }
-}
-
-

아래는 lil-gui를 만드는 코드입니다.

-
const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-gui.add(light, 'intensity', 0, 5, 0.01);
-
-

결과물은 다음과 같죠.

-

- -

-

카메라를 공전시키기(orbit) 위해 화면을 드래그해보세요.

-

물체들이 평평하고, 윤곽이 뚜렷하지 않습니다. AmbientLight는 물체와 -조명의 색, 그리고 조명의 밝기를 곱한 것과 같죠.

-
color = materialColor * light.color * light.intensity;
-

이게 전부입니다. AmbientLight에는 방향이라는 개념이 없죠. 주변광은 -완전히 고르게 적용되고 공간 안 물체의 색을 바꾸는 역할만 하기 때문에 -실용적이지 않은데다 그다지 조명처럼 느껴지지도 않습니다. 어두운 장면을 -덜 어둡게 만드는 정도에만 도움이 되죠.

-

HemisphereLight

-

조명을 HemisphereLight(반구광)으로 바꾸겠습니다. HemisphereLight는 -천장과 바닥의 색을 인자로 받아, 물체의 천장을 바라보는 면은 천장 색, 바닥을 -바라보는 면은 바닥 색으로 혼합합니다.

-
-const color = 0xFFFFFF;
-+const skyColor = 0xB1E1FF;  // 하늘색
-+const groundColor = 0xB97A20;  // 오렌지 브라운
-const intensity = 1;
--const light = new THREE.AmbientLight(color, intensity);
-+const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
-scene.add(light);
-
-

마찬가지로 lil-gui를 수정해 두 색상을 조정할 수 있도록 합니다.

-
const gui = new GUI();
--gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-+gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('skyColor');
-+gui.addColor(new ColorGUIHelper(light, 'groundColor'), 'value').name('groundColor');
-gui.add(light, 'intensity', 0, 5, 0.01);
-
-

- -

-

이 또한 그다지 입체적이지 않습니다. 아까보다는 낮지만 전체적으로 2D처럼 -보이네요. HemisphereLight는 주로 풍경을 표현하거나 할 때 다른 조명과 -함께 사용합니다. 다른 조명과 조합할 때 유용하고, 간단히는 AmbientLight -대신 사용할 수 있죠.

-

DirectionalLight

-

이번에는 조명을 DirectionalLight(직사광)로 바꿔보죠. DirectionalLight는 -주로 태양을 표현할 때 사용합니다.

-
const color = 0xFFFFFF;
-const intensity = 1;
-const light = new THREE.DirectionalLight(color, intensity);
-light.position.set(0, 10, 0);
-light.target.position.set(-5, 0, 0);
-scene.add(light);
-scene.add(light.target);
-
-

먼저 lightlight.target(목표)을 모두 장면에 추가해야 합니다. -그래야 Three.js의 DirectionalLight가 목표가 있는 방향으로 빛을 -쬘 테니까요.

-

이 역시 GUI를 사용해 목표의 위치를 조정할 수 있도록 만들겠습니다.

-
const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-gui.add(light, 'intensity', 0, 5, 0.01);
-gui.add(light.target.position, 'x', -10, 10);
-gui.add(light.target.position, 'z', -10, 10);
-gui.add(light.target.position, 'y', 0, 10);
-
-

- -

-

조명의 위치가 보이지 않으니 정확한 동작을 확인하기가 좀 어렵네요. -다행히 Three.js에는 눈에 보이지 않는 요소의 시각화를 도와주는 -다양한 헬퍼 객체가 있습니다. 이 경우 DirectionalLightHelper를 -사용해 조명을 면으로, 조명의 방향을 선으로 나타낼 수 있습니다. -사용법도 간단해서 조명을 인자로 넘겨주고 생성한 인스턴스를 장면에 -추가하면 됩니다.

-
const helper = new THREE.DirectionalLightHelper(light);
-scene.add(helper);
-
-

하는 김에 조명과 목표 둘 다 위치를 조정할 수 있도록 하겠습니다. -Vector3 객체를 인자로 받아, lil-gui로 이 객체의 x, y, -z 속성을 조정하는 함수를 하나 만듭니다.

-
function makeXYZGUI(gui, vector3, name, onChangeFn) {
-  const folder = gui.addFolder(name);
-  folder.add(vector3, 'x', -10, 10).onChange(onChangeFn);
-  folder.add(vector3, 'y', 0, 10).onChange(onChangeFn);
-  folder.add(vector3, 'z', -10, 10).onChange(onChangeFn);
-  folder.open();
-}
-
-

헬퍼 객체를 사용할 때는 헬퍼 객체의 update 메서드를 수동으로 -호출해줘야 합니다. 한 예로 lil-gui가 객체 속성을 변경할 때마다 -인자로 넘겨준 onChangeFn에서 헬퍼 객체의 update 메서드를 -호출할 수 있죠.

-

그리고 조명의 위치, 목표의 위치 객체에 방금 만든 함수를 각각 적용합니다.

-
+function updateLight() {
-+  light.target.updateMatrixWorld();
-+  helper.update();
-+}
-+updateLight();
-
-const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-gui.add(light, 'intensity', 0, 5, 0.01);
-
-+makeXYZGUI(gui, light.position, 'position', updateLight);
-+makeXYZGUI(gui, light.target.position, 'target', updateLight);
-
-

이제 조명, 목표의 위치를 각각 조정할 수 있습니다.

-

- -

-

카메라를 돌려보면 아까보다 훨씬 동작이 명확하게 보일 겁니다. -평면은 DirectionalLight를 나타내는데, 이는 직사광이 어느 -한 점에서 뻗어나오는 조명이 아니기 때문입니다. 무한한 광원이 -목표를 향해 평행하게 빛을 내리쬐는 것이죠.

-

PointLight

-

PointLight는 한 점에서 무한히 뻗어나가는 광원입니다. 코드를 -다시 한 번 수정해보죠.

-
const color = 0xFFFFFF;
--const intensity = 1;
-+const intensity = 150;
--const light = new THREE.DirectionalLight(color, intensity);
-+const light = new THREE.PointLight(color, intensity);
-light.position.set(0, 10, 0);
--light.target.position.set(-5, 0, 0);
-scene.add(light);
--scene.add(light.target);
-
-

헬퍼 객체도 PointLightHelper로 바꾸겠습니다.

-
-const helper = new THREE.DirectionalLightHelper(light);
-+const helper = new THREE.PointLightHelper(light);
-scene.add(helper);
-
-

PointLight에는 목표가 없으므로 onChange 함수도 훨씬 간단하게 -짤 수 있습니다.

-
function updateLight() {
--  light.target.updateMatrixWorld();
-  helper.update();
-}
--updateLight();
-
-

PointLightHelper는 점의 표상을 그립니다. 점의 표상이란 점으로는 확인이 어려우니, -기본값으로 다이아몬드 형태의 와이어프레임(wireframe)을 대신 그려놓은 것이죠. 점의 -형태는 조명에 mesh 객체를 하나 넘겨 얼마든지 바꿀 수 있습니다.

-

PointLight에는 추가로 distance 속성이 있습니다. -distance가이 0이면 PointLight의 밝기가 무한대임을 의미하고, 0보다 크면 -distance에 지정된 거리만큼만 영향을 미칩니다.

-

거리도 조정할 수 있도록 GUI에 추가하겠습니다.

-
const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-gui.add(light, 'intensity', 0, 250, 1);
-+gui.add(light, 'distance', 0, 40).onChange(updateLight);
-
-makeXYZGUI(gui, light.position, 'position', updateLight);
--makeXYZGUI(gui, light.target.position, 'target', updateLight);
-
-

이제 한 번 테스트해보죠.

-

- -

-

distance가 0보다 클 때 조명의 밝기를 잘 관찰해보세요.

-

SpotLight

-

스포트라이트는 비유하자면 원뿔 안의 PointLight입니다. -차이점은 원뿔 안에서만 빛난다는 점이죠. SpotLight의 -원뿔은 종류는 외부 원뿔과 내부 원뿔 두 가지입니다. -빛의 밝기는 내부 원뿔에서 가장 세고, 외부 원뿔에 가까워질수록 -0까지 낮아집니다.

-

DirectionalLight와 마찬가지로 SpotLight도 목표의 위치를 -정해줘야 합니다. 원뿔의 밑면이 해당 목표물을 바라보게 되죠.

-

위 예제의 DirectionalLight와 헬퍼 객체를 수정하겠습니다.

-
const color = 0xFFFFFF;
--const intensity = 1;
-+const intensity = 150;
--const light = new THREE.DirectionalLight(color, intensity);
-+const light = new THREE.SpotLight(color, intensity);
-scene.add(light);
-scene.add(light.target);
-
--const helper = new THREE.DirectionalLightHelper(light);
-+const helper = new THREE.SpotLightHelper(light);
-scene.add(helper);
-
-

원뿔의 내각은 angle에 호도(radians)값을 지정해 -설정합니다. 텍스처 예제에서 사용했던 DegRadHelper -객체를 사용해 UI에는 도(degrees)로 표시하도록 하겠습니다.

-
gui.add(new DegRadHelper(light, 'angle'), 'value', 0, 90).name('angle').onChange(updateLight);
-
-

내부 원뿔의 크기는 penumbra(반음영) 속성을 외부 -원뿔에 대한 비율(퍼센트)로 지정해 사용합니다. 다시 말해 penumbra 속성이 -0이면 외부 원뿔과 크기가 동일하다는 것이고, 1이면 빛이 중앙에서부터 외부 -원뿔까지 점점 희미해짐을 의미하죠. penumbra 속성이 0.5이라면? 중앙과 외부 -원뿔의 사이 50% 지점부터 빛이 희미해짐을 의미합니다.

-
gui.add(light, 'penumbra', 0, 1, 0.01);
-
-

- -

-

penumbra 속성이 0일 때는 빛의 경계가 굉장히 분명한 것이 보일 겁니다. -penumbra 속성을 1에 가깝게 조정하면 경계가 점점 흐릿해지죠.

-

SpotLight원뿔 모양처럼 보이지 않을지도 모릅니다. 이는 바닥이 원뿔의 -거리보다 가까이 있기 때문으로, distance를 약 5 정도로 조정하면 원뿔의 밑면을 -확인할 수 있을 겁니다.

-

RectAreaLight

-

마지막으로 살펴볼 조명은 RectAreaLight입니다. 이름 그대로 사각 형태의 -조명으로, 형광등이나 천장의 유리를 통과하는 태양빛을 표현하기에 적합합니다.

-

RectAreaLightMeshStandardMaterialMeshPhysicalMaterial만 -지원합니다. 예전 코드에서 재질(material)을 MeshStandardMaterial로 바꾸겠습니다.

-
  ...
-
-  const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
--  const planeMat = new THREE.MeshPhongMaterial({
-+  const planeMat = new THREE.MeshStandardMaterial({
-    map: texture,
-    side: THREE.DoubleSide,
-  });
-  const mesh = new THREE.Mesh(planeGeo, planeMat);
-  mesh.rotation.x = Math.PI * -.5;
-  scene.add(mesh);
-}
-{
-  const cubeSize = 4;
-  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
-- const cubeMat = new THREE.MeshPhongMaterial({color: '#8AC'});
-+ const cubeMat = new THREE.MeshStandardMaterial({color: '#8AC'});
-  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
-  mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
-  scene.add(mesh);
-}
-{
-  const sphereRadius = 3;
-  const sphereWidthDivisions = 32;
-  const sphereHeightDivisions = 16;
-  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
--  const sphereMat = new THREE.MeshPhongMaterial({color: '#CA8'});
-+ const sphereMat = new THREE.MeshStandardMaterial({color: '#CA8'});
-  const mesh = new THREE.Mesh(sphereGeo, sphereMat);
-  mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
-  scene.add(mesh);
-}
-
-

RectAreaLight를 사용하려면 별도의 데이터를 불러와야 합니다. 또한 -RectAreaLightHelper도 같이 불러와 조명을 시각화하겠습니다.

-
import * as THREE from 'three';
-+import { RectAreaLightUniformsLib } from 'three/addons/lights/RectAreaLightUniformsLib.js';
-+import { RectAreaLightHelper } from 'three/addons/helpers/RectAreaLightHelper.js';
-
-

모듈을 불러온 후 RectAreaLightUniformsLib.init 메서드를 호출합니다.

-
function main() {
-  const canvas = document.querySelector('#c');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+  RectAreaLightUniformsLib.init();
-
-

데이터를 불러오지 않아도 에러는 발생하지 않지만, 이상하게 보일 것이므로 -데이터를 불러와야 한다는 것을 꼭 기억하기 바랍니다.

-

이제 조명을 추가합니다.

-
const color = 0xFFFFFF;
-*const intensity = 5;
-+const width = 12;
-+const height = 4;
-*const light = new THREE.RectAreaLight(color, intensity, width, height);
-light.position.set(0, 10, 0);
-+light.rotation.x = THREE.MathUtils.degToRad(-90);
-scene.add(light);
-
-*const helper = new RectAreaLightHelper(light);
-*light.add(helper);
-
-

RectAreaLightDirectionalLight, SpotLight와 달리 목표를 사용하지 않습니다. -빛의 방향은 rotation으로 설정할 수 있죠. 또 RectAreaLightHelper는 직접 조명을 -자식으로 두는 다른 헬퍼 객체와 달리, 해당 조명의 자식이어야 합니다.

-

조명의 rotation, width, height 속성을 조정할 수 있도록 GUI도 수정해줍니다.

-
const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-gui.add(light, 'intensity', 0, 10, 0.01);
-gui.add(light, 'width', 0, 20);
-gui.add(light, 'height', 0, 20);
-gui.add(new DegRadHelper(light.rotation, 'x'), 'value', -180, 180).name('x rotation');
-gui.add(new DegRadHelper(light.rotation, 'y'), 'value', -180, 180).name('y rotation');
-gui.add(new DegRadHelper(light.rotation, 'z'), 'value', -180, 180).name('z rotation');
-
-makeXYZGUI(gui, light.position, 'position');
-
-

- -

- -

조명은 renderer가 장면을 렌더링하는 속도에 영향을 미칩니다. 그러니 -가능한 적은 조명을 쓰는 게 좋죠.

-

다음 장에서는 카메라 조작법에 대해 알아보겠습니다.

-

- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/load-gltf.html b/manual/ko/load-gltf.html deleted file mode 100644 index 6516509639b0b1..00000000000000 --- a/manual/ko/load-gltf.html +++ /dev/null @@ -1,673 +0,0 @@ - - - 에서 .GLTF 파일 불러오기 - - - - - - - - - - - - - - -
-
-

에서 .GLTF 파일 불러오기

-
-
-
-

이전 글에서는 .OBJ 파일을 불러오는 법에 대해 -배웠습니다. 이전 글을 읽지 않았다면 먼저 읽고 오길 권장합니다.

-

이전 글에서 말했듯 .OBJ 파일은 굉장히 오래된 파일 형식이고 구성이 간단합니다. -애초에 3D 에디터끼리 간단히 데이터를 주고 받을 것을 목적으로 만들었기에 씬 -그래프라는 개념이 없죠. 하나의 거대한 mesh입니다.

-

gLTF 형식은 애초부터 그래픽 요소를 -표현하기 위해 설계된 파일 형식입니다. 3D 파일 형식은 크게 3, 4개 형식으로 나눌 -수 있죠.

-
    -
  • 3D 에디터 형식

    -

    특정 프로그램을 위한 파일 형식입니다. .blend(블렌더), .max(3D Studio Max), -.mb, .ma(마야) 등이 있죠.

    -
  • -
  • 교환 형식

    -

    여기에 .OBJ, .DAE(Collada), .FBX 등이 여기에 속합니다. 3D 에디터끼리 데이터를 -교환하기 위해 고안된 형식으로, 보통 3D 에디터 내부에서 사용하는 것보다 더 많은 -데이터를 포함합니다.

    -
  • -
  • 앱 형식

    -

    특정 앱이나 게임 등에서 사용하는 파일 형식입니다.

    -
  • -
  • 전달(transmission) 형식

    -

    glTF가 첫 전달 형식 파일입니다. 굳이 따지자면 VRML이 처음이라고 할 수도 있으나, -VRML은 부족한 점이 많습니다.

    -

    glTF는 기존 파일 형식에서 부진한 점을 보완한 형식으로, 크게 다음 면에서 기존 형식보다 -뛰어납니다.

    -
      -
    1. 전달 시 파일 용량 최적화

      -

      정점 등의 큰 데이터를 이진수(binary) 형태로 저장하는 것을 의미합니다. glTF 파일을 -사용하면 별도의 가공 과정 없이 데이터를 GPU에 바로 로드할 수 있죠. 반면 VRML, .OBJ, -.DAE 등의 형식은 이런 데이터를 텍스트로 저장하여 파싱 과정이 필요합니다. 텍스트 기반의 -정점 데이터는 이진수 데이터보다 3배에서 많게는 5배까지 큽니다.

      -
    2. -
    3. 렌더링 최적화

      -

      앱 형식을 제외한 다른 파일 형식과 다른 점입니다. glTF 형식의 데이터는 수정이 아니라, -렌더링에 최적화되어 있습니다. 일반적으로 렌더링에 필요없는 데이터를 제거하는데, 예를 -들어 다각형을 glTF 형식으로 저장하면 전부 삼각형으로 변환됩니다. 적용할 재질 데이터도 -전부 지정되어 있죠.

      -
    4. -
    -
  • -
-

glTF는 특정 목적으로 고안되었기에 대부분의 경우 glTF 파일을 다운받아 사용하는 것은 -큰 문제가 없습니다. 다른 형식을 사용할 때는 대부분 조금씩 문제가 있었는데, 이번에는 -아니길 빌어봐야겠네요.

-

사실 예제 하나로는 glTF를 전부 소개하기 어렵습니다. 단순한 glTF 파일은 .OBJ 파일보다 -사용하기가 쉬운 경우도 많고, .OBJ 파일과 달리 재질(materials)을 파일 안에 포함하거든요. -그러니 파일을 하나 골라 불러오고, 이 과정에서 발생하는 문제를 해결하는 것이 더 도움이 -될 듯합니다.

-

인터넷 검색으로 로우-폴리(low-poly) 마을을 -하나 찾았습니다(작가: antonmoek). 뭔가 괜찮은 -예제가 나올 것 같은 예감이 드네요.

-
- -

.OBJ에 관한 글에서 썼던 예제를 가져와 .OBJ 파일을 불러오는 -코드를 .GLTF를 불러오는 코드로 바꾸겠습니다.

-

아래의 기존 코드를

-
const mtlLoader = new MTLLoader();
-mtlLoader.loadMtl('resources/models/windmill/windmill-fixed.mtl', (mtl) => {
-  mtl.preload();
-  mtl.materials.Material.side = THREE.DoubleSide;
-  objLoader.setMaterials(mtl);
-  objLoader.load('resources/models/windmill/windmill.obj', (event) => {
-    const root = event.detail.loaderRootNode;
-    scene.add(root);
-    ...
-  });
-});
-
-

.GLTF를 불러오는 코드로 바꿉니다.

-
{
-  const gltfLoader = new GLTFLoader();
-  const url = 'resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf';
-  gltfLoader.load(url, (gltf) => {
-    const root = gltf.scene;
-    scene.add(root);
-    ...
-  });
-
-

자동으로 카메라의 시야를 조정하는 코드는 그대로 두었습니다.

-

모듈이 바뀌었으니 import 문도 변경해야 합니다. OBJLoader를 제거하고 GLTFLoader를 -추가합니다.

-
-import { LoaderSupport } from 'three/addons/loaders/LoaderSupport.js';
--import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
--import { MTLLoader } from 'three/addons/loaders/MTLLoader.js';
-+import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
-
-

이제 실행해보죠.

-

- -

-

이런 걸 마법이라고 하나봅니다. 텍스처를 비롯해 모든 게 한 번에 완성됐네요.

-

여기에 자동차가 도로를 따라 달리도록 할 수 있다면 더 멋있겠습니다. 먼저 장면(scene)에서 -차가 별도의 요소인지 확인하고, 별도의 요소라면 이 요소를 다룰 수 있는 방법을 찾아야 합니다.

-

먼저 간단하게 함수를 만들어 씬 그래프를 자바스크립트 콘솔에 -띄워보겠습니다.

-
function dumpObject(obj, lines = [], isLast = true, prefix = '') {
-  const localPrefix = isLast ? '└─' : '├─';
-  lines.push(`${prefix}${prefix ? localPrefix : ''}${obj.name || '*no-name*'} [${obj.type}]`);
-  const newPrefix = prefix + (isLast ? '  ' : '│ ');
-  const lastNdx = obj.children.length - 1;
-  obj.children.forEach((child, ndx) => {
-    const isLast = ndx === lastNdx;
-    dumpObject(child, lines, isLast, newPrefix);
-  });
-  return lines;
-}
-
-

씬을 전부 불러온 뒤, 만든 함수를 호출합니다.

-
const gltfLoader = new GLTFLoader();
-gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
-  const root = gltf.scene;
-  scene.add(root);
-  console.log(dumpObject(root).join('\n'));
-
-

코드를 실행하니 아래와 같은 결과가 -나왔습니다.

-
OSG_Scene [Scene]
-  └─RootNode_(gltf_orientation_matrix) [Object3D]
-    └─RootNode_(model_correction_matrix) [Object3D]
-      └─4d4100bcb1c640e69699a87140df79d7fbx [Object3D]
-        └─RootNode [Object3D]
-          │ ...
-          ├─Cars [Object3D]
-          │ ├─CAR_03_1 [Object3D]
-          │ │ └─CAR_03_1_World_ap_0 [Mesh]
-          │ ├─CAR_03 [Object3D]
-          │ │ └─CAR_03_World_ap_0 [Mesh]
-          │ ├─Car_04 [Object3D]
-          │ │ └─Car_04_World_ap_0 [Mesh]
-          │ ├─CAR_03_2 [Object3D]
-          │ │ └─CAR_03_2_World_ap_0 [Mesh]
-          │ ├─Car_04_1 [Object3D]
-          │ │ └─Car_04_1_World_ap_0 [Mesh]
-          │ ├─Car_04_2 [Object3D]
-          │ │ └─Car_04_2_World_ap_0 [Mesh]
-          │ ├─Car_04_3 [Object3D]
-          │ │ └─Car_04_3_World_ap_0 [Mesh]
-          │ ├─Car_04_4 [Object3D]
-          │ │ └─Car_04_4_World_ap_0 [Mesh]
-          │ ├─Car_08_4 [Object3D]
-          │ │ └─Car_08_4_World_ap8_0 [Mesh]
-          │ ├─Car_08_3 [Object3D]
-          │ │ └─Car_08_3_World_ap9_0 [Mesh]
-          │ ├─Car_04_1_2 [Object3D]
-          │ │ └─Car_04_1_2_World_ap_0 [Mesh]
-          │ ├─Car_08_2 [Object3D]
-          │ │ └─Car_08_2_World_ap11_0 [Mesh]
-          │ ├─CAR_03_1_2 [Object3D]
-          │ │ └─CAR_03_1_2_World_ap_0 [Mesh]
-          │ ├─CAR_03_2_2 [Object3D]
-          │ │ └─CAR_03_2_2_World_ap_0 [Mesh]
-          │ ├─Car_04_2_2 [Object3D]
-          │ │ └─Car_04_2_2_World_ap_0 [Mesh]
-          ...
-
-

살펴보니 모든 자동차는 "Cars"라는 부모의 자식이네요.

-
*          ├─Cars [Object3D]
-          │ ├─CAR_03_1 [Object3D]
-          │ │ └─CAR_03_1_World_ap_0 [Mesh]
-          │ ├─CAR_03 [Object3D]
-          │ │ └─CAR_03_World_ap_0 [Mesh]
-          │ ├─Car_04 [Object3D]
-          │ │ └─Car_04_World_ap_0 [Mesh]
-
-

간단히 테스트를 해봅시다. 먼저 "Cars"의 자식 요소 전부를 Y축을 기준으로 -회전시켜보겠습니다.

-

장면을 불러온 뒤, "Cars" 요소를 참조해 변수로 저장합니다.

-
+let cars;
-{
-  const gltfLoader = new GLTFLoader();
-  gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
-    const root = gltf.scene;
-    scene.add(root);
-+    cars = root.getObjectByName('Cars');
-
-

그리고 render 함수 안에서 cars의 자식 요소를 전부 회전시킵니다.

-
+function render(time) {
-+  time *= 0.001;  // convert to seconds
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-+  if (cars) {
-+    for (const car of cars.children) {
-+      car.rotation.y = time;
-+    }
-+  }
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-

잘 작동하는지 볼까요?

-

- -

-

음... 기준축이 제각각인 것을 보니 이 파일을 만든 디자이너가 애니메이션까지 -고려하지는 않은 듯합니다. 트럭들이 전부 이상한 방향으로 도네요.

-

이처럼 3D 프로젝트를 진행할 때는 목적에 따라 개체를 디자인해야 합니다. 그래야 -기준축이나, 크기 등이 제대로 적용될 테니까요.

-

저는 디자이너도 아니고 블렌더를 그다지 잘 하지도 못하기에, 편법을 사용하겠습니다. -각각의 자동차에 별도의 Object3D를 만들어 자동차를 이 Object3D의 자식으로 -지정할 겁니다. 이러면 자동차가 아닌 Object3D로 차를 움직일 수 있고, 자동차의 -기준축도 별도로 설정할 수 있죠.

-

아까 봤던 씬 그래프를 다시 보니 자동차의 종류는 총 3개인 듯합니다. "Car_08", -"CAR_03", "Car_04" 이렇게요. 종류별로 조정했을 때 제대로 적용되는지 봅시다.

-

아래의 코드는 각 자동차를 새로운 Object3D의 자식으로 지정하고, 이 Object3D를 -장면에 추가한 뒤, 자동차의 종류별로 기준축을 정렬합니다. 그리고 새로 만든 -Object3Dcars 배열에 추가하죠.

-
-let cars;
-+const cars = [];
-{
-  const gltfLoader = new GLTFLoader();
-  gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
-    const root = gltf.scene;
-    scene.add(root);
-
--    cars = root.getObjectByName('Cars');
-+    const loadedCars = root.getObjectByName('Cars');
-+    const fixes = [
-+      { prefix: 'Car_08', rot: [Math.PI * .5, 0, Math.PI * .5], },
-+      { prefix: 'CAR_03', rot: [0, Math.PI, 0], },
-+      { prefix: 'Car_04', rot: [0, Math.PI, 0], },
-+    ];
-+
-+    root.updateMatrixWorld();
-+    for (const car of loadedCars.children.slice()) {
-+      const fix = fixes.find(fix => car.name.startsWith(fix.prefix));
-+      const obj = new THREE.Object3D();
-+      car.getWorldPosition(obj.position);
-+      car.position.set(0, 0, 0);
-+      car.rotation.set(...fix.rot);
-+      obj.add(car);
-+      scene.add(obj);
-+      cars.push(obj);
-+    }
-     ...
-
-

이제 기준축이 제대로 정렬되었습니다.

-

- -

-

이제 자동차를 달리게 만들어봅시다.

-

간단한 이동 시스템이라고 해도 튜토리얼에서 다루기에는 다소 복잡합니다. 대신 -도로 전체를 달리는 뒤얽힌 경로를 만들어 자동차를 해당 경로에 놓을 수는 있죠. -아래 스크린샷은 경로를 반쯤 완성했을 때 블렌더의 화면을 캡쳐한 것입니다.

-
- -

이제 블렌더에서 데이터를 추출해야 합니다. 다행히 경로만을 골라 내보낼 수 있네요. -"write nurbs"를 체크해 경로를 .OBJ 파일로 내보냅니다.

-
- -

.OBJ 파일을 열어보니 각 정점 데이터가 있습니다. 이를 배열로 바꿔 사용하도록 -하죠.

-
const controlPoints = [
-  [1.118281, 5.115846, -3.681386],
-  [3.948875, 5.115846, -3.641834],
-  [3.960072, 5.115846, -0.240352],
-  [3.985447, 5.115846, 4.585005],
-  [-3.793631, 5.115846, 4.585006],
-  [-3.826839, 5.115846, -14.736200],
-  [-14.542292, 5.115846, -14.765865],
-  [-14.520929, 5.115846, -3.627002],
-  [-5.452815, 5.115846, -3.634418],
-  [-5.467251, 5.115846, 4.549161],
-  [-13.266233, 5.115846, 4.567083],
-  [-13.250067, 5.115846, -13.499271],
-  [4.081842, 5.115846, -13.435463],
-  [4.125436, 5.115846, -5.334928],
-  [-14.521364, 5.115846, -5.239871],
-  [-14.510466, 5.115846, 5.486727],
-  [5.745666, 5.115846, 5.510492],
-  [5.787942, 5.115846, -14.728308],
-  [-5.423720, 5.115846, -14.761919],
-  [-5.373599, 5.115846, -3.704133],
-  [1.004861, 5.115846, -3.641834],
-];
-
-

Three.js에는 몇 가지 곡선 클래스가 있습니다. 이 경우 CatmullRomCurve3가 -적당하겠네요. 이런 곡선은 각 정점을 지나는 부드러운 곡선을 만든다는 것이 -특징입니다.

-

만약 위 정점으로 곡선을 생성하면 다음 그림과 같은 곡선이 생길 겁니다.

-
- -

모서리가 각지면 좀 더 깔끔할 듯하네요. 정점을 몇 개 더 추가해 원하는 -결과를 만들어봅시다. 각 정점 짝마다 10% 아래에 하나, 두 정점 사이 90% -지점에 하나를 새로 만들어 CatmullRomCurve3에 넘겨주겠습니다.

-

우리가 원하는 곡선은 다음과 같죠.

-
- -

아래는 곡선을 생성하는 코드입니다.

-
let curve;
-let curveObject;
-{
-  const controlPoints = [
-    [1.118281, 5.115846, -3.681386],
-    [3.948875, 5.115846, -3.641834],
-    [3.960072, 5.115846, -0.240352],
-    [3.985447, 5.115846, 4.585005],
-    [-3.793631, 5.115846, 4.585006],
-    [-3.826839, 5.115846, -14.736200],
-    [-14.542292, 5.115846, -14.765865],
-    [-14.520929, 5.115846, -3.627002],
-    [-5.452815, 5.115846, -3.634418],
-    [-5.467251, 5.115846, 4.549161],
-    [-13.266233, 5.115846, 4.567083],
-    [-13.250067, 5.115846, -13.499271],
-    [4.081842, 5.115846, -13.435463],
-    [4.125436, 5.115846, -5.334928],
-    [-14.521364, 5.115846, -5.239871],
-    [-14.510466, 5.115846, 5.486727],
-    [5.745666, 5.115846, 5.510492],
-    [5.787942, 5.115846, -14.728308],
-    [-5.423720, 5.115846, -14.761919],
-    [-5.373599, 5.115846, -3.704133],
-    [1.004861, 5.115846, -3.641834],
-  ];
-  const p0 = new THREE.Vector3();
-  const p1 = new THREE.Vector3();
-  curve = new THREE.CatmullRomCurve3(
-    controlPoints.map((p, ndx) => {
-      p0.set(...p);
-      p1.set(...controlPoints[(ndx + 1) % controlPoints.length]);
-      return [
-        (new THREE.Vector3()).copy(p0),
-        (new THREE.Vector3()).lerpVectors(p0, p1, 0.1),
-        (new THREE.Vector3()).lerpVectors(p0, p1, 0.9),
-      ];
-    }).flat(),
-    true,
-  );
-  {
-    const points = curve.getPoints(250);
-    const geometry = new THREE.BufferGeometry().setFromPoints(points);
-    const material = new THREE.LineBasicMaterial({color: 0xff0000});
-    curveObject = new THREE.Line(geometry, material);
-    scene.add(curveObject);
-  }
-}
-
-

코드의 첫 블럭에서 곡선을 만듭니다. 두 번째 블럭에서는 곡선에서 250개의 -정점을 받은 뒤, 이 정점들을 이어 곡선을 시각화합니다.

-

하지만 예제를 실행하니 곡선이 보이지 -않습니다. 일단 어디에 있는지 확인하기 위해 깊이 테스트(depth test) 옵션을 -끄고, 마지막에 렌더링하도록 설정하겠습니다.

-
    curveObject = new THREE.Line(geometry, material);
-+    material.depthTest = false;
-+    curveObject.renderOrder = 1;
-
-

다시 예제를 실행해보니 곡선이 너무 작은 게 문제였네요.

-
- -

블렌더로 계층 구조를 확인해보니 디자이너가 자동차 부모의 스케일(scale)을 -건드렸습니다.

-
- -

실제 3D 앱에서 스케일을 건드리는 것은 좋지 않습니다. 갖은 문제를 일으켜 -개발자를 좌절의 굴레에 빠지게 하거든요. 디자이너 입장에서야 각각의 크기를 -직접 수정하는 것보다 전체의 스케일을 조정하는 게 훨씬 편하겠지만, 실제 3D -앱 프로젝트에 참여한다면 디자이너에게 스케일을 건드리지 말라고 요청하기 -바랍니다. 만약 디자이너가 스케일을 수정해야만 하는 경우라면, 앱을 만들 때 -스케일을 무시할 수 있도록 정점에까지 스케일을 적용할 수 있는 방법을 찾아야 -합니다.

-

이 예제의 경우는 스케일뿐만 아니라 자동차들의 회전값과 위치값까지 Cars -요소의 영향을 받습니다. 이러면 자동차가 돌아다니게 만들기가 훨씬 어렵죠. -물론 예제의 경우 차를 전역 공간 안에서 움직여야 하기에 어려움이 있지만, -지역 공간에서만 무언가를 조작하는 경우, 예를 들어 지구를 도는 달을 구현하는 -경우는 이런 것이 큰 걸림돌이 되진 않습니다.

-

씬 그래프를 출력하기 위해 썼던 코드를 다시 가져와 이번에는 각 요소의 -위치값(position), 회전값(rotation), 크기값(scale)까지 출력해봅시다.

-
+function dumpVec3(v3, precision = 3) {
-+  return `${v3.x.toFixed(precision)}, ${v3.y.toFixed(precision)}, ${v3.z.toFixed(precision)}`;
-+}
-
-function dumpObject(obj, lines, isLast = true, prefix = '') {
-  const localPrefix = isLast ? '└─' : '├─';
-  lines.push(`${prefix}${prefix ? localPrefix : ''}${obj.name || '*no-name*'} [${obj.type}]`);
-+  const dataPrefix = obj.children.length
-+     ? (isLast ? '  │ ' : '│ │ ')
-+     : (isLast ? '    ' : '│   ');
-+  lines.push(`${prefix}${dataPrefix}  pos: ${dumpVec3(obj.position)}`);
-+  lines.push(`${prefix}${dataPrefix}  rot: ${dumpVec3(obj.rotation)}`);
-+  lines.push(`${prefix}${dataPrefix}  scl: ${dumpVec3(obj.scale)}`);
-  const newPrefix = prefix + (isLast ? '  ' : '│ ');
-  const lastNdx = obj.children.length - 1;
-  obj.children.forEach((child, ndx) => {
-    const isLast = ndx === lastNdx;
-    dumpObject(child, lines, isLast, newPrefix);
-  });
-  return lines;
-}
-
-

코드를 실행하니 -다음과 같은 결과가 나옵니다.

-
OSG_Scene [Scene]
-  │   pos: 0.000, 0.000, 0.000
-  │   rot: 0.000, 0.000, 0.000
-  │   scl: 1.000, 1.000, 1.000
-  └─RootNode_(gltf_orientation_matrix) [Object3D]
-    │   pos: 0.000, 0.000, 0.000
-    │   rot: -1.571, 0.000, 0.000
-    │   scl: 1.000, 1.000, 1.000
-    └─RootNode_(model_correction_matrix) [Object3D]
-      │   pos: 0.000, 0.000, 0.000
-      │   rot: 0.000, 0.000, 0.000
-      │   scl: 1.000, 1.000, 1.000
-      └─4d4100bcb1c640e69699a87140df79d7fbx [Object3D]
-        │   pos: 0.000, 0.000, 0.000
-        │   rot: 1.571, 0.000, 0.000
-        │   scl: 1.000, 1.000, 1.000
-        └─RootNode [Object3D]
-          │   pos: 0.000, 0.000, 0.000
-          │   rot: 0.000, 0.000, 0.000
-          │   scl: 1.000, 1.000, 1.000
-          ├─Cars [Object3D]
-*          │ │   pos: -369.069, -90.704, -920.159
-*          │ │   rot: 0.000, 0.000, 0.000
-*          │ │   scl: 1.000, 1.000, 1.000
-          │ ├─CAR_03_1 [Object3D]
-          │ │ │   pos: 22.131, 14.663, -475.071
-          │ │ │   rot: -3.142, 0.732, 3.142
-          │ │ │   scl: 1.500, 1.500, 1.500
-          │ │ └─CAR_03_1_World_ap_0 [Mesh]
-          │ │       pos: 0.000, 0.000, 0.000
-          │ │       rot: 0.000, 0.000, 0.000
-          │ │       scl: 1.000, 1.000, 1.000
-
-

이제 보니 기존 장면의 Cars에 있던 회전값과 크기값이 자식에게 옮겨갔네요. -파일을 열었을 때와 렌더링했을 때의 데이터가 다른 이유는 아마 디자이너가 -.GLTF 파일을 만들 때 쓴 프로그램이 무언가를 건드렸거나, 디자이너가 .blend -파일에서 조금 수정한 버젼으로 .GLTF 파일을 만들었기 때문일 겁니다.

-

진작 .blend 파일을 받아 직접 .GLTF 파일을 만들었더라면 좋았을 거라는 생각이 -듭니다. 내보내기 전에 주요 요소를 점검해 불필요한 설정을 제거했으면 더 -좋았을 텐데 말이죠.

-

아래의 이 요소들도

-
OSG_Scene [Scene]
-  │   pos: 0.000, 0.000, 0.000
-  │   rot: 0.000, 0.000, 0.000
-  │   scl: 1.000, 1.000, 1.000
-  └─RootNode_(gltf_orientation_matrix) [Object3D]
-    │   pos: 0.000, 0.000, 0.000
-    │   rot: -1.571, 0.000, 0.000
-    │   scl: 1.000, 1.000, 1.000
-    └─RootNode_(model_correction_matrix) [Object3D]
-      │   pos: 0.000, 0.000, 0.000
-      │   rot: 0.000, 0.000, 0.000
-      │   scl: 1.000, 1.000, 1.000
-      └─4d4100bcb1c640e69699a87140df79d7fbx [Object3D]
-        │   pos: 0.000, 0.000, 0.000
-        │   rot: 1.571, 0.000, 0.000
-        │   scl: 1.000, 1.000, 1.000
-
-

전부 불필요한 것들이고요.

-

위치값도, 회전값도, 크기값도 없는 하나의 "root" 요소가 있는 게 더 이상적입니다. -런타임에 루트 요소의 자식을 전부 꺼내 장면 자체의 자식으로 지정하는 것은 어떨까요? -"Cars"와 루트 요소가 자동차를 찾는 데 도움이 될 수는 있으나, 이 역시 별도의 위치값, -회전값, 크기값이 없는 게 나으니 간단히 장면을 자동차의 부모로 지정하는 것은요?

-

가장 최선의 해결책은 아니지만, 곡선 자체의 크기를 키우는 게 가장 빠른 해결책이긴 -합니다.

-

일단 저는 마지막 해결책을 선택하겠습니다.

-

먼저 곡선의 위치를 옮겨 적당한 위치에 둔 뒤 곡선을 숨깁니다.

-
{
-  const points = curve.getPoints(250);
-  const geometry = new THREE.BufferGeometry().setFromPoints(points);
-  const material = new THREE.LineBasicMaterial({color: 0xff0000});
-  curveObject = new THREE.Line(geometry, material);
-+  curveObject.scale.set(100, 100, 100);
-+  curveObject.position.y = -621;
-+  curveObject.visible = false;
-  material.depthTest = false;
-  curveObject.renderOrder = 1;
-  scene.add(curveObject);
-}
-
-

다음으로 자동차가 곡선을 따라 달리도록 코드를 작성합니다. 자동차마다 곡선에 비례해 -0에서 1사이의 위치를 정한 뒤, curveObject를 이용해 전역 공간에서의 위치값을 -구합니다. 그리고 곡선의에서 조금 더 낮은 값을 구한 뒤, looAt 메서드를 이용해 -자동차가 이 점을 바라보도록 설정하고, 자동차를 위치값과 방금 구한 점 중간에 둡니다.

-
// 경로를 계산할 때 쓸 Vector3 객체를 생성합니다
-const carPosition = new THREE.Vector3();
-const carTarget = new THREE.Vector3();
-
-function render(time) {
-  ...
-
--  for (const car of cars) {
--    car.rotation.y = time;
--  }
-
-+  {
-+    const pathTime = time * .01;
-+    const targetOffset = 0.01;
-+    cars.forEach((car, ndx) => {
-+      // 0에서 1사이의 값으로, 자동차의 간격을 균일하게 배치합니다
-+      const u = pathTime + ndx / cars.length;
-+
-+      // 첫 번째 점을 구합니다
-+      curve.getPointAt(u % 1, carPosition);
-+      carPosition.applyMatrix4(curveObject.matrixWorld);
-+
-+      // 곡선을 따라 첫 번째 점보다 조금 낮은 두 번째 점을 구합니다
-+      curve.getPointAt((u + targetOffset) % 1, carTarget);
-+      carTarget.applyMatrix4(curveObject.matrixWorld);
-+
-+      // (임시로) 자동차를 첫 번째 점에 둡니다
-+      car.position.copy(carPosition);
-+      // 자동차가 두 번째 점을 바라보게 합니다
-+      car.lookAt(carTarget);
-+
-+      // 차를 두 점 중간에 둡니다
-+      car.position.lerpVectors(carPosition, carTarget, 0.5);
-+    });
-+  }
-
-

실행시켜보니 자동차의 높이 기준도 제각기네요. 각 자동차의 위치값을 조금씩 수정하겠습니다.

-
const loadedCars = root.getObjectByName('Cars');
-const fixes = [
--  { prefix: 'Car_08', rot: [Math.PI * .5, 0, Math.PI * .5], },
--  { prefix: 'CAR_03', rot: [0, Math.PI, 0], },
--  { prefix: 'Car_04', rot: [0, Math.PI, 0], },
-+  { prefix: 'Car_08', y: 0,  rot: [Math.PI * .5, 0, Math.PI * .5], },
-+  { prefix: 'CAR_03', y: 33, rot: [0, Math.PI, 0], },
-+  { prefix: 'Car_04', y: 40, rot: [0, Math.PI, 0], },
-];
-
-root.updateMatrixWorld();
-for (const car of loadedCars.children.slice()) {
-  const fix = fixes.find(fix => car.name.startsWith(fix.prefix));
-  const obj = new THREE.Object3D();
-  car.getWorldPosition(obj.position);
--  car.position.set(0, 0, 0);
-+  car.position.set(0, fix.y, 0);
-  car.rotation.set(...fix.rot);
-  obj.add(car);
-  scene.add(obj);
-  cars.push(obj);
-}
-
-

- -

-

몇 분 투자한 것 치고는 괜찮은 결과물이네요!

-

마지막으로 그림자까지 추가하면 완벽할 것 같습니다.

-

그림자에 관한 글DirectionalLight 그림자 예제를 가져와 -그대로 코드에 붙여 넣습니다.

-

그리고 파일을 불러온 뒤, 모든 요소의 그림자 설정을 켜줍니다.

-
{
-  const gltfLoader = new GLTFLoader();
-  gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
-    const root = gltf.scene;
-    scene.add(root);
-
-+    root.traverse((obj) => {
-+      if (obj.castShadow !== undefined) {
-+        obj.castShadow = true;
-+        obj.receiveShadow = true;
-+      }
-+    });
-
-

그런데 그림자 헬퍼가 하나도 나타나지 않습니다. renderer의 그림자 설정을 -켜주지 않았기 때문이죠.

-
renderer.shadowMap.enabled = true;
-
-

이 간단한 걸 해결하느라 무려 4시간이 걸렸다는 건 비밀입니다. 😭

-

그리고 DirectionLight의 그림자용 카메라가 장면 전체를 투사하도록 절두체를 -조정합니다. 다음과 같이요.

-
{
-  const color = 0xFFFFFF;
-  const intensity = 1;
-  const light = new THREE.DirectionalLight(color, intensity);
-+  light.castShadow = true;
-*  light.position.set(-250, 800, -850);
-*  light.target.position.set(-550, 40, -450);
-
-+  light.shadow.bias = -0.004;
-+  light.shadow.mapSize.width = 2048;
-+  light.shadow.mapSize.height = 2048;
-
-  scene.add(light);
-  scene.add(light.target);
-+  const cam = light.shadow.camera;
-+  cam.near = 1;
-+  cam.far = 2000;
-+  cam.left = -1500;
-+  cam.right = 1500;
-+  cam.top = 1500;
-+  cam.bottom = -1500;
-...
-
-

마지막으로 배경색을 옅은 하늘색으로 설정합니다.

-
const scene = new THREE.Scene();
--scene.background = new THREE.Color('black');
-+scene.background = new THREE.Color('#DEFEFF');
-
-

- -

-

이 글이 씬 그래프를 포함한 파일을 불러오고, 몇몇 문제를 해결하는 데 도움이 -되었으면 합니다.

-

.blend 파일과 .gltf 파일을 비교해보면 재미있는 점이 하나 있습니다. .blend -파일에는 몇 가지 조명 요소가 있지만 씬 그래프로 변환하면 더 이상 조명의 역할을 -하지 못한다는 거죠. .GLTF 파일은 단순한 JSON 형태의 파일이기에 쉽게 내용을 -열어 볼 수 있습니다. .GLTF에는 여러 배열이 있고 이 배열의 요소를 참조할 때는 -인덱스값을 사용하죠. 기능을 확장하기에 쉬운 형식이라고는 해도, 다른 3D 형식과 -마찬가지로 존재하는 모든 기능을 지원하지는 못합니다.

-

때문에 항상 추가 데이터가 필요합니다. 아까 자동차가 따라갈 경로를 따로 내보낸 -것도 한 예죠. 경로까지 .GLTF 파일에 포함하는 게 이상적이긴 하나, 그러려면 -내보내기 규칙도 작성해야 하고, 규칙을 적용하기 위해 각 요소에 이름을 따로 -부여하든지, 네이밍 스키마를 사용하든지, 하여간 데이터를 앱에서 사용하기 위해 -무언가 해야 합니다.

-

어떤 게 가장 좋은 방법일까요? 제 생각에 이건 전적으로 여러분이 풀어나가야 할 -숙제입니다. 상황에 따라서 많이 달라질 테니까 말이죠.

- -
-
-
- - - - - - - - diff --git a/manual/ko/load-obj.html b/manual/ko/load-obj.html deleted file mode 100644 index 030af4757784d2..00000000000000 --- a/manual/ko/load-obj.html +++ /dev/null @@ -1,598 +0,0 @@ - - - 에서 .OBJ 파일 불러오기 - - - - - - - - - - - - - - -
-
-

에서 .OBJ 파일 불러오기

-
-
-
-

Three.js로 프로젝트를 진행할 때, 3D 모델 파일을 불러와 사용하는 것은 -아주 흔한 일입니다. 오늘은 주로 사용하는 파일 형식인 .OBJ 파일을 -불러오는 법에 대해 알아보겠습니다.

-

인터넷을 검색해 CC-BY-NC 3.0 풍자 3D 모델을 -하나 가져왔습니다(작가: ahedov).

-
- -

다운받은 파일 형식이 .blend네요. 블렌더(Blender)로 -파일을 열어 .OBJ 형식으로 변환하겠습니다.

-
- -
-

블렌더는 다른 프로그램과 다른 점이 많아 낯설게 느껴질 수 있습니다. -블렌더를 처음 접한다면, 글 읽기를 잠시 멈추고 블렌더의 기본 UI 가이드를 -먼저 읽어보길 권장합니다.

-

추가로 보통 3D 프로그램은 수천 가지 기능을 지원하는 거대 함선과 같습니다. -프로그램들 중에서도 복잡하기로 유명하죠. 1996년, 제가 3D Studio Max를 처음 -배우기 시작했을 때 저는 하루에 몇 시간씩 3주를 들여 공식 매뉴얼의 70% 정도를 -정독했습니다. 그리고 그게 몇 년 뒤 마야(Maya)를 배울 때 도움이 많이 됐죠. 3D -모델을 만들든, 기존 모델을 수정하든, 3D 프로그램으로 무언가를 하고 싶다면 강의나 -튜토리얼에 따로 시간을 투자하기 바랍니다.

-
-

특별한 일이 없다면 저는 파일을 내보낼 때 아래의 옵션을 사용합니다.

-
- -

자 이제 한 번 화면에 띄워보죠!

-

조명에 관한 글에서 썼던 예제를 가져와 이 예제를 -반구광(hemisphere light) 예제와 합칩니다. 그러면 장면에는 HemisphereLight -하나, DirectionalLight 하나가 있는 셈입니다. 또 GUI 관련 코드와 정육면체, -구체 관련 코드도 지웁니다.

-

다음으로 먼저 OBJLoader 모듈을 스크립트에 로드합니다.

-
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
-
-

OBJLoader의 인스턴스를 생성한 뒤 .OBJ 파일의 경로와 콜백 함수를 넘겨 -load 메서드를 실행합니다. 그리고 콜백 함수에서 불러온 모델을 장면에 -추가합니다.

-
{
-  const objLoader = new OBJLoader();
-  objLoader.load('resources/models/windmill/windmill.obj', (root) => {
-    scene.add(root);
-  });
-}
-
-

어떤 결과가 나올까요?

-

- -

-

뭔가 성공한 듯하지만 재질(materials)이 없어 오류가 납니다. .OBJ 파일에도 -재질이 없고 따로 재질을 지정하지도 않았기 때문이죠.

-

위에서 생성한 .OBJ 로더에는 이름 : 재질 쌍을 객체로 지정할 수 있습니다. -.OBJ 파일을 불러올 때, 이름이 지정되었다면 로더에 지정한 재질 중에 이름(키)과 -일치하는 재질을 찾아 사용하고, 재질을 찾지 못했다면 기본 재질을 사용하죠.

-

.OBJ 파일을 생성할 때 재질에 대한 데이터를 담은 .MTL 파일이 같이 생성되기도 -합니다. 방금의 경우에도 .MTL 파일이 같이 생성되었죠. MTL 파일은 ASCII 인코딩이므로 -일반 텍스트 파일처럼 열어볼 수 있습니다.

-
# Blender MTL File: 'windmill_001.blend'
-# Material Count: 2
-
-newmtl Material
-Ns 0.000000
-Ka 1.000000 1.000000 1.000000
-Kd 0.800000 0.800000 0.800000
-Ks 0.000000 0.000000 0.000000
-Ke 0.000000 0.000000 0.000000
-Ni 1.000000
-d 1.000000
-illum 1
-map_Kd windmill_001_lopatky_COL.jpg
-map_Bump windmill_001_lopatky_NOR.jpg
-
-newmtl windmill
-Ns 0.000000
-Ka 1.000000 1.000000 1.000000
-Kd 0.800000 0.800000 0.800000
-Ks 0.000000 0.000000 0.000000
-Ke 0.000000 0.000000 0.000000
-Ni 1.000000
-d 1.000000
-illum 1
-map_Kd windmill_001_base_COL.jpg
-map_Bump windmill_001_base_NOR.jpg
-map_Ns windmill_001_base_SPEC.jpg
-
-

파일을 살펴보면 2개의 재질과 5개의 jpg 텍스처가 보이는데, 텍스처 파일은 -디렉토리 내에 보이지 않습니다. 대체 어디에 있는 걸까요?

-
- -

생성된 거라고는 .OBJ 파일 하나와 .MTL 파일 하나 뿐입니다.

-

사실 방금 사용한 모델의 텍스처는 .blend 파일에 포함되어 있습니다. -File->External Data->Unpack All Into Files를 선택하고

-
- -

Write Files to Current Directory를 선택해 텍스처를 별도 파일로 -내보낼 수 있습니다.

-
- -

이러면 .blend 파일과 같은 경로의 textures 폴더 안에 텍스처 파일이 -생성됩니다.

-
- -

내보낸 텍스처를 복사해 .OBJ 파일과 같은 경로에 두겠습니다.

-
- -

이제 .MTL 파일에서 사용할 텍스처를 생성했으니 .MTL 파일을 불러오도록 합시다.

-

MTLLoader 모듈을 불러옵니다.

-
import * as THREE from 'three';
-import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
-+import { MTLLoader } from 'three/addons/loaders/MTLLoader.js';
-
-

-

우선 .MTL 파일을 불러와 MtlObjBridge로 재질을 만듭니다. 그리고 OBJLoader -인스턴스에 방금 만든 재질을 추가한 뒤 .OBJ 파일을 불러옵니다.

-
{
-+  const mtlLoader = new MTLLoader();
-+  mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => {
-+    mtl.preload();
-+    objLoader.setMaterials(mtl);
-    objLoader.load('resources/models/windmill/windmill.obj', (root) => {
-      scene.add(root);
-    });
-+  });
-}
-
-

- -

-

얼핏 제대로 불러온 것 같지만 아직 부족한 점이 있습니다. 모델을 이리저리 -회전시켜 보면 풍차의 날개 뒷면이 사라지는 것을 볼 수 있을 겁니다.

-
- -

재질에 관한 글을 읽었다면 원인이 무엇인지 알 -겁니다. 일단 풍차의 날개 양면을 모두 렌더링하도록 설정해야 겠네요. .MTL -파일을 직접 수정하기는 어렵습니다. 그렇다면 쉽게 떠올릴 수 있는 방법은 -3가지 정도죠.

-
    -
  1. 모든 재질을 불러온 뒤 반복문으로 처리한다.

    -
     const mtlLoader = new MTLLoader();
    - mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => {
    -   mtl.preload();
    -   for (const material of Object.values(mtl.materials)) {
    -     material.side = THREE.DoubleSide;
    -   }
    -   ...
    -

    문제가 해결되긴 하겠지만, 양면 렌더링은 단면 렌더링에 비해 성능이 느립니다. -양면일 필요가 있는 재질만 양면으로 렌더링하는 게 이상적이겠죠.

    -
  2. -
  3. 특정 재질을 골라 설정한다.

    -

    .MTL 파일에는 "windmill", "Material" 2개의 재질이 있습니다. 여러 번의 시도와 -에러 끝에 날개가 "Material"이라는 이름의 재질을 쓴다는 것을 알아낸 뒤, 이 재질에만 -양면 속성을 설정할 수도 있을 겁니다.

    -
     const mtlLoader = new MTLLoader();
    - mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => {
    -   mtl.preload();
    -   mtl.materials.Material.side = THREE.DoubleSide;
    -   ...
    -
  4. -
  5. .MTL 파일의 한계에 굴복하고 직접 재질을 만든다.

    -
     objLoader.load('resources/models/windmill/windmill.obj', (root) => {
    -   const materials = {
    -     Material: new THREE.MeshPhongMaterial({...}),
    -     windmill: new THREE.MeshPhongMaterial({...}),
    -   };
    -   root.traverse(node => {
    -     const material = materials[node.material?.name];
    -     if (material) {
    -       node.material = material;
    -     }
    -   })
    -   scene.add(root);
    - });
    -
  6. -
-

뭘 선택하든 그건 여러분의 선택입니다. 1번이 가장 간단하고, 3번이 가장 -확장성이 좋죠. 2번은 그 중간입니다. 지금은 2번 해결책을 사용하도록 하죠.

-

해결책을 적용하면 날개가 제대로 보일 겁니다. 하지만 문제가 하나 더 남았습니다. -모델을 확대해보면 텍스처가 굉장히 각져 보일 거예요.

-
- -

뭐가 문제일까요?

-

텍스처 파일 중에는 NOR, 법선 맵(NORmal map)이라는 이름이 붙은 파일이 있습니다. -이 파일이 바로 법선 맵이죠. 범프 맵(bump map)이 흑백이라면 법선 맵은 보통 -자주색을 띱니다. 범프 맵이 표면의 높이를 나타낸다면 법선 맵은 표면의 방향을 -나타내죠.

-
- -

MTLLoader의 소스 코드를 -살펴보면 법선 맵의 키(key)가 norm이어야 한다고 합니다. 간단히 .MTL 파일을 -수정해보죠.

-
# Blender MTL File: 'windmill_001.blend'
-# Material Count: 2
-
-newmtl Material
-Ns 0.000000
-Ka 1.000000 1.000000 1.000000
-Kd 0.800000 0.800000 0.800000
-Ks 0.000000 0.000000 0.000000
-Ke 0.000000 0.000000 0.000000
-Ni 1.000000
-d 1.000000
-illum 1
-map_Kd windmill_001_lopatky_COL.jpg
--map_Bump windmill_001_lopatky_NOR.jpg
-+norm windmill_001_lopatky_NOR.jpg
-
-newmtl windmill
-Ns 0.000000
-Ka 1.000000 1.000000 1.000000
-Kd 0.800000 0.800000 0.800000
-Ks 0.000000 0.000000 0.000000
-Ke 0.000000 0.000000 0.000000
-Ni 1.000000
-d 1.000000
-illum 1
-map_Kd windmill_001_base_COL.jpg
--map_Bump windmill_001_base_NOR.jpg
-+norm windmill_001_base_NOR.jpg
-map_Ns windmill_001_base_SPEC.jpg
-
-

이제 법선 맵이 정상적으로 적용되었고, 날개의 뒷면도 제대로 보입니다.

-

- -

-

다른 파일도 불러와봅시다.

-

인터넷을 뒤져 CC-BY-NC -풍차 3D 모델을 발견했습니다(작가: Roger Gerzner / GERIZ.3D Art).

-
- -

.OBJ 형식으로 다운 받을 수 있으므로, 해당 형식으로 받아 불러오겠습니다(잠깐 -.MTL 로더를 제거했습니다).

-
-  objLoader.load('resources/models/windmill/windmill.obj', ...
-+  objLoader.load('resources/models/windmill-2/windmill.obj', ...
-
-

- -

-

음, 아무것도 나타나지 않습니다. 뭐가 문제일까요? 모델의 원래 크기 때문일까요? -Three.js로부터 모델 사이즈를 구해 카메라를 한 번 업데이트해보겠습니다.

-

먼저 Three.js가 방금 불러온 모델을 감싸는 육면체를 계산해 모델의 크기와 중심점을 -구하는 코드를 작성합니다.

-
objLoader.load('resources/models/windmill_2/windmill.obj', (root) => {
-  scene.add(root);
-
-+  const box = new THREE.Box3().setFromObject(root);
-+  const boxSize = box.getSize(new THREE.Vector3()).length();
-+  const boxCenter = box.getCenter(new THREE.Vector3());
-+  console.log(boxSize);
-+  console.log(boxCenter);
-
-

자바스크립트 콘솔을 확인해보면 아래와 -같은 결과가 보일 겁니다.

-
size 2123.6499788469982
-center p { x: -0.00006103515625, y: 770.0909731090069, z: -3.313507080078125 }
-
-

이 카메라는 현재 near 0.1, far가 100 이므로 약 100칸 정도를 투사합니다. -땅도 40x40칸인데 이 모델은 2000칸이죠. 카메라의 시야보다 훨씬 크니 절두체 영역 -밖에 있는 게 당연합니다.

-
- -

수작업으로 고칠 수도 있지만, 카메라가 장면의 크기를 자동으로 감지하도록 만들어보겠습니다. -방금 모델의 크기를 구할 때 썼던 육면체를 이용하면 되겠네요. 카메라의 위치를 정하는 데 -정해진 방법은 없습니다. 경우에 따라 카메라의 방향과 위치가 다르니 그때 그때 상황에 -맞춰 방법을 찾아야 하죠.

-

카메라에 관해 배운 내용을 떠올려봅시다. 카메라를 만들려면 -절두체를 정의해야 하죠. 절두체는 fov(시야각, field of view), near, far 속성을 -지정해 정의합니다. 시야각이 얼마이든, 절두체가 무한히 늘어난다고 가정할 때, 장면을 -둘러싼 육면체가 절두체 안에 들어오게 하려면 카메라를 얼마나 멀리 보내야 할까요? 그러니까 -near가 0.00000001이고 far가 무한대라 가정했을 때 말이죠.

-

다행히 시야각과 육면체의 크기를 아니 다음 그림과 같은 삼각형을 사용할 수 있습니다.

-
- -

그림에서 왼쪽은 카메라이고, 카메라에서 뻗어나온 파란 절두체가 풍차를 투사합니다. -방금 풍차를 둘러싼 육면체의 위치값을 계산했죠. 이제 얼마나 카메라를 멀리 보내야 -육면체가 절두체 안에 들어올지 계산해야 합니다.

-

절두체의 시야각과 육면체의 크기를 구했으니, 기본 삼각함수와 *SOHCAHTOA를 -이용해 카메라와 육면체의 거리(distance)를 구할 수 있습니다.

-

※ SOH-CAH-TOA: 한국에서 삼각함수를 배울 때 얼싸안코와 비슷한 식으로 외우듯, -영미권에도 삼각함수를 배울 때 Sin = Opposite(대변) 나누기 Hypotenuse(빗변), -Cos = Adjacent(밑변) 나누기 Hypotenuse, Tan = Oppsite 나누기 Adjacent와 같은 -식으로 외웁니다. 이를 줄여서 SOH-CAH-TOA(소-카-토아)라고 부릅니다. 역주.

-
- -

그림을 기반으로 계산식을 짜보겠습니다.

-
distance = halfSizeToFitOnScreen / tangent(halfFovY) // 거리 = 화면 크기의 반 / 탄젠트(시야각의 절반)
-
-

이제 위 계산식을 코드로 옮겨야 합니다. 먼저 distance(거리)를 구한 뒤 -카메라를 육면체의 중심에서 distance값만큼 옮깁니다. 그리고 카메라가 육면체의 -center(중심)을 바라보게 설정합니다.

-
function frameArea(sizeToFitOnScreen, boxSize, boxCenter, camera) {
-  const halfSizeToFitOnScreen = sizeToFitOnScreen * 0.5;
-  const halfFovY = THREE.MathUtils.degToRad(camera.fov * .5);
-  const distance = halfSizeToFitOnScreen / Math.tan(halfFovY);
-
-  // 육면체의 중심에서 카메라가 있는 곳으로 향하는 방향 벡터를 계산합니다
-  const direction = (new THREE.Vector3()).subVectors(camera.position, boxCenter).normalize();
-
-  // 방향 벡터에 따라 카메라를 육면체로부터 일정 거리에 위치시킵니다
-  camera.position.copy(direction.multiplyScalar(distance).add(boxCenter));
-
-  // 육면체를 투사할 절두체를 near와 far값으로 정의합니다
-  camera.near = boxSize / 100;
-  camera.far = boxSize * 100;
-
-  camera.updateProjectionMatrix();
-
-  // 카메라가 육면체의 중심을 바라보게 합니다
-  camera.lookAt(boxCenter.x, boxCenter.y, boxCenter.z);
-}
-
-

이 함수는 boxSizesizeToFitOnScreen, 두 개의 크기값을 매개변수로 받습니다. -boxSize 값으로 sizeToFitOnScreen 값을 대체할 수도 있지만, 이러면 육면체가 -화면에 꽉 차게 됩니다. 조금 여유가 있는 편이 보기 편하므로 조금 더 큰 값을 넘겨주도록 -하겠습니다.

-
{
-  const objLoader = new OBJLoader();
-  objLoader.load('resources/models/windmill_2/windmill.obj', (root) => {
-    scene.add(root);
-+    // 모든 요소를 포함하는 육면체를 계산합니다
-+    const box = new THREE.Box3().setFromObject(root);
-+
-+    const boxSize = box.getSize(new THREE.Vector3()).length();
-+    const boxCenter = box.getCenter(new THREE.Vector3());
-+
-+    // 카메라가 육면체를 완전히 감싸도록 설정합니다
-+    frameArea(boxSize * 1.2, boxSize, boxCenter, camera);
-+
-+    // 마우스 휠 이벤트가 큰 크기에 대응하도록 업데이트합니다
-+    controls.maxDistance = boxSize * 10;
-+    controls.target.copy(boxCenter);
-+    controls.update();
-  });
-}
-
-

위 예제에서는 boxSize * 1.2 값을 넘겨주어 20% 정도 빈 공간을 더 만들었습니다. -또 카메라가 장면의 중심을 기준으로 회전하도록 OrbitControls도 업데이트했죠.

-

이제 코드를 실행하면...

-

- -

-

성공했습니다. 마우스로 장면을 드래그하면 풍차가 보일 거예요. 하지만 카메라가 풍차의 -정면이 아닌 아래쪽을 먼저 보여줍니다. 이는 풍차가 너무 커서 육면체의 중심이 약 -(0, 770, 0)인데, 카메라를 육면체의 중심에서 기존 위치 (0, 10, 20) 방향으로 distance만큼 -옮겼기에 풍차의 아래쪽에 카메라가 위치하게 된 것입니다.

-
- -

카메라의 기존 위치에 상관없이 육면체의 중심을 기준으로 카메라를 배치해보겠습니다. -단순히 카메라와 육면체 간 벡터의 y 요소를 0으로 만들면 됩니다. y 요소를 0으로 -만든 뒤 벡터를 정규화(normalize)하면, XZ 면에 평행한 벡터, 그러니까 바닥에 평행한 -벡터가 되겠죠.

-
-// 육면체의 중심에서 카메라가 있는 곳으로 향하는 방향 벡터를 계산합니다
--const direction = (new THREE.Vector3()).subVectors(camera.position, boxCenter).normalize();
-+// 카메라와 육면체 사이의 방향 벡터를 항상 XZ 면에 평행하게 만듭니다
-+const direction = (new THREE.Vector3())
-+    .subVectors(camera.position, boxCenter)
-+    .multiply(new THREE.Vector3(1, 0, 1))
-+    .normalize();
-
-

풍차의 아랫면을 보면 작은 정사각형이 하나 보일 겁니다. 원래 땅으로 썼던 평면이죠.

-
- -

원래 땅은 40x40칸이었으니 풍차에 비해 훨씬 작은 것이 당연합니다. 풍차의 크기는 -2000칸이 넘습니다. 땅을 풍차에 맞게 키워야 겠네요. 또 크기만 키우면 체크무늬가 -너무 작아 확대하지 않는 한 보기가 어려울 테니 체스무늬 한 칸의 크기도 키우겠습니다.

-
-const planeSize = 40;
-+const planeSize = 4000;
-
-const loader = new THREE.TextureLoader();
-const texture = loader.load('resources/images/checker.png');
-texture.wrapS = THREE.RepeatWrapping;
-texture.wrapT = THREE.RepeatWrapping;
-texture.magFilter = THREE.NearestFilter;
--const repeats = planeSize / 2;
-+const repeats = planeSize / 200;
-texture.repeat.set(repeats, repeats);
-
-

- -

-

이제 재질을 다시 붙여봅시다. 이전 모델과 마찬가지로 텍스처에 대한 데이터를 담은 -.MTL 파일이 보입니다. 하지만 동시에 다른 문제도 보이네요.

-
 $ ls -l windmill
- -rw-r--r--@ 1 gregg  staff       299 May 20  2009 windmill.mtl
- -rw-r--r--@ 1 gregg  staff    142989 May 20  2009 windmill.obj
- -rw-r--r--@ 1 gregg  staff  12582956 Apr 19  2009 windmill_diffuse.tga
- -rw-r--r--@ 1 gregg  staff  12582956 Apr 20  2009 windmill_normal.tga
- -rw-r--r--@ 1 gregg  staff  12582956 Apr 19  2009 windmill_spec.tga
-
-

어마어마하게 큰 TARGA (.tga) 파일이 있습니다.

-

THREE.js에 TGA 로더가 있기는 하나 대부분의 경우 이를 사용하는 건 좋지 않습니다. -아주 소수의 경우, 예를 들어 사용자가 임의의 3D 모델 파일을 불러와 확인할 수 있는 -뷰어를 만든다거나 하는 경우라면 TGA 파일을 사용할 수도 있죠.(*)

-

TGA 파일의 문제점 중 하나는 압축을 거의 하지 않는다는 점입니다. TGA는 아주 간단한 -압축만 지원하죠. 파일의 크기가 모두 같을 확률은 매우 희박하니, 위 파일들은 아예 -압축이 되지 않았다고 볼 수 있습니다. 게다가 파일 하나당 무려 12 메가바이트!! 저 -파일을 그대로 사용한다면 사용자는 풍차 하나를 보기 위해 36MB의 데이터를 다운받아야 -하는 셈이 됩니다.

-

또한 브라우저가 TGA를 지원하지 않기에, .JPG나 .PNG 파일보다 로딩 시간이 훨씬 느릴 -겁니다.

-

확신하건데, 이 경우 .JPG 파일로 변환하는 게 가장 좋은 선택입니다. TGA 파일은 알파값이 -없는 RGB 3개의 채널로 구성되죠. JPG도 채널 3개만 사용하니 딱 적당합니다. 또 JPG는 손실 -압축을 사용하기에 파일 용량을 훨씬 많이 줄일 수 있습니다.

-

파일을 열어보니 각각 해상도가 2048x2048입니다. 쓰기에 따라 다르겠지만, 저는 이게 다소 -낭비라는 생각에 해상도를 1024x1024로 낯추고 포토샵의 퀄리티 설정을 50%로 지정했습니다. -다시 파일 구조를 살펴보죠.

-
 $ ls -l ../threejs.org/manual/examples/resources/models/windmill
- -rw-r--r--@ 1 gregg  staff     299 May 20  2009 windmill.mtl
- -rw-r--r--@ 1 gregg  staff  142989 May 20  2009 windmill.obj
- -rw-r--r--@ 1 gregg  staff  259927 Nov  7 18:37 windmill_diffuse.jpg
- -rw-r--r--@ 1 gregg  staff   98013 Nov  7 18:38 windmill_normal.jpg
- -rw-r--r--@ 1 gregg  staff  191864 Nov  7 18:39 windmill_spec.jpg
-
-

36MB에서 0.55MB가 되었네요! 물론 디자너이너의 생각은 다를 수 있으니 절충안을 -찾기 전에 상의를 하는 것이 좋습니다.

-

이제 .MTL 파일을 열어 .TGA 파일 경로를 .JPG 파일로 바꿉니다. 다행히 .MTL 파일은 -텍스트라 수정이 어렵지 않습니다.

-
newmtl blinn1SG
-Ka 0.10 0.10 0.10
-
-Kd 0.00 0.00 0.00
-Ks 0.00 0.00 0.00
-Ke 0.00 0.00 0.00
-Ns 0.060000
-Ni 1.500000
-d 1.000000
-Tr 0.000000
-Tf 1.000000 1.000000 1.000000
-illum 2
--map_Kd windmill_diffuse.tga
-+map_Kd windmill_diffuse.jpg
-
--map_Ks windmill_spec.tga
-+map_Ks windmill_spec.jpg
-
--map_bump windmill_normal.tga
--bump windmill_normal.tga
-+map_bump windmill_normal.jpg
-+bump windmill_normal.jpg
-
-

텍스처의 용량을 최적화했으니 이제 불러올 일만 남았습니다. 먼저 아까 했던 것처럼 -재질을 불러와 OBJLoader에 지정합니다.

-
{
-+  const mtlLoader = new MTLLoader();
-+  mtlLoader.load('resources/models/windmill_2/windmill-fixed.mtl', (mtl) => {
-+    mtl.preload();
-+    const objLoader = new OBJLoader();
-+    objLoader.setMaterials(mtl);
-    objLoader.load('resources/models/windmill/windmill.obj', (root) => {
-      root.updateMatrixWorld();
-      scene.add(root);
-      // 모든 요소를 포함하는 육면체를 계산합니다
-      const box = new THREE.Box3().setFromObject(root);
-
-      const boxSize = box.getSize(new THREE.Vector3()).length();
-      const boxCenter = box.getCenter(new THREE.Vector3());
-
-      // 카메라가 육면체를 완전히 감싸도록 설정합니다
-      frameArea(boxSize * 1.2, boxSize, boxCenter, camera);
-
-      // 마우스 휠 이벤트가 큰 크기에 대응하도록 업데이트합니다
-      controls.maxDistance = boxSize * 10;
-      controls.target.copy(boxCenter);
-      controls.update();
-    });
-+  });
-}
-
-

결과를 확인했는데 문제가 발생했습니다. 여러분에게 직접 보여주기보다 하나하나 -짚어보도록 하죠.

-

문제 #1: 3개의 MTLLoader가 각각 디퓨즈(diffuse) 색과 디퓨즈 텍스처 맵으로 -혼합하는 재질을 만듬.

-

이는 유용한 기능이지만, .MTL 파일의 디퓨즈 색상은 0입니다.

-
Kd 0.00 0.00 0.00
-
-

(텍스처 맵 * 0 = 검정)이죠. 모델링 프로그램에서는 디퓨즈 텍스처 맵과 디퓨즈 색을 혼합하지 -않아도 모델이 제대로 보입니다. 이 풍차를 만든 디자이너 입장에서는 이 파일이 문제가 -없다고 생각하는 것이 당연하죠.

-

.MTL 파일을 다음과 같이 수정해 문제를 해결할 수 있습니다.

-
Kd 1.00 1.00 1.00
-
-

(텍스처 맵 * 1 = 텍스처 맵)이니까요.

-

문제 #2: 스페큘러(specular) 색이 검정임.

-

Ks로 시작하는 줄은 스페큘러 색을 나타냅니다. 이 역시 디자이너가 사용한 모델링 -프로그램이 디퓨즈 맵처럼 뭔가 다른 처리를 해주었을 겁니다. Three.js는 스페큘러 -색을 얼마나 많이 반사할지 결정할 때 스페큘러 맵의 빨강(red) 채널만 사용하기는 -하나, 3가지 색상 채널을 모두 지정하긴 해야 합니다.

-

디퓨즈 색과 마찬가지로 .MTL 파일을 다음과 같이 수정하겠습니다.

-
-Ks 0.00 0.00 0.00
-+Ks 1.00 1.00 1.00
-
-

문제 #3: windmill_normal.jpg가 법선 맵이 아닌 범프 맵임.

-

마찬가지로 .MTL 파일을 수정해줍니다.

-
-map_bump windmill_normal.jpg
--bump windmill_normal.jpg
-+norm windmill_normal.jpg
-
-

위 변경 사항을 모두 반영하면 재질이 정상적으로 적용될 겁니다.

-

- -

-

모델을 불러올 때 주의해야 하는 점을 몇 가지만 적어보겠습니다.

-
    -
  • 크기를 알아야 한다

    -

    예제에서는 카메라가 장면 전체를 감싸도록 했지만, 이게 항상 최적의 해결책이 될 수는 없습니다. -직접 모델을 만들거나, 모델을 다운받아 3D 프로그램으로 크기를 조절하는 것이 더 이상적인 방법입니다.

    -
  • -
  • 잘못된 방향축

    -

    Three.js에서는 보통 Y축이 위쪽입니다. 모델링 프로그램에서는 Z축이 위쪽인 경우, Y축이 위쪽인 경우, 직접 -설정할 수 있는 경우 등 경우가 다양하죠. 모델을 불러왔는데 방향이 잘못되었다면, 모델을 불러온 후 방향을 -바꾸거나(권장하지 않음), 3D 프로그램이나 커맨드 라인 프로그램으로 모델을 원하는 방향으로 맞출 수 있습니다. -브라우저에서 이미지를 쓸 때와 마찬가지로, 이미지를 수정하는 코드를 넣는 것보다는 이미지를 다운받아 이미지 -자체를 편집하는 게 더 나을 겁니다. 블렌더에서는 아예 파일을 내보낼 때 방향을 바꿀 수 있습니다.

    -
  • -
  • .MTL 파일이 없거나 재질 또는 지원하지 않는 값이 있는 경우

    -

    위 예제를 만들 때 .MTL 파일 덕에 재질을 만드는 수고는 덜었지만, 몇 가지 문제가 있었습니다. 문제를 해결하기 -위해 직접 .MTL 파일을 수정했고요. 파일을 열어 .OBJ 파일 안에 어떤 재질이 있는지 확인하거나, Three.js로 -.OBJ 파일을 불러와 재질을 전부 출력하도록 하는 것은 꽤 자주 있는 일입니다. 그런 후에 .MTL 파일 대신 직접 -재질을 만들어 적절한 이름/재질 쌍의 객체로 로더에 넘겨주거나, 장면을 렌더링한 뒤 테스트하면서 문제를 수정하는 -것이죠.

    -
  • -
  • 고용량 텍스처

    -

    3D 모델은 주로 건축, 영화나 광고, 게임 등에서 사용합니다. 건축이나 영화 같은 분야라면 텍스처의 용량을 -신경 쓸 필요는 없죠. 반면 게임의 경우는 메모리도 제한적이고 로컬 환경에서 구동되기에 신경을 꽤 써야 합니다. -웹 페이지의 경우는 빠르게 불러와야 하니 용량이 퀄리티가 너무 떨어지지 않는 선에서 최대한 작은 게 좋죠. -첫 번째로 쓴 풍차의 경우, 실제로 사용하려면 텍스처를 손볼 필요가 있습니다. 지금은 총 용량이 무려 10MB가 -넘거든요!!!

    -

    또한 텍스처에 관한 글에서 말했듯, 텍스처의 해상도도 고려해야 합니다. 50KB짜리 -4096x4096 JPG 이미지는 불러오는 속도는 빠를지 몰라도 굉장히 많은 메모리를 차지할 테니까요.

    -
  • -
-

마지막으로 풍차가 돌아가는 것을 보여주고 싶지만, .OBJ 파일에는 계층 구조가 없습니다. 다시 말해 풍차의 모든 -요소를 기본적으로 1개의 mesh로 취급한다는 것이죠. 풍차의 날개를 건물에서 분리할 수 없기에 날개를 회전시킬 -수가 없습니다.

-

이런 이유로 .OBJ는 그다지 좋은 파일 형식이라고 하기 어렵습니다. 추측하건데 .OBJ 형식을 자주 사용하는 이유는 -사용법이 간단하고, 복잡한 기능이 필요 없는 경우가 많기 때문일 겁니다. 예를 들어 건축 디자인을 하는 경우, -대부분 애니메이션이 필요 없기에 장면에 정적 요소를 추가하는 게 더 좋을 수 있죠.

-

.gLTF는 .OBJ보다 더 많은 기능을 지원합니다. 다음 글에서는 이 gLTF 장면을 불러오는 법에 대해 알아보겠습니다.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/material-table.html b/manual/ko/material-table.html deleted file mode 100644 index bf8f1b2a4440a0..00000000000000 --- a/manual/ko/material-table.html +++ /dev/null @@ -1,48 +0,0 @@ - - - 재질(Material) 속성표 - - - - - - - - - - - - - - -
-
-

재질(Material) 속성표

-
-
-
-

Three.js에서 주로 쓰는 재질(material)은 Mesh 재질입니다. 아래는 각 재질이 지원하는 속성을 표로 나타낸 것입니다.

-
-
- - -
- - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/materials.html b/manual/ko/materials.html deleted file mode 100644 index 1fe12cf2ad00ee..00000000000000 --- a/manual/ko/materials.html +++ /dev/null @@ -1,317 +0,0 @@ - - - 재질(Materials) - - - - - - - - - - - - - - -
-
-

재질(Materials)

-
-
-
-

※ 이 글은 Three.js의 튜토리얼 시리즈로서, -먼저 Three.js의 기본 구조에 관한 글을 -읽고 오길 권장합니다.

-

Three.js에는 기본으로 제공하는 재질(materials) 몇 개가 있습니다. -재질이란, 물체가 씬(scene)에 어떤 식으로 나타날지를 결정하는 요소로서, -어떤 재질을 사용할지는 전적으로 상황에 따라 판단해야 합니다.

-

재질의 속성(property)를 정하는 방법은 크게 두 가지로 나뉩니다. 하나는 -이전처럼 생성자를 호출할 때 값을 넘겨주는 것이고,

-
const material = new THREE.MeshPhongMaterial({
-  color: 0xFF0000,    // 빨강 (CSS처럼 문자열로 넘겨줄 수도 있음. "#ff0000")
-  flatShading: true,
-});
-
-

다른 하나는 생성한 뒤에 바꾸는 것이죠.

-
const material = new THREE.MeshPhongMaterial();
-material.color.setHSL(0, 1, .5);  // 빨강
-material.flatShading = true;
-
-

물론 HSL 색상 모델 외에 rgb, hex 등 다양한 방법으로 색을 지정할 -수 있습니다.

-
material.color.set(0x00FFFF);    // CSS의 #RRGGBB 형식
-material.color.set(cssString);   /* CSS 색상 문자열, 예를 들어 'purple', '#F32',
-                                  * 'rgb(255, 127, 64)',
-                                  * 'hsl(180, 50%, 25%)' 등
-                                  */
-material.color.set(someColor)    // THREE.Color에 정의된 static 색상
-material.color.setHSL(h, s, l)   // hsl 색상, 0부터 1까지
-material.color.setRGB(r, g, b)   // rgb 색상, 0부터 1까지
-
-

생성시에도 hex 값 또는 CSS 문자열을 전달해도 됩니다.

-
const m1 = new THREE.MeshBasicMaterial({color: 0xFF0000});         // 빨강
-const m2 = new THREE.MeshBasicMaterial({color: 'red'});            // 빨강
-const m3 = new THREE.MeshBasicMaterial({color: '#F00'});           // 빨강
-const m4 = new THREE.MeshBasicMaterial({color: 'rgb(255,0,0)'});   // 빨강
-const m5 = new THREE.MeshBasicMaterial({color: 'hsl(0,100%,50%)'}); // 빨강
-
-

이제 Three.js의 기본 재질을 살펴보겠습니다.

-

MeshBasicMaterial은 광원의 영향을 받지 않습니다. MeshLambertMaterial은 -정점에서만 광원을 계산하고, MeshPhongMaterial은 픽셀 하나하나 전부 광원을 -계산합니다. 뿐만 아니라 MeshPhongMaterial은 반사점(specular highlights, 물체가 조명을 받을 때 물체에 나타나는 밝은 점. 역주)도 -지원합니다.

-
-
-
-
Basic
-
-
-
-
Lambert
-
-
-
-
Phong
-
-
-
-
-
-
-
-
-
-
-
-
-
-
같은 모델을 로우-폴리(low poly) 모델로 바꾼 것
- -

MeshPhongMaterialshininess 속성으로 반사점의 밝기를 조절할 수 -있습니다(기본값 30).

-
-
-
-
shininess: 0
-
-
-
-
shininess: 30
-
-
-
-
shininess: 150
-
-
- -

만약 MeshLambertMaterial이나 MeshPhongMaterialemissive 속성에 -색상값을 지정하고, (MeshPhongMaterialshininess도 0으로 지정해야함) -color 속성을 검정으로 지정하면 MeshBasicMaterial과 마찬가지로 입체감이 -사라집니다.

-
-
-
-
-
Basic
-
color: 'purple'
-
-
-
-
-
-
Lambert
-
color: 'black'
-
emissive: 'purple'
-
-
-
-
-
-
Phong
-
color: 'black'
-
emissive: 'purple'
-
shininess: 0
-
-
-
- -

MeshPhongMaterialMeshBasicMaterialMeshLambertMaterial을 -구현할 수 있는데 3가지로 분리해 놓았을까요? 이미 감을 잡으셨겠지만, 재질이 -정교할수록 GPU의 부담이 커지기 때문입니다. GPU 성능이 낮은 저사양 기기에서는 -덜 정교한 재질을 씀으로써 GPU의 부담을 줄일 수 있죠. 또한 복잡한 표현이 필요 -없다면 더 간단한 재질을, 광원 효과가 아예 필요 없다면 MeshBasicMaterial을 -사용하는 것이 좋습니다.

-

MeshToonMaterialMeshPhongMaterial과 유사하나, 큰 차이점이 하나 있습니다. -부드럽게 쉐이딩(shading)하는 대신, MeshToonMaterial은 그라디언트 맵(gradient map)을 -사용합니다. 기본적으로 MeshToonMaterial은 처음 70%까지는 밝고 다음 100%까지는 -어두운 그라디언트 맵을 사용하나, 그라디언트 맵을 직접 지정해 줄 수도 있죠. MeshToonMaterial로 -만든 물체는 투톤을 띄어 카툰 느낌을 줍니다.

-
-
-
- -

다음으로 살펴 볼 두 재질은 물리 기반 렌더링을 위한 재질입니다. 물리 기반 렌더링(Physically Based Rendering)은 -줄여서 PBR이라고 하죠.

-

위에서 살펴본 재질들은 재질을 3D처럼 보이게 하기 위해 간단한 수학을 사용하나, -이는 실제 세계와는 다릅니다. 이 두 가지 PBR 재질은 실제 세계에서처럼 물체를 -구현하기 위해 훨씬 복잡한 수학을 사용하죠.

-

첫 번째는 MeshStandardMaterial입니다.MeshPhongMaterialMeshStandardMaterial의 -가장 큰 차이점은 사용하는 속성이 다르다는 점입니다. MeshPhongMaterial은 -shininess를 사용하지만, MeshStandardMaterialroughnessmetalness -두 가지 속성을 사용합니다.

-

roughnessroughness는 -0부터 1까지의 숫자값으로, shininess의 반대입니다. 높은 roughness를 -가진 물체, 예를 들어 야구공은 빛 반사가 거의 없지만, 반대로 낮은 -roughness를 가진 물체, 당구공은 매우 번들번들하죠.

-

metalness는 얼마나 금속성입니다. -얼마나 금속 재질에 가까울 것인가로써, 0은 아예 금속 같지 않은 것이고, -1은 완전히 금속처럼 보이는 것을 의미합니다.

-

아래의 예제는 MeshStandardMaterialroughness를 왼쪽에서 오른쪽으로 -커지게(0 -> 1), metalness를 위에서 아래로 커지게(0 -> 1) 한 것입니다.

-
- -

MeshPhysicalMaterialMeshStandardMaterial과 기본적으로 같지만, -0부터 1까지의 clearcoat 속성으로 표면에 코팅 세기를 설정하고, -clearcoatRoughness 속성으로 코팅의 거침 정도를 설정한다는 점이 다릅니다.

-

아래는 위의 예제와 마찬가지로 roughnessmetalness 속성을 주고 -clearcoat 속성과 clearcoatRoughness 속성을 조정할 수 있도록 한 예제입니다.

-
- -

여태까지 살펴본 Three.js의 기본 재질을 성능이 빠른 것부터 나열하면,

-

MeshBasicMaterialMeshLambertMaterialMeshPhongMaterial ➡ -MeshStandardMaterialMeshPhysicalMaterial

-

입니다. 성능 부담이 -클수록 더 현실적인 결과물을 얻을 수 있지만, 저사양 지원을 위해서는 코드 -최적화에 그만큼 신경을 써야 합니다.

-

또 특수한 경우에 사용하는 세 가지 재질이 있습니다. ShadowMaterial은 -그림자로부터 데이터를 가져오는 데 사용하죠. 아직 그림자에 대해서는 다루지 -않았지만, 그림자에 대해서 살펴볼 때 씬 뒤에서 무슨 일이 일어나는지 -자세히 살펴볼 것입니다.

-

MeshDepthMaterial은 각 픽셀의 깊이를 렌더링합니다. 카메라의 마이너스 -near에 위치한 픽셀은 0으로, 마이너스 -far에 위치한 픽셀은 1로 렌더링하죠. 이 재질을 -사용해 구현할 수 있는 것에 대해서는 다른 글에서 나중에 다뤄보겠습니다.

-
-
-
-
-
- -

MeshNormalMaterialgeometry법선(normals)을 보여줍니다. -법선이란 특정한 삼각형이나 픽셀이 가리키는 방향을 의미하죠. -MeshNormalMaterial은 카메라를 기반으로 법선을 렌더링합니다. -x축은 빨강, -y축은 초록, -z축은 파랑이죠. -다시 말해 오른쪽 면은 분홍, -왼쪽 면은 청녹, -위쪽 면은 청녹, -아래쪽 면은 자주, -정면은 연보라가 됩니다.

-
-
-
-
-
- -

ShaderMaterialRawShaderMaterial은 재질을 커스텀할 때 사용합니다. -둘의 차이점은 ShaderMaterial은 Three.js의 쉐이더 시스템을 이용하고, -RawShaderMaterial은 아예 Three.js의 도움을 받지 않는다는 점이죠. 둘 -다 짧게 다루기는 어려운 주제로, 나중에 상세하게 다루겠습니다.

-

재질 속성(properties)의 대부분은 Material 클래스에 의해 정의됩니다. -자세한 건 공식 문서를 참고하되, 여기서는 자주 사용하는 -두 가지 속성만 살펴보도록 하죠.

-

flatShading: -물체를 각지게(faceted) 표현할지의 여부입니다. 기본값은 false.

-
-
-
-
flatShading: false
-
-
-
-
flatShading: true
-
-
- -

side: -어떤 면을 렌더링할지의 여부입니다. 기본값은 THREE.FrontSide(앞면). -다른 값으로는 THREE.BackSide(뒷면)THREE.DoubleSide(양면)를 -지정할 수 있습니다. 3D로 렌더링한 물체는 대부분 불투명한 고체이기에, -뒷면(고체의 안쪽면)은 굳이 렌더링할 필요가 없습니다. side 속성을 -별도로 지정하는 경우는 면이나 비-고체 등 뒷면을 렌더링해야 할 경우 -뿐이죠.

-

아래는 각각 THREE.FrontSideTHREE.DoubleSide를 이용해 6개의 -면을 렌더링한 것입니다.

-
-
-
-
side: THREE.FrontSide
-
-
-
-
side: THREE.DoubleSide
-
-
- -

실제 프로젝트에서 재질을 다룰 때는 고려해야 할 것이 훨씬 많습니다. -이 장에서는 아주 기본적인 것만 살펴보았을 뿐이고, 제대로 사용하기 -위해서는 알아야 할 것들이 훨씬 더 많죠. 예를 들어 나중에 살펴볼 -텍스처(textures, 질감)만 추가해도 경우의 수가 엄청나게 늘어납니다. -말 나온 김에 바로 텍스처를 살펴보는 것도 좋지만, 다음 장에서는 잠시 -쉬어가는 의미로 Three.js 개발 환경 구성하기에 -대해 알아보겠습니다.

-
-

material.needsUpdate

-

-이 속성은 사용할 일이 그다지 많진 않으니 참고로 알아두시기 바랍니다. -Three.js는 재질을 사용할 때-해당 재질을 사용하는 물체를 렌더링할 때- -재질의 설정을 적용합니다. 재질을 바꿀 때는 많은 자원이 들어가기에, -Three.js는 기본적으로 처음 한 번만 재질의 설정을 적용합니다. 만약 -재질의 속성(properties)을 런타임에 바꿔야 할 경우, material.needsUpdate = true를 -설정해 Three.js가 변경사항을 반영하도록 해야 하죠. 대표적으로 -needsUpdate를 사용해야 하는 경우는 다음과 같습니다. - -

-
    -
  • flatShading 속성을 변경할 때
  • -
  • 텍스처를 추가/제거할 때 -

    - 단순히 텍스처를 변경할 때는 상관없으나, 아예 텍스처를 사용하지 않다가 - 텍스처를 추가하는 경우, 또는 텍스처를 사용하다가 텍스처를 제거하는 경우 - needsUpdate = true를 설정해주어야 합니다. -

    -

    - 하지만 텍스처를 제거하는 경우, 대게 1x1 하얀색 픽셀 텍스처로 변경하는 게 - 낫습니다. -

  • -
-

-위에서 언급했듯, 대부분의 앱은 이런 경우를 고려할 일이 거의 없습니다. -flatShade 속성을 변경하는 경우는 흔치 않고, 텍스처를 변경하는 -경우는 있어도 물체의 속성이나 색상을 지정하지 않았다가 추가하는 경우는 흔치 않기 -때문입니다. -

-
- -

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/multiple-scenes.html b/manual/ko/multiple-scenes.html deleted file mode 100644 index 3e8a3b4e2b2d7d..00000000000000 --- a/manual/ko/multiple-scenes.html +++ /dev/null @@ -1,649 +0,0 @@ - - - 로 캔버스, 장면 여러 개 만들기 - - - - - - - - - - - - - - -
-
-

로 캔버스, 장면 여러 개 만들기

-
-
-
-

사람들이 자주 하는 질문 중 하나는 Three.js로 여러 개의 캔버스(canvas)를 렌더링하려면 -어떻게 해야 하나요?"입니다. 쇼핑몰 사이트나 3D 도표가 여러 개 있는 웹 페이지를 -제작한다고 해봅시다. 얼핏 그리 어려울 건 없어 보입니다. 그냥 도표가 들어갈 곳마다 -각각 캔버스를 만들고, 각 캔버스마다 Renderer를 생성하면 되지 않을까요?

-

하지만 이 방법을 적용하자마자 문제가 생깁니다.

-
    -
  1. 브라우저의 WebGL 컨텍스트(context)는 제한적이다.

    -

    일반적으로 약 8개가 최대입니다. 9번째 컨텍스트를 만들면 제일 처음에 만들었던 - 컨텍스트가 사라지죠.

    -
  2. -
  3. WebGL 자원은 컨텍스트끼리 공유할 수 없다.

    -

    10MB짜리 모델을 캔버스 두 개에서 사용하려면 모델을 각각 총 두 번 로드해야 하고, - 원래의 두 배인 20MB의 자원을 사용한다는 의미입니다. 컨텍스트끼리는 어떤 것도 공유할 - 수 없죠. 또한 초기화도 두 번, 쉐이더 컴파일도 두 번, 같은 동작은 모두 두 번씩 - 실행해야 합니다. 캔버스의 개수가 많아질수록 성능에 문제가 생기겠죠.

    -
  4. -
-

그렇다면 어떻게 해야 할까요?

-

방법 중 하나는 캔버스 하나로 화면 전체를 채우고, 각 "가상" 캔버스를 대신할 HTML 요소(element)를 -두는 겁니다. Renderer는 하나만 만들되 가상 캔버스에 각각 Scene을 만드는 거죠. 그리고 -가상 HTML 요소의 좌표를 계산해 요소가 화면에 보인다면 Three.js가 해당 장면(scene)을 가상 -요소의 좌표에 맞춰 렌더링하도록 합니다.

-

이 방법은 캔버스를 하나만 사용하므로 위 1번과 2번 문제 모두 해결할 수 있습니다. 컨텍스트를 -하나만 사용하니 WebGL 컨텍스트 제한을 걱정할 일도 없고, 자원을 몇 배씩 더 사용할 일도 없죠.

-

2개의 장면만 만들어 간단히 테스트를 해보겠습니다. 먼저 HTML을 작성합니다.

-
<canvas id="c"></canvas>
-<p>
-  <span id="box" class="diagram left"></span>
-  I love boxes. Presents come in boxes.
-  When I find a new box I'm always excited to find out what's inside.
-</p>
-<p>
-  <span id="pyramid" class="diagram right"></span>
-  When I was a kid I dreamed of going on an expedition inside a pyramid
-  and finding a undiscovered tomb full of mummies and treasure.
-</p>
-
-

다음으로 CSS를 작성합니다.

-
#c {
-  position: fixed;
-  left: 0;
-  top: 0;
-  width: 100%;
-  height: 100%;
-  display: block;
-  z-index: -1;
-}
-.diagram {
-  display: inline-block;
-  width: 5em;
-  height: 3em;
-  border: 1px solid black;
-}
-.left {
-  float: left;
-  margin-right: .25em;
-}
-.right {
-  float: right;
-  margin-left: .25em;
-}
-
-

캔버스가 화면 전체를 채우도록 하고 z-index를 -1로 설정해 다른 요소 뒤로 가도록 했습니다. -가상 요소에 컨텐츠가 없어 크기가 0이니 별도의 width와 height도 지정해줬습니다.

-

이제 각각의 카메라와 조명이 있는 장면 2개를 만듭니다. 하나에는 정육면체, 다른 하나에는 -다이아몬드 모양을 넣을 겁니다.

-
function makeScene(elem) {
-  const scene = new THREE.Scene();
-
-  const fov = 45;
-  const aspect = 2;  // 캔버스 기본값
-  const near = 0.1;
-  const far = 5;
-  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-  camera.position.z = 2;
-  camera.position.set(0, 1, 2);
-  camera.lookAt(0, 0, 0);
-
-  {
-    const color = 0xFFFFFF;
-    const intensity = 1;
-    const light = new THREE.DirectionalLight(color, intensity);
-    light.position.set(-1, 2, 4);
-    scene.add(light);
-  }
-
-  return { scene, camera, elem };
-}
-
-function setupScene1() {
-  const sceneInfo = makeScene(document.querySelector('#box'));
-  const geometry = new THREE.BoxGeometry(1, 1, 1);
-  const material = new THREE.MeshPhongMaterial({color: 'red'});
-  const mesh = new THREE.Mesh(geometry, material);
-  sceneInfo.scene.add(mesh);
-  sceneInfo.mesh = mesh;
-  return sceneInfo;
-}
-
-function setupScene2() {
-  const sceneInfo = makeScene(document.querySelector('#pyramid'));
-  const radius = .8;
-  const widthSegments = 4;
-  const heightSegments = 2;
-  const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
-  const material = new THREE.MeshPhongMaterial({
-    color: 'blue',
-    flatShading: true,
-  });
-  const mesh = new THREE.Mesh(geometry, material);
-  sceneInfo.scene.add(mesh);
-  sceneInfo.mesh = mesh;
-  return sceneInfo;
-}
-
-const sceneInfo1 = setupScene1();
-const sceneInfo2 = setupScene2();
-
-

이제 각 요소가 화면에 보일 때만 장면을 렌더링할 함수를 만듭니다. Renderer.setScissorTest를 -호출해 가위(scissor) 테스트를 활성화하면 Three.js가 캔버스의 특정 부분만 렌더링하도록 -할 수 있습니다. 그리고 Renderer.setScissor로 가위를 설정한 뒤 Renderer.setViewport로 -장면의 좌표를 설정합니다.

-
function renderSceneInfo(sceneInfo) {
-  const { scene, camera, elem } = sceneInfo;
-
-  // 해당 요소의 화면 대비 좌표를 가져옵니다
-  const { left, right, top, bottom, width, height } =
-      elem.getBoundingClientRect();
-
-  const isOffscreen =
-      bottom < 0 ||
-      top > renderer.domElement.clientHeight ||
-      right < 0 ||
-      left > renderer.domElement.clientWidth;
-
-  if (isOffscreen) {
-    return;
-  }
-
-  camera.aspect = width / height;
-  camera.updateProjectionMatrix();
-
-  const positiveYUpBottom = canvasRect.height - bottom;
-  renderer.setScissor(left, positiveYUpBottom, width, height);
-  renderer.setViewport(left, positiveYUpBottom, width, height);
-
-  renderer.render(scene, camera);
-}
-
-

다음으로 render 함수 안에서 먼저 캔버스 전체를 비운 뒤 각 장면을 렌더링합니다.

-
function render(time) {
-  time *= 0.001;
-
-  resizeRendererToDisplaySize(renderer);
-
-  renderer.setScissorTest(false);
-  renderer.clear(true, true);
-  renderer.setScissorTest(true);
-
-  sceneInfo1.mesh.rotation.y = time * .1;
-  sceneInfo2.mesh.rotation.y = time * .1;
-
-  renderSceneInfo(sceneInfo1);
-  renderSceneInfo(sceneInfo2);
-
-  requestAnimationFrame(render);
-}
-
-

결과를 확인해볼까요?

-

- -

-

첫 번째 <span> 요소가 있는 곳에는 빨간 정육면체가, 두 번째 <span> 요소가 있는 곳에는 -파란 다이아몬드가 보일 겁니다.

-

동기화하기

-

위 코드는 나쁘지 않지만 작은 문제가 있습니다. 복잡한 장면 등 무슨 이유라도 렌더링하는 -데 시간이 오래 걸린다면, 장면의 좌표는 페이지의 다른 컨텐츠에 비해 더디게 내려올 겁니다.

-

각 가상 요소에 테두리를 넣고

-
.diagram {
-  display: inline-block;
-  width: 5em;
-  height: 3em;
-+  border: 1px solid black;
-}
-
-

각 장면에 배경색도 넣어줍니다.

-
const scene = new THREE.Scene();
-+scene.background = new THREE.Color('red');
-
-

그런 다음 빠르게 스크롤을 위아래로 반복해보면 -문제가 보일겁니다. 아래는 스크롤 애니메이션 캡쳐본의 속도를 10배 낮춘 예시입니다.

-
- -

추가로 처리해줘야 할 것이 있긴 하지만, 캔버스의 CSS를 position: fixed에서 position: absolute로 -바꿔 문제를 해결할 수 있습니다.

-
#c {
--  position: fixed;
-+  position: absolute;
-
-

그리고 페이지 스크롤에 상관 없이 캔버스가 항상 화면의 상단에 위치할 수 있도록 캔버스에 -transform 스타일을 지정해줍니다.

-
function render(time) {
-  ...
-
-  const transform = `translateY(${ window.scrollY }px)`;
-  renderer.domElement.style.transform = transform;
-
-

캔버스에 position: fixed를 적용하면 캔버스는 스크롤의 영향을 받지 않습니다. position: absolute를 -적용하면 렌더링하는 데 시간이 걸리더라도 일단 다른 페이지와 같이 스크롤이 되겠죠. 그리고 -렌더링하기 전에 캔버스를 다시 움직여 화면 전체에 맞춘 뒤 캔버스를 렌더링하는 겁니다. 이러면 -화면의 가장자리에 살짝 렌더링되지 않은 부분이 보일 수는 있어도 나머지 페이지에 있는 요소는 -버벅이지 않고 제자리에 있을 겁니다. 아래는 해당 코드를 적용한 화면의 캡쳐본을 아까와 마찬가지로 -10배 느리게 만든 것입니다.

-
- -

확장하기 쉽게 만들기

-

여러 장면을 구현했으니 이제 이 예제를 좀 더 확장하기 쉽게 만들어보겠습니다.

-

먼저 기존처럼 캔버스 전체를 렌더링하는 render 함수를 두고, 각 장면에 해당하는 가상 요소, -해당 장면을 렌더링하는 함수로 이루어진 객체의 배열을 만듭니다. render 함수에서 가상 요소가 -화면에 보이는지 확인한 뒤, 가상 요소가 화면에 보인다면 상응하는 렌더링 함수를 호출합니다. 이러면 -확장성은 물론 각 장면의 렌더링 함수를 작성할 때도 전체를 신경쓸 필요가 없죠.

-

아래는 전체를 담당하는 render 함수입니다.

-
const sceneElements = [];
-function addScene(elem, fn) {
-  sceneElements.push({ elem, fn });
-}
-
-function render(time) {
-  time *= 0.001;
-
-  resizeRendererToDisplaySize(renderer);
-
-  renderer.setScissorTest(false);
-  renderer.setClearColor(clearColor, 0);
-  renderer.clear(true, true);
-  renderer.setScissorTest(true);
-
-  const transform = `translateY(${ window.scrollY }px)`;
-  renderer.domElement.style.transform = transform;
-
-  for (const { elem, fn } of sceneElements) {
-    // 해당 요소의 화면 대비 좌표를 가져옵니다
-    const rect = elem.getBoundingClientRect();
-    const {left, right, top, bottom, width, height} = rect;
-
-    const isOffscreen =
-        bottom < 0 ||
-        top > renderer.domElement.clientHeight ||
-        right < 0 ||
-        left > renderer.domElement.clientWidth;
-
-    if (!isOffscreen) {
-      const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
-      renderer.setScissor(left, positiveYUpBottom, width, height);
-      renderer.setViewport(left, positiveYUpBottom, width, height);
-
-      fn(time, rect);
-    }
-  }
-
-  requestAnimationFrame(render);
-}
-
-

render 함수는 elemfn 속성의 객체로 이루어진 sceneElements 배열을 순회합니다.

-

그리고 각 요소가 화면에 보이는지 확인하고, 화면에 보인다면 fn에 해당 장면이 들어가야할 -사각 좌표와 현재 시간값을 넘겨주어 호출합니다.

-

이제 각 장면을 만들고 상응하는 요소와 렌더링 함수를 추가합니다.

-
{
-  const elem = document.querySelector('#box');
-  const { scene, camera } = makeScene();
-  const geometry = new THREE.BoxGeometry(1, 1, 1);
-  const material = new THREE.MeshPhongMaterial({ color: 'red' });
-  const mesh = new THREE.Mesh(geometry, material);
-  scene.add(mesh);
-  addScene(elem, (time, rect) => {
-    camera.aspect = rect.width / rect.height;
-    camera.updateProjectionMatrix();
-    mesh.rotation.y = time * .1;
-    renderer.render(scene, camera);
-  });
-}
-
-{
-  const elem = document.querySelector('#pyramid');
-  const { scene, camera } = makeScene();
-  const radius = .8;
-  const widthSegments = 4;
-  const heightSegments = 2;
-  const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
-  const material = new THREE.MeshPhongMaterial({
-    color: 'blue',
-    flatShading: true,
-  });
-  const mesh = new THREE.Mesh(geometry, material);
-  scene.add(mesh);
-  addScene(elem, (time, rect) => {
-    camera.aspect = rect.width / rect.height;
-    camera.updateProjectionMatrix();
-    mesh.rotation.y = time * .1;
-    renderer.render(scene, camera);
-  });
-}
-
-

sceneInfo1, sceneInfo2는 더 이상 필요 없으니 제거합니다. 대신 각 mesh의 회전은 해당 -장면에서 처리해야 합니다.

-

- -

-

HTML Dataset 사용하기

-

HTML의 dataset을 -이용하면 좀 더 확장하기 쉬운 환경을 만들 수 있습니다. id="..." 대신 data-diagram="..."을 -이용해 데이터를 직접 HTML 요소에 지정하는 거죠.

-
<canvas id="c"></canvas>
-<p>
--  <span id="box" class="diagram left"></span>
-+  <span data-diagram="box" class="left"></span>
-  I love boxes. Presents come in boxes.
-  When I find a new box I'm always excited to find out what's inside.
-</p>
-<p>
--  <span id="pyramid" class="diagram left"></span>
-+  <span data-diagram="pyramid" class="right"></span>
-  When I was a kid I dreamed of going on an expedition inside a pyramid
-  and finding a undiscovered tomb full of mummies and treasure.
-</p>
-
-

요소의 id를 제거했으니 CSS 셀렉터도 다음처럼 바꾸어야 합니다.

-
-.diagram
-+*[data-diagram] {
-  display: inline-block;
-  width: 5em;
-  height: 3em;
-}
-
-

또한 각 장면을 만드는 코드를 scene initialization functions라는 맵으로 만듭니다. -이 맵은 키값에 대응하는 장면 렌더링 함수를 반환할 겁니다.

-
const sceneInitFunctionsByName = {
-  'box': () => {
-    const { scene, camera } = makeScene();
-    const geometry = new THREE.BoxGeometry(1, 1, 1);
-    const material = new THREE.MeshPhongMaterial({color: 'red'});
-    const mesh = new THREE.Mesh(geometry, material);
-    scene.add(mesh);
-    return (time, rect) => {
-      mesh.rotation.y = time * .1;
-      camera.aspect = rect.width / rect.height;
-      camera.updateProjectionMatrix();
-      renderer.render(scene, camera);
-    };
-  },
-  'pyramid': () => {
-    const { scene, camera } = makeScene();
-    const radius = .8;
-    const widthSegments = 4;
-    const heightSegments = 2;
-    const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
-    const material = new THREE.MeshPhongMaterial({
-      color: 'blue',
-      flatShading: true,
-    });
-    const mesh = new THREE.Mesh(geometry, material);
-    scene.add(mesh);
-    return (time, rect) => {
-      mesh.rotation.y = time * .1;
-      camera.aspect = rect.width / rect.height;
-      camera.updateProjectionMatrix();
-      renderer.render(scene, camera);
-    };
-  },
-};
-
-

그리고 querySelectorAll로 가상 요소를 전부 불러와 해당 요소에 상응하는 렌더링 함수를 -실행합니다.

-
document.querySelectorAll('[data-diagram]').forEach((elem) => {
-  const sceneName = elem.dataset.diagram;
-  const sceneInitFunction = sceneInitFunctionsByName[sceneName];
-  const sceneRenderFunction = sceneInitFunction(elem);
-  addScene(elem, sceneRenderFunction);
-});
-
-

이제 코드를 확장하기가 한결 편해졌습니다.

-

-

각 요소에 액션 추가하기

-

사용자 액션, 예를 들어 TrackballControls를 추가하는 건 아주 간단합니다. 먼저 스크립트를 -불러옵니다.

-
import { TrackballControls } from 'three/addons/controls/TrackballControls.js';
-
-

그리고 각 장면에 대응하는 요소에 TrackballControls를 추가합니다.

-
-function makeScene() {
-+function makeScene(elem) {
-  const scene = new THREE.Scene();
-
-  const fov = 45;
-  const aspect = 2;  // 캔버스 기본값
-  const near = 0.1;
-  const far = 5;
-  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-  camera.position.set(0, 1, 2);
-  camera.lookAt(0, 0, 0);
-+  scene.add(camera);
-
-+  const controls = new TrackballControls(camera, elem);
-+  controls.noZoom = true;
-+  controls.noPan = true;
-
-  {
-    const color = 0xFFFFFF;
-    const intensity = 1;
-    const light = new THREE.DirectionalLight(color, intensity);
-    light.position.set(-1, 2, 4);
--    scene.add(light);
-+    camera.add(light);
-  }
-
--  return { scene, camera };
-+ return { scene, camera, controls };
-}
-
-

위 코드에서는 카메라를 장면에 추가하고, 카메라에 조명을 추가했습니다. 이러면 조명이 카메라를 -따라다니겠죠. TrackballControls는 카메라를 조정하기 때문에 이렇게 해야 빛이 계속 우리가 -바라보는 방향에서 나갑니다.

-

또한 컨트롤을 렌더링 함수에서 업데이트해줘야 합니다.

-
const sceneInitFunctionsByName = {
-- 'box': () => {
--    const {scene, camera} = makeScene();
-+ 'box': (elem) => {
-+    const { scene, camera, controls } = makeScene(elem);
-    const geometry = new THREE.BoxGeometry(1, 1, 1);
-    const material = new THREE.MeshPhongMaterial({color: 'red'});
-    const mesh = new THREE.Mesh(geometry, material);
-    scene.add(mesh);
-    return (time, rect) => {
-      mesh.rotation.y = time * .1;
-      camera.aspect = rect.width / rect.height;
-      camera.updateProjectionMatrix();
-+      controls.handleResize();
-+      controls.update();
-      renderer.render(scene, camera);
-    };
-  },
--  'pyramid': () => {
--    const { scene, camera } = makeScene();
-+  'pyramid': (elem) => {
-+    const { scene, camera, controls } = makeScene(elem);
-    const radius = .8;
-    const widthSegments = 4;
-    const heightSegments = 2;
-    const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
-    const material = new THREE.MeshPhongMaterial({
-      color: 'blue',
-      flatShading: true,
-    });
-    const mesh = new THREE.Mesh(geometry, material);
-    scene.add(mesh);
-    return (time, rect) => {
-      mesh.rotation.y = time * .1;
-      camera.aspect = rect.width / rect.height;
-      camera.updateProjectionMatrix();
-+      controls.handleResize();
-+      controls.update();
-      renderer.render(scene, camera);
-    };
-  },
-};
-
-

이제 각 물체를 자유롭게 회전시킬 수 있습니다.

-

- -

-

이 기법은 이 사이트 전체에 사용한 기법입니다. 원시 모델에 관한 글과 -재질에 관한 글에서 다양한 예시를 보여주기 위해 사용했죠.

-

다른 방법으로는 화면 밖의 캔버스에서 장면을 렌더링해 각 요소에 2D 캔버스 형태로 넘겨주는 -방법이 있습니다. 이 방법의 장점은 각 영역을 어떻게 분리할지 고민하지 않아도 된다는 것이죠. -위에서 살펴본 방법은 캔버스를 화면 전체의 배경으로 써야 하지만, 이 방법은 일반 HTML 형태로 -사용할 수 있습니다.

-

하지만 이 방법은 각 영역을 복사하는 것이기에 성능이 더 느립니다. 얼마나 느릴지는 브라우저와 -GPU 성능에 따라 다르죠.

-

바꿔야 하는 건 생각보다 많지 않습니다.

-

먼저 배경에서 캔버스 요소를 제거합니다.

-
<body>
--  <canvas id="c"></canvas>
-  ...
-</body>
-
-

CSS도 바꿔줍니다.

-
-#c {
--  position: absolute;
--  left: 0;
--  top: 0;
--  width: 100%;
--  height: 100%;
--  display: block;
--  z-index: -1;
--}
-canvas {
-  width: 100%;
-  height: 100%;
-  display: block;
-}
-*[data-diagram] {
-  display: inline-block;
-  width: 5em;
-  height: 3em;
-}
-
-

캔버스 요소가 부모에 꽉 차도록 변경했습니다.

-

이제 자바스크립트를 변경해봅시다. 먼저 캔버스를 참조할 필요가 없으니 대신 캔버스 요소를 -새로 만듭니다. 또한 가위 테스트를 처음에 활성화합니다.

-
function main() {
--  const canvas = document.querySelector('#c');
-+  const canvas = document.createElement('canvas');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas, alpha: true});
-+  renderer.setScissorTest(true);
-
-  ...
-
-

다음으로 각 장면에 2D 렌더링 컨텍스트를 생성하고 장면에 대응하는 요소에 캔버스를 추가합니다.

-
const sceneElements = [];
-function addScene(elem, fn) {
-+  const ctx = document.createElement('canvas').getContext('2d');
-+  elem.appendChild(ctx.canvas);
--  sceneElements.push({ elem, fn });
-+  sceneElements.push({ elem, ctx, fn });
-}
-
-

만약 렌더링 시 렌더링용 캔버스의 크기가 장면의 크기보다 작을 경우, 렌더링용 캔버스의 크기를 -키웁니다. 또한 2D 캔버스의 크기가 부모 요소와 다르다면 2D 캔버스의 크기를 조정합니다. 마지막으로 -가위와 화면을 설정하고, 해당 장면을 렌더링한 뒤, 요소의 캔버스로 렌더링 결과물을 복사합니다.

-
function render(time) {
-  time *= 0.001;
-
--  resizeRendererToDisplaySize(renderer);
--
--  renderer.setScissorTest(false);
--  renderer.setClearColor(clearColor, 0);
--  renderer.clear(true, true);
--  renderer.setScissorTest(true);
--
--  const transform = `translateY(${ window.scrollY }px)`;
--  renderer.domElement.style.transform = transform;
-
--  for (const { elem, fn } of sceneElements) {
-+  for (const { elem, fn, ctx } of sceneElements) {
-    // 해당 요소의 화면 대비 좌표를 가져옵니다
-    const rect = elem.getBoundingClientRect();
-    const { left, right, top, bottom, width, height } = rect;
-+    const rendererCanvas = renderer.domElement;
-
-    const isOffscreen =
-        bottom < 0 ||
--        top > renderer.domElement.clientHeight ||
-+        top > window.innerHeight ||
-        right < 0 ||
--        left > renderer.domElement.clientWidth;
-+        left > window.innerWidth;
-
-    if (!isOffscreen) {
--      const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
--      renderer.setScissor(left, positiveYUpBottom, width, height);
--      renderer.setViewport(left, positiveYUpBottom, width, height);
-
-+      // 렌더링용 캔버스 크기 조정
-+      if (rendererCanvas.width < width || rendererCanvas.height < height) {
-+        renderer.setSize(width, height, false);
-+      }
-+
-+      // 2D 캔버스의 크기가 요소의 크기와 같도록 조정
-+      if (ctx.canvas.width !== width || ctx.canvas.height !== height) {
-+        ctx.canvas.width = width;
-+        ctx.canvas.height = height;
-+      }
-+
-+      renderer.setScissor(0, 0, width, height);
-+      renderer.setViewport(0, 0, width, height);
-
-      fn(time, rect);
-
-+      // 렌더링된 장면을 2D 캔버스에 복사
-+      ctx.globalCompositeOperation = 'copy';
-+      ctx.drawImage(
-+          rendererCanvas,
-+          0, rendererCanvas.height - height, width, height,  // 원본 사각 좌표
-+          0, 0, width, height);                              // 결과물 사각 좌표
-    }
-  }
-
-  requestAnimationFrame(render);
-}
-
-

결과물은 위와 다르지 않습니다.

-

- -

-

이 기법의 다른 장점은 OffscreenCanvas -웹 워커를 이용해 이 기능을 별도 스레드에서 구현할 수 있다는 겁니다. 하지만 아쉽게도 -2020년 7월을 기준으로 OffscreenCanvas는 아직 크로미움 기반 브라우저에서만 지원합니다.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/offscreencanvas.html b/manual/ko/offscreencanvas.html deleted file mode 100644 index 3b3a64462d0c3e..00000000000000 --- a/manual/ko/offscreencanvas.html +++ /dev/null @@ -1,977 +0,0 @@ - - - OffscreenCanvas - - - - - - - - - - - - - - -
-
-

OffscreenCanvas

-
-
-
-

OffscreenCanvas는 비교적 최근 도입된 브라우저 API로 아직 크로미움 기반 브라우저에서만 사용가능하지만, 갈수록 대부분의 브라우저에서 이 API를 사용할 수 있을 겁니다. OffscreenCanvas를 이용하면 웹 워커(Web Worker)에서 캔버스를 렌더링해 복잡한 3D 장면 등의 무거운 작업을 별도 프로세스에서 처리할 수 있습니다. 이러면 무거운 작업을 처리할 때 브라우저가 덜 버벅이도록 할 수 있죠. 또한 데이터도 워커에서 불러와 처리하므로 페이지 초기 로드 시 버벅임을 훨씬 줄일 수 있습니다.

-

사용법은 꽤나 직관적입니다. 먼저 반응형 디자인에 관한 글에서 썼던 예제를 가져오도록 하죠.

-

이 사이트 대부분의 예제는 스크립트를 해당 HTML 파일에 인라인으로 작성했습니다. 반면에 워커는 일반적으로 별도의 스크립트 파일로 분리해 작성합니다.

-

이 글에서는 offscreencanvas-cubes.js라는 별도 파일을 만들어 반응형 디자인에서 가져온 예제의 자바스크립트 코드를 전부 복사해 넣을 겁니다. 그런 다음 바꿔야할 부분을 바꿔보도록 하죠.

-

하지만 여전히 HTML 파일에 약간의 자바스크립트 코드가 필요합니다. 캔버스 요소를 참조하고 canvas.transferControlToOffscreen 메서드를 호출해 캔버스의 제어권을 offscreen에 넘겨줍니다.

-
function main() {
-  const canvas = document.querySelector('#c');
-  const offscreen = canvas.transferControlToOffscreen();
-
-  ...
-
-

그리고 new Worker(워커 스크립트 경로, { type: 'module' })로 워커를 생성한 뒤, 워커에 offscreen 객체를 넘깁니다.

-
function main() {
-  const canvas = document.querySelector('#c');
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-cubes.js', { type: 'module' });
-  worker.postMessage({ type: 'main', canvas: offscreen }, [ offscreen ]);
-}
-main();
-
-

스크립트를 따로 써야 하는 이유는 워커 안에서는 DOM에 접근할 수 없기 때문입니다. HTML 요소를 참조하거나 DOM 요소의 이벤트를 받을 수도 없죠. 일반적으로 메시지 이벤트를 통해서만 다른 스크립트와 통신할 수 있습니다.

-

워커에 메시지를 보내려면 worker.postMessage에 하나 또는 두 개의 인자를 넘겨주어 호출하면 됩니다. 첫 번째 인자는 워커에 전달할 객체로, 이 객체는 그대로 전달되지 않고 복사됩니다. 두 번째 인자는 옵션으로 첫 번째 인자 중 그대로 전달하기 원하는 객체를 배열로 지정합니다. 여기에 지정한 객체는 복사되지 않죠. 그대로 워커에 전달되지만 객체를 보낸 스크립트에서는 더 이상 사용이 불가능해집니다. 이것도 모든 객체를 전달할 수 있는 게 아니라 특정 타입의 객체만을 전달할 수 있죠. 당연하게도 이 중에는 OffscreenCanvas도 있습니다. 정리하자면 offscreen 객체를 전달하고 나면 이 객체는 이 스크립트에서 더 이상 쓸모가 없어집니다.

-

워커의 message 이벤트를 이용하면 메시지를 받을 수 있습니다. postMessage에서 넘긴 객체는 event.data에 담겨 리스너에 전달되죠. 아까 위 코드에서는 type: 'main' 속성을 객체에 선언해 워커에 넘겨줬습니다. 이 type 속성은 브라우저의 메인 스레드에서는 쓸 일이 없는 값으로, 워커 내에 다른 함수를 호출하는 키값으로 사용할 겁니다. 이러면 메인 스크립트에서 워커 내의 함수를 호출하기가 훨씬 쉬워지겠죠.

-
const handlers = {
-  main,
-};
-
-self.onmessage = function(e) {
-  const fn = handlers[e.data.type];
-  if (typeof fn !== 'function') {
-    throw new Error('no handler for type: ' + e.data.type);
-  }
-  fn(e.data);
-};
-
-

type 값을 통해 호출할 함수를 찾고, 함수가 있다면 메인 스크립트에서 넘어온 data를 인자로 넘겨 호출하도록 했습니다.

-

이제 반응형 디자인에 관한 글에서 가져온 예제의 main 함수를 수정해야 합니다.

-

DOM에서 캔버스에 접근하는 대신 이벤트의 data 속성에서 캔버스 요소를 받도록 합니다.

-
-function main() {
--  const canvas = document.querySelector('#c');
-+function main(data) {
-+  const { canvas } = data;
-  const renderer = new THREE.WebGLRenderer({ canvas });
-
-  ...
-
-

워커에서는 DOM에 접근할 수 없다고 했었죠. 마찬가지로 DOM 속성인 canvas.clientWidthcanvas.clientHeight에도 접근할 수 없습니다. resizeRendererToDisplaySize를 그대로 사용할 수 없는 것이죠.

-
function resizeRendererToDisplaySize(renderer) {
-  const canvas = renderer.domElement;
-  const width = canvas.clientWidth;
-  const height = canvas.clientHeight;
-  const needResize = canvas.width !== width || canvas.height !== height;
-  if (needResize) {
-    renderer.setSize(width, height, false);
-  }
-  return needResize;
-}
-
-

대신 캔버스 크기가 변경될 때마다 워커에 메시지를 보낼 겁니다. 워커에 전역 변수를 하나 생성해 여기에 width와 height 값을 지정하도록 하죠.

-
const state = {
-  width: 300,  // 캔버스 기본값
-  height: 150,  // 캔버스 기본값
-};
-
-

그리고 size라는 함수를 만들어 해당 값을 업데이트하도록 합니다.

-
+function size(data) {
-+  state.width = data.width;
-+  state.height = data.height;
-+}
-
-const handlers = {
-  main,
-+  size,
-};
-
-

resizeRendererToDisplaySizestate.widthstate.height를 쓰도록 변경합니다.

-
function resizeRendererToDisplaySize(renderer) {
-  const canvas = renderer.domElement;
--  const width = canvas.clientWidth;
--  const height = canvas.clientHeight;
-+  const width = state.width;
-+  const height = state.height;
-  const needResize = canvas.width !== width || canvas.height !== height;
-  if (needResize) {
-    renderer.setSize(width, height, false);
-  }
-  return needResize;
-}
-
-

마찬가지로 종횡비를 계산하는 코드도 DOM 속성 대신 state를 쓰도록 변경합니다.

-
function render(time) {
-  time *= 0.001;
-
-  if (resizeRendererToDisplaySize(renderer)) {
--    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-+    camera.aspect = state.width / state.height;
-    camera.updateProjectionMatrix();
-  }
-
-  ...
-
-

메인 스크립트로 돌아와 페이지 크기가 바뀔 때마다 워커의 size 함수를 실행하도록 합니다.

-
const worker = new Worker('offscreencanvas-picking.js', { type: 'module' });
-worker.postMessage({ type: 'main', canvas: offscreen }, [ offscreen ]);
-
-+function sendSize() {
-+  worker.postMessage({
-+    type: 'size',
-+    width: canvas.clientWidth,
-+    height: canvas.clientHeight,
-+  });
-+}
-+
-+window.addEventListener('resize', sendSize);
-+sendSize();
-
-

또한 직접 호출해 최초에 한 번 값을 보내도록 합니다.

-

여러분의 브라우저가 OffscreenCanvas를 완벽히 지원한다면 이제 문제 없이 작동할 겁니다. 그렇게 많이 바꾼 것이 없는데도 말이죠. 혹시 모르니 브라우저가 OffscreenCanvas를 지원하지 않을 경우 에러 메시지를 보여주도록 하겠습니다. 먼저 에러 메시지를 표시할 HTML을 작성합니다.

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="noOffscreenCanvas" style="display:none;">
-+    <div>no OffscreenCanvas support</div>
-+  </div>
-</body>
-
-

간단한 스타일도 넣어주도록 하죠.

-
#noOffscreenCanvas {
-    display: flex;
-    width: 100%;
-    height: 100%;
-    align-items: center;
-    justify-content: center;
-    background: red;
-    color: white;
-}
-
-

그리고 캔버스 요소에 transferControlToOffscreen 메서드가 있는지 확인해 OffscreenCanvas의 지원 여부를 확인합니다.

-
function main() {
-  const canvas = document.querySelector('#c');
-+  if (!canvas.transferControlToOffscreen) {
-+    canvas.style.display = 'none';
-+    document.querySelector('#noOffscreenCanvas').style.display = '';
-+    return;
-+  }
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-picking.js'. { type: 'module' });
-  worker.postMessage({ type: 'main', canvas: offscreen }, [ offscreen ]);
-
-  ...
-
-

브라우저가 OffscreenCanvas를 지원한다면 문제 없이 작동할 겁니다.

-

- -

-

하지만 현재 모든 브라우저가 OffscreenCanvas를 지원하는 것은 아닙니다. OffscreenCanvas를 지원할 경우에만 워커를 사용하도록 하고, 그렇지 않을 경우에는 기존처럼 메인 스크립트에서 렌더링을 처리하도록 하겠습니다.

-
-

OffscreenCanvas를 단순히 페이지를 반응형으로 만드는 데 사용하는 건 의미없어 보일 수 있습니다. 메인 스크립트에서 반응형을 처리할 때보다 워커에서 처리할 때 오히려 작업이 더 많이 들 수 있거든요. 하지만 메인 스크립트만 사용할 때보다 워커를 사용할 때 자원을 더 넉넉하게 활용할 수 있다는 건 분명합니다. 전적으로 여러분이 상황에 따라 결정할 문제라는 것이죠.

-
-

먼저 Three.js 관련 코드를 분리해 워커 관련 코드와 그렇지 않은 코드로 나눠야 합니다. 같은 코드를 메인 스크립트와 워커에서 모두 쓸 수 있도록 말이죠. 아래와 같이 3개의 파일로 나뉠 겁니다.

-
    -
  1. html 파일.

    -

    threejs-offscreencanvas-w-fallback.html

    -
  2. -
  3. three.js 관련 자바스크립트 파일.

    -

    shared-cubes.js

    -
  4. -
  5. 워커용 스크립트

    -

    offscreencanvas-worker-cubes.js

    -
  6. -
-

shared-cubes.jsoffscreencanvas-worker-cubes.js는 단순히 이전 offscreencanvas-cubes.js 파일을 쪼갠 것입니다. 먼저 offscreencanvas-cube.jsshared-cube.js로 옮긴 뒤, 메인 HTML 파일에 이미 main 함수가 있어 main 함수의 이름만 init으로 바꿔야 하죠. 여기에 추가로 initstate 함수를 export 시켜줘야 합니다.

-
import * as THREE from 'three';
-
--const state = {
-+export const state = {
-  width: 300,   // 캔버스 기본값
-  height: 150,  // 캔버스 기본값
-};
-
--function main(data) {
-+export function init(data) {
-  const { canvas } = data;
-  const renderer = new THREE.WebGLRenderer({ canvas });
-
-

그리고 Three.js와 관련 없는 부분을 잘라냅니다.

-
-function size(data) {
--  state.width = data.width;
--  state.height = data.height;
--}
--
--const handlers = {
--  main,
--  size,
--};
--
--self.onmessage = function(e) {
--  const fn = handlers[e.data.type];
--  if (typeof fn !== 'function') {
--    throw new Error('no handler for type: ' + e.data.type);
--  }
--  fn(e.data);
--};
-
-

방금 잘라낸 부분을 offscreencanvas-worker-cubes.js에 붙여넣고, shared-cubes.js를 import 합니다. 또한 main 대신 init을 호출하도록 합니다.

-
import { init, state } from './shared-cubes.js';
-
-function size(data) {
-  state.width = data.width;
-  state.height = data.height;
-}
-
-const handlers = {
--  main,
-+  init,
-  size,
-};
-
-self.onmessage = function(e) {
-  const fn = handlers[e.data.type];
-  if (typeof fn !== 'function') {
-    throw new Error('no handler for type: ' + e.data.type);
-  }
-  fn(e.data);
-};
-
-

메인 페이지에서도 마찬가지로 Three.js와 shared-cubes.js를 추가합니다.

-
<script type="module"></script>
-+import { init, state } from './shared-cubes.js';
-
-

이전에 추가했던 에러 메시지용 HTML과 CSS를 제거합니다.

-
<body>
-  <canvas id="c"></canvas>
--  <div id="noOffscreenCanvas" style="display:none;">
--    <div>no OffscreenCanvas support</div>
--  </div>
-</body>
-
-
-#noOffscreenCanvas {
--    display: flex;
--    width: 100%;
--    height: 100%;
--    align-items: center;
--    justify-content: center;
--    background: red;
--    color: white;
--}
-
-

그리고 OffscreenCanvas의 지원 여부에 따라 다른 함수를 실행하도록 합니다.

-
function main() {
-  const canvas = document.querySelector('#c');
--  if (!canvas.transferControlToOffscreen) {
--    canvas.style.display = 'none';
--    document.querySelector('#noOffscreenCanvas').style.display = '';
--    return;
--  }
--  const offscreen = canvas.transferControlToOffscreen();
--  const worker = new Worker('offscreencanvas-picking.js', { type: 'module' });
--  worker.postMessage({ type: 'main', canvas: offscreen }, [ offscreen ]);
-+  if (canvas.transferControlToOffscreen) {
-+    startWorker(canvas);
-+  } else {
-+    startMainPage(canvas);
-+  }
-  ...
-
-

워커를 만들기 위해 사용했던 코드를 전부 startWorker 함수로 옮깁니다.

-
function startWorker(canvas) {
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-worker-cubes.js', { type: 'module' });
-  worker.postMessage({ type: 'main', canvas: offscreen }, [ offscreen ]);
-
-  function sendSize() {
-    worker.postMessage({
-      type: 'size',
-      width: canvas.clientWidth,
-      height: canvas.clientHeight,
-    });
-  }
-
-  window.addEventListener('resize', sendSize);
-  sendSize();
-
-  console.log('using OffscreenCanvas');
-}
-
-

메시지에 'main' 대신 'init'을 보냅니다.

-
-  worker.postMessage({ type: 'main', canvas: offscreen }, [ offscreen ]);
-+  worker.postMessage({ type: 'init', canvas: offscreen }, [ offscreen ]);
-
-

워커를 사용할 수 없는 경우 다음과 같이 실행합니다.

-
function startMainPage(canvas) {
-  init({ canvas });
-
-  function sendSize() {
-    state.width = canvas.clientWidth;
-    state.height = canvas.clientHeight;
-  }
-  window.addEventListener('resize', sendSize);
-  sendSize();
-
-  console.log('using regular canvas');
-}
-
-

이제 OffscreenCanvas를 지원하는 경우에만 OffscreenCanvas를 사용하고, 지원하지 않는 경우에는 메인 스레드에서 직접 렌더링합니다.

-

- -

-

어떤가요? 생각했던 것보다 쉽지 않나요? 여기에 피킹(picking)을 추가해봅시다. 피킹에 관한 글RayCaster 예제에서 코드 일부를 가져오도록 하겠습니다.

-

먼저 shared-cube.js의 코드를 shared-picking.js로 복사한 뒤, 피킹 예제에서 PickHelper를 가져옵니다.

-
class PickHelper {
-  constructor() {
-    this.raycaster = new THREE.Raycaster();
-    this.pickedObject = null;
-    this.pickedObjectSavedColor = 0;
-  }
-  pick(normalizedPosition, scene, camera, time) {
-    // 이미 다른 물체를 피킹했다면 색을 복원합니다
-    if (this.pickedObject) {
-      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
-      this.pickedObject = undefined;
-    }
-
-    // 절두체 안에 광선을 쏩니다
-    this.raycaster.setFromCamera(normalizedPosition, camera);
-    // 광선과 교차하는 물체들을 배열로 만듭니다
-    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
-    if (intersectedObjects.length) {
-      // 첫 번째 물체가 제일 가까우므로 해당 물체를 고릅니다
-      this.pickedObject = intersectedObjects[0].object;
-      // 기존 색을 저장해둡니다
-      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
-      // emissive 색을 빨강/노랑으로 빛나게 만듭니다
-      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
-    }
-  }
-}
-
-const pickPosition = { x: 0, y: 0 };
-const pickHelper = new PickHelper();
-
-

pickPosition은 마우스 포인터의 좌표를 기록하는 역할입니다. 이벤트를 통해 해당 속성을 업데이트하도록 했었죠.

-
function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function setPickPosition(event) {
-  const pos = getCanvasRelativePosition(event);
-  pickPosition.x = (pos.x / canvas.width ) *  2 - 1;
-  pickPosition.y = (pos.y / canvas.height) * -2 + 1;  // Y축을 뒤집었음
-}
-window.addEventListener('mousemove', setPickPosition);
-
-

워커는 포인터 좌표에 직접 접근할 수 없기에, 반응형 처리에 사용했던 코드처럼 포인터 좌표를 메시지로 보내야 합니다. 먼저 size 함수와 마찬가지로 mouse 함수를 만들어 pickPosition을 업데이트하도록 합니다.

-
function size(data) {
-  state.width = data.width;
-  state.height = data.height;
-}
-
-+function mouse(data) {
-+  pickPosition.x = data.x;
-+  pickPosition.y = data.y;
-+}
-
-const handlers = {
-  init,
-+  mouse,
-  size,
-};
-
-self.onmessage = function(e) {
-  const fn = handlers[e.data.type];
-  if (typeof fn !== 'function') {
-    throw new Error('no handler for type: ' + e.data.type);
-  }
-  fn(e.data);
-};
-
-

그리고 메인 페이지에 분기 함수를 만들어 워커 또는 메인 페이지로 좌표 데이터를 보내도록 합니다.

-
+let sendMouse;
-
-function startWorker(canvas) {
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-worker-picking.js', { type: 'module' });
-  worker.postMessage({ type: 'init', canvas: offscreen }, [ offscreen ]);
-
-+  sendMouse = (x, y) => {
-+    worker.postMessage({
-+      type: 'mouse',
-+      x,
-+      y,
-+    });
-+  };
-
-  function sendSize() {
-    worker.postMessage({
-      type: 'size',
-      width: canvas.clientWidth,
-      height: canvas.clientHeight,
-    });
-  }
-
-  window.addEventListener('resize', sendSize);
-  sendSize();
-
-  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
-}
-
-function startMainPage(canvas) {
-  init({ canvas });
-
-+  sendMouse = (x, y) => {
-+    pickPosition.x = x;
-+    pickPosition.y = y;
-+  };
-
-  function sendSize() {
-    state.width = canvas.clientWidth;
-    state.height = canvas.clientHeight;
-  }
-  window.addEventListener('resize', sendSize);
-  sendSize();
-
-  console.log('using regular canvas');  /* eslint-disable-line no-console */
-}
-
-

다음으로 마우스 이벤트 관련 코드를 메인 페이지로 옮긴 뒤 sendMouse 함수를 쓰도록 수정합니다.

-
function setPickPosition(event) {
-  const pos = getCanvasRelativePosition(event);
--  pickPosition.x = (pos.x / canvas.clientWidth ) *  2 - 1;
--  pickPosition.y = (pos.y / canvas.clientHeight) * -2 + 1;  // Y축을 뒤집었음
-+  sendMouse(
-+      (pos.x / canvas.clientWidth ) *  2 - 1,
-+      (pos.y / canvas.clientHeight) * -2 + 1);  // Y축을 뒤집었음
-}
-
-function clearPickPosition() {
-  /**
-   * 마우스의 경우는 항상 위치가 있어 그다지 큰
-   * 상관이 없지만, 터치 같은 경우 사용자가 손가락을
-   * 떼면 피킹을 멈춰야 합니다. 지금은 일단 어떤 것도
-   * 선택할 수 없는 값으로 지정해두었습니다
-   **/
--  pickPosition.x = -100000;
--  pickPosition.y = -100000;
-+  sendMouse(-100000, -100000);
-}
-window.addEventListener('mousemove', setPickPosition);
-window.addEventListener('mouseout', clearPickPosition);
-window.addEventListener('mouseleave', clearPickPosition);
-
-window.addEventListener('touchstart', (event) => {
-  event.preventDefault(); // 스크롤 이벤트 방지
-  setPickPosition(event.touches[0]);
-}, { passive: false });
-
-window.addEventListener('touchmove', (event) => {
-  setPickPosition(event.touches[0]);
-});
-
-window.addEventListener('touchend', clearPickPosition);
-
-

이제 OffscreenCanvas에서도 피킹이 정상적으로 작동할 겁니다.

-

- -

-

좀 더 욕심을 내 OrbitControls까지 추가해봅시다. OrbitControls는 DOM에 꽤 다양하게 접근하기에 처리해줘야 할 것이 좀 많습니다. 제대로 작동하려면 마우스 이벤트, 터치 이벤트, 키보드 이벤트를 모두 처리해줘야 하죠.

-

여태까지는 전역 state 객체를 사용했지만, OrbitControls의 경우는 객체 속성이 너무 많아 그걸 전부 다 하드 코딩하는 건 너무 번거롭습니다. OrbitControls는 필요한 DOM 이벤트의 대부분을 인자로 받는 HTMLElement에 바인딩합니다. 이를 이용해 DOM 요소와 같은 구조의 객체를 넘겨준다면 어떨까요? OrbitControls에 필요한 기능만 살려서 말이죠.

-

OrbitControls의 소스 코드를 분석해보니 아래의 이벤트만 지원하면 될 듯합니다.

-
    -
  • contextmenu
  • -
  • pointerdown
  • -
  • pointermove
  • -
  • pointerup
  • -
  • touchstart
  • -
  • touchmove
  • -
  • touchend
  • -
  • wheel
  • -
  • keydown
  • -
-

마우스 이벤트 중 OrbitControls가 사용하는 속성은 ctrlKey, metaKey, shiftKey, button, pointerType, clientX, clientY, pageX, pageY이고,

-

keydown 이벤트의 경우는 ctrlKey, metaKey, shiftKey, keyCode 속성,

-

wheel 이벤트는 deltaY 속성만,

-

터치 이벤트의 경우는 touches 속성의 pageX, pageY 속성이 필요하네요.

-

이를 처리할 경유(proxy) 객체를 한 쌍 만들어봅시다. 한쪽은 메인 페이지에서 위 이벤트를 받아 필요한 속성을 워커에 넘겨주는 역할을 할 겁니다. 그리고 다른 한쪽은 워커 안에서 이 이벤트를 받아 OrbitControls에 넘겨줄 겁니다. 이벤트 객체가 DOM 이벤트와 같은 구조이기에 OrbitControls는 이 이벤트가 DOM 이벤트가 아니란 걸 눈치채지 못하겠죠.

-

아래는 워커 안의 코드입니다.

-
import { EventDispatcher } from 'three';
-
-class ElementProxyReceiver extends EventDispatcher {
-  constructor() {
-    super();
-  }
-  handleEvent(data) {
-    this.dispatchEvent(data);
-  }
-}
-
-

위 코드는 단순히 메시지를 받았을 때 그걸 다시 내보내는(dispatch) 역할을 합니다. 부모 클래스인 EventDispatcher는 DOM 요소처럼 addEventListenerremoveEventListener 메서드를 제공하기에 HTML 요소 대신 이 클래스의 인스턴스를 넘겨줘도 문제없이 작동할 겁니다.

-

ElementProxyReceiver는 하나의 요소만 대신할 수 있습니다. 예제의 경우 하나만 필요하기는 하나 나중에 캔버스를 여러 개 사용할 수도 있으니 여러 ElementProxyReceiver를 관리하는 클래스를 만들겠습니다.

-
class ProxyManager {
-  constructor() {
-    this.targets = {};
-    this.handleEvent = this.handleEvent.bind(this);
-  }
-  makeProxy(data) {
-    const { id } = data;
-    const proxy = new ElementProxyReceiver();
-    this.targets[id] = proxy;
-  }
-  getProxy(id) {
-    return this.targets[id];
-  }
-  handleEvent(data) {
-    this.targets[data.id].handleEvent(data.data);
-  }
-}
-
-

ProxyManager의 인스턴스를 만들고 id값과 함께 makeProxy 메서드를 호출하면 해당 id에만 응답하는 ElementProxyReceiver가 생성됩니다.

-

이제 이 클래스를 기존 워커 코드와 연동해봅시다.

-
const proxyManager = new ProxyManager();
-
-function start(data) {
-  const proxy = proxyManager.getProxy(data.canvasId);
-  init({
-    canvas: data.canvas,
-    inputElement: proxy,
-  });
-}
-
-function makeProxy(data) {
-  proxyManager.makeProxy(data);
-}
-
-...
-
-const handlers = {
--  init,
--  mouse,
-+  start,
-+  makeProxy,
-+  event: proxyManager.handleEvent,
-   size,
-};
-
-self.onmessage = function(e) {
-  const fn = handlers[e.data.type];
-  if (typeof fn !== 'function') {
-    throw new Error('no handler for type: ' + e.data.type);
-  }
-  fn(e.data);
-};
-
-

Three.js의 공통 코드에 OrbitControls 모듈도 불러와 설정해야 합니다.

-
import * as THREE from 'three';
-+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-
-export function init(data) {
--  const { canvas } = data;
-+  const { canvas, inputElement } = data;
-  const renderer = new THREE.WebGLRenderer({ canvas });
-
-+  const controls = new OrbitControls(camera, inputElement);
-+  controls.target.set(0, 0, 0);
-+  controls.update();
-
-

위 코드에서는 이전과 달리 inputElement로 경유 객체를 OrbitControls에 넘겨줬습니다.

-

하는 김에 피킹 이벤트도 경유 객체를 사용하도록 바꿉니다.

-
function getCanvasRelativePosition(event) {
--  const rect = canvas.getBoundingClientRect();
-+  const rect = inputElement.getBoundingClientRect();
-  return {
-    x: event.clientX - rect.left,
-    y: event.clientY - rect.top,
-  };
-}
-
-function setPickPosition(event) {
-  const pos = getCanvasRelativePosition(event);
--  sendMouse(
--      (pos.x / canvas.clientWidth ) *  2 - 1,
--      (pos.y / canvas.clientHeight) * -2 + 1);  // Y축을 뒤집었음
-+  pickPosition.x = (pos.x / inputElement.clientWidth ) *  2 - 1;
-+  pickPosition.y = (pos.y / inputElement.clientHeight) * -2 + 1;  // Y축을 뒤집었음
-}
-
-function clearPickPosition() {
-  /**
-   * 마우스의 경우는 항상 위치가 있어 그다지 큰
-   * 상관이 없지만, 터치 같은 경우 사용자가 손가락을
-   * 떼면 피킹을 멈춰야 합니다. 지금은 일단 어떤 것도
-   * 선택할 수 없는 값으로 지정해두었습니다
-   **/
--  sendMouse(-100000, -100000);
-+  pickPosition.x = -100000;
-+  pickPosition.y = -100000;
-}
-
-*inputElement.addEventListener('mousemove', setPickPosition);
-*inputElement.addEventListener('mouseout', clearPickPosition);
-*inputElement.addEventListener('mouseleave', clearPickPosition);
-
-*inputElement.addEventListener('touchstart', (event) => {
-  event.preventDefault(); // 스크롤 이벤트 방지
-  setPickPosition(event.touches[0]);
-}, { passive: false });
-
-*inputElement.addEventListener('touchmove', (event) => {
-  setPickPosition(event.touches[0]);
-});
-
-*inputElement.addEventListener('touchend', clearPickPosition);
-
-

메인 페이지에서 위에 열거한 모든 이벤트가 워커로 메시지를 보내도록 합니다.

-
let nextProxyId = 0;
-class ElementProxy {
-  constructor(element, worker, eventHandlers) {
-    this.id = nextProxyId++;
-    this.worker = worker;
-    const sendEvent = (data) => {
-      this.worker.postMessage({
-        type: 'event',
-        id: this.id,
-        data,
-      });
-    };
-
-    // id를 등록합니다.
-    worker.postMessage({
-      type: 'makeProxy',
-      id: this.id,
-    });
-    for (const [eventName, handler] of Object.entries(eventHandlers)) {
-      element.addEventListener(eventName, function(event) {
-        handler(event, sendEvent);
-      });
-    }
-  }
-}
-
-

ElementProxy는 이벤트를 우회할 요소(element)를 인자로 받습니다. 그리고 고유 id를 생성해 워커에 makeProxy 메시지로 id를 등록합니다. 그러면 아까 만들었듯 워커는 이 id에 새로운 ElementProxyReceiver를 생성하겠죠.

-

다음으로 이벤트를 처리할 핸들러 맵(eventHandlers)을 만듭니다. 이러면 해당 이벤트가 발생했을 때만 워커에 메시지를 보낼 수 있죠.

-

워커를 생성할 때 ElementProxy에 이 핸들러 맵을 넘겨 새 우회 요소를 생성합니다.

-
function startWorker(canvas) {
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-worker-orbitcontrols.js', { type: 'module' });
-
-+  const eventHandlers = {
-+    contextmenu: preventDefaultHandler,
-+    mousedown: mouseEventHandler,
-+    mousemove: mouseEventHandler,
-+    mouseup: mouseEventHandler,
-+    pointerdown: mouseEventHandler,
-+    pointermove: mouseEventHandler,
-+    pointerup: mouseEventHandler,
-+    touchstart: touchEventHandler,
-+    touchmove: touchEventHandler,
-+    touchend: touchEventHandler,
-+    wheel: wheelEventHandler,
-+    keydown: filteredKeydownEventHandler,
-+  };
-+  const proxy = new ElementProxy(canvas, worker, eventHandlers);
-  worker.postMessage({
-    type: 'start',
-    canvas: offscreen,
-+    canvasId: proxy.id,
-  }, [ offscreen ]);
-  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
-}
-
-

핸들러 맵의 핸들러는 넘겨 받은 이벤트의 속성 중 넘겨 받은 키 배열에 해당하는 속성만 복사합니다. 그리고 ElementProxy에서 넘겨 받은 sendEvent 함수를 복사한 데이터와 함께 호출하죠. 그러면 sendEvent 함수는 해당하는 id와 데이터를 워커에 보냅니다.

-
const mouseEventHandler = makeSendPropertiesHandler([
-  'ctrlKey',
-  'metaKey',
-  'shiftKey',
-  'button',
-  'pointerType',
-  'clientX',
-  'clientY',
-  'pointerId',
-  'pageX',
-  'pageY',
-]);
-const wheelEventHandlerImpl = makeSendPropertiesHandler([
-  'deltaX',
-  'deltaY',
-]);
-const keydownEventHandler = makeSendPropertiesHandler([
-  'ctrlKey',
-  'metaKey',
-  'shiftKey',
-  'keyCode',
-]);
-
-function wheelEventHandler(event, sendFn) {
-  event.preventDefault();
-  wheelEventHandlerImpl(event, sendFn);
-}
-
-function preventDefaultHandler(event) {
-  event.preventDefault();
-}
-
-function copyProperties(src, properties, dst) {
-  for (const name of properties) {
-    dst[name] = src[name];
-  }
-}
-
-function makeSendPropertiesHandler(properties) {
-  return function sendProperties(event, sendFn) {
-    const data = { type: event.type };
-    copyProperties(event, properties, data);
-    sendFn(data);
-  };
-}
-
-function touchEventHandler(event, sendFn) {
-  // preventDefault() fixes mousemove, mouseup and mousedown 
-  // firing when doing a simple touchup touchdown
-  // Happens only at offscreen canvas
-  event.preventDefault(); 
-  const touches = [];
-  const data = { type: event.type, touches };
-  for (let i = 0; i < event.touches.length; ++i) {
-    const touch = event.touches[i];
-    touches.push({
-      pageX: touch.pageX,
-      pageY: touch.pageY,
-      clientX: touch.clientX,
-      clientY: touch.clientY,
-    });
-  }
-  sendFn(data);
-}
-
-// 키보드의 화살표 키
-const orbitKeys = {
-  '37': true,  // 왼쪽
-  '38': true,  // 위쪽
-  '39': true,  // 오른쪽
-  '40': true,  // 아래쪽
-};
-function filteredKeydownEventHandler(event, sendFn) {
-  const { keyCode } = event;
-  if (orbitKeys[keyCode]) {
-    event.preventDefault();
-    keydownEventHandler(event, sendFn);
-  }
-}
-
-

거의 다 된 듯합니다. 하지만 실제로 예제를 실행해보니 아직 처리해줘야 할 것들이 몇 개 더 있네요.

-

OrbitControlselement.focus 메서드를 호출합니다. 이는 워커에서 그다지 쓸모가 없으니 빈 함수로 대체하겠습니다.

-
class ElementProxyReceiver extends THREE.EventDispatcher {
-  constructor() {
-    super();
-  }
-  handleEvent(data) {
-    this.dispatchEvent(data);
-  }
-+  focus() {
-+    // 빈 함수(no-operation)
-+  }
-}
-
-

event.preventDefaultevent.stopPropagation도 사용합니다. 이는 이미 메인 페이지에서 처리했으니 이 역시 빈 함수로 대체합니다.

-
+function noop() {
-+}
-
-class ElementProxyReceiver extends THREE.EventDispatcher {
-  constructor() {
-    super();
-  }
-  handleEvent(data) {
-+    data.preventDefault = noop;
-+    data.stopPropagation = noop;
-    this.dispatchEvent(data);
-  }
-  focus() {
-    // 빈 함수(no-operation)
-  }
-}
-
-

clientWidthclientHeight도 사용합니다. 이전에는 캔버스의 크기값을 따로 넘겨줬었는데, 경유 객체들이 이 값도 주고받도록 수정하겠습니다.

-

워커의 경우 다음처럼 코드를 추가합니다.

-
class ElementProxyReceiver extends THREE.EventDispatcher {
-  constructor() {
-    super();
-  }
-+  get clientWidth() {
-+    return this.width;
-+  }
-+  get clientHeight() {
-+    return this.height;
-+  }
-+  getBoundingClientRect() {
-+    return {
-+      left: this.left,
-+      top: this.top,
-+      width: this.width,
-+      height: this.height,
-+      right: this.left + this.width,
-+      bottom: this.top + this.height,
-+    };
-+  }
-  handleEvent(data) {
-+    if (data.type === 'size') {
-+      this.left = data.left;
-+      this.top = data.top;
-+      this.width = data.width;
-+      this.height = data.height;
-+      return;
-+    }
-    data.preventDefault = noop;
-    data.stopPropagation = noop;
-    this.dispatchEvent(data);
-  }
-  focus() {
-    // 빈 함수(no-operation)
-  }
-}
-
-

이제 메인 페이지에서 캔버스의 크기와 위치 좌표를 넘겨줘야 합니다. 하나 언급하고 싶은 건 예제에서는 캔버스의 크기가 바뀌는 경우만 가정했지, 캔버스가 움직이는 경우는 가정하지 않았다는 점입니다. 캔버스가 움직이는 경우를 처리하려면 캔버스가 움직였을 때 sendSize를 호출하면 됩니다.

-
class ElementProxy {
-  constructor(element, worker, eventHandlers) {
-    this.id = nextProxyId++;
-    this.worker = worker;
-    const sendEvent = (data) => {
-      this.worker.postMessage({
-        type: 'event',
-        id: this.id,
-        data,
-      });
-    };
-
-    // id를 등록합니다.
-    worker.postMessage({
-      type: 'makeProxy',
-      id: this.id,
-    });
-+    sendSize();
-    for (const [eventName, handler] of Object.entries(eventHandlers)) {
-      element.addEventListener(eventName, function(event) {
-        handler(event, sendEvent);
-      });
-    }
-
-+    function sendSize() {
-+      const rect = element.getBoundingClientRect();
-+      sendEvent({
-+        type: 'size',
-+        left: rect.left,
-+        top: rect.top,
-+        width: element.clientWidth,
-+        height: element.clientHeight,
-+      });
-+    }
-+
-+    window.addEventListener('resize', sendSize);
-  }
-}
-
-

이제 공통 Three.js 코드에서 state 전역 변수를 쓰지 않으니 삭제합니다.

-
-export const state = {
--  width: 300,   // 캔버스 기본값
--  height: 150,  // 캔버스 기본값
--};
-
-...
-
-function resizeRendererToDisplaySize(renderer) {
-  const canvas = renderer.domElement;
--  const width = state.width;
--  const height = state.height;
-+  const width = inputElement.clientWidth;
-+  const height = inputElement.clientHeight;
-  const needResize = canvas.width !== width || canvas.height !== height;
-  if (needResize) {
-    renderer.setSize(width, height, false);
-  }
-  return needResize;
-}
-
-function render(time) {
-  time *= 0.001;
-
-  if (resizeRendererToDisplaySize(renderer)) {
--    camera.aspect = state.width / state.height;
-+    camera.aspect = inputElement.clientWidth / inputElement.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-  ...
-
-

OrbitControls는 마우스 이벤트를 감지하기 위해 해당 요소의 ownerDocumentpointermovepointerup 리스너를 추가합니다(마우스가 창 밖으로 나갔을 경우를 위해).

-

또한 코드는 전역 document 객체를 참조하지만 워커에는 전역 document 객체가 없습니다.

-

이 문제는 간단한 편법(hack)을 써 해결할 수 있습니다. 다시 한 번 워커의 경유 객체를 이용하도록 하죠.

-
function start(data) {
-  const proxy = proxyManager.getProxy(data.canvasId);
-+  proxy.ownerDocument = proxy; // HACK!
-+  self.document = {} // HACK!
-  init({
-    canvas: data.canvas,
-    inputElement: proxy,
-  });
-}
-
-

이러면 OrbitControls가 에러를 던지지 않을 겁니다.

-

예제가 복잡해 이해하기 어려웠을 수 있습니다. 동작을 요약하자면 ElementProxy가 메인 페이지의 DOM 이벤트를 워커의 ElementProxyReceiver에 넘기고, ElementProxyReceiverHTMLElement를 가장해 OrbitControls와 공통 코드에서 쓸 수 있는 대체 DOM 요소로 기능합니다.

-

마지막으로 OffscreenCanvas를 지원하지 않는 경우의 예외 코드만 수정해주면 끝입니다. 간단히 inputElement에 캔버스 요소자체를 넘겨주기만 하면 되죠.

-
function startMainPage(canvas) {
--  init({ canvas });
-+  init({ canvas, inputElement: canvas });
-  console.log('using regular canvas');
-}
-
-

이제 OffscreenCanvas에서도 OrbitControls가 잘 작동합니다.

-

- -

-

아마 이 예제가 이 시리즈를 통틀어 가장 복잡한 예제일 겁니다. 각 예제마다 HTML 파일, 워커 파일, 공통 Three.js 코드 파일, 이렇게 파일 3개가 서로 연동되니 그럴만 하죠.

-

이 글이 너무 어렵게 느껴지지 않았다면 좋겠습니다. 또한 Three.js에서 OffscreenCanvas와 웹 워커를 활용하는 좋은 예가 되었길 바랍니다.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/optimize-lots-of-objects-animated.html b/manual/ko/optimize-lots-of-objects-animated.html deleted file mode 100644 index 6cd493ebf0403d..00000000000000 --- a/manual/ko/optimize-lots-of-objects-animated.html +++ /dev/null @@ -1,419 +0,0 @@ - - - 다중 애니메이션 요소 최적화하기 - - - - - - - - - - - - - - -
-
-

다중 애니메이션 요소 최적화하기

-
-
-
-

※ 이 글은 다중 요소 최적화하기에서 이어지는 글입니다. 이전 글을 읽지 않았다면 먼저 읽고 오기 바랍니다.

-

이전 글에서는 약 19000 육면체를 하나의 geometry로 만들었습니다. 이 방법을 적용해 렌더링 속도는 눈에 띄게 빨라졌지만, 각 육면체를 움직이기 어렵다는 게 단점이었죠.

-

구현하고자 하는 바에 따라 해결책은 천차만별입니다. 이 글에서는 여러 데이터 그룹(set)을 그래프로 만들어 각 그룹을 전환할 때 애니메이션을 넣는 경우를 살펴보겠습니다.

-

먼저 데이터를 그룹으로 묶어야 합니다. 프로그램 밖에서 데이터를 미리 가공하는 게 이상적이지만, 여기서는 데이터 2개를 따로 불러오겠습니다.

-

아래는 기존 코드입니다.

-
loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
-  .then(parseData)
-  .then(addBoxes)
-  .then(render);
-
-

아래와 같은 식으로 바꿔줍니다.

-
async function loadData(info) {
-  const text = await loadFile(info.url);
-  info.file = parseData(text);
-}
-
-async function loadAll() {
-  const fileInfos = [
-    { name: 'men',   hueRange: [0.7, 0.3], url: 'resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc' },
-    { name: 'women', hueRange: [0.9, 1.1], url: 'resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014ft_2010_cntm_1_deg.asc' },
-  ];
-
-  await Promise.all(fileInfos.map(loadData));
-
-  ...
-}
-loadAll();
-
-

위 코드는 fileInfos 배열에 있는 파일을 불러오고, 각 파일의 로딩이 끝났을 때 해당 객체의 file 속성에 불러온 파일을 지정합니다. namehueRange는 나중에 사용할 속성으로, name은 UI에, hueRange는 색상 맵을 지정할 때 사용할 겁니다.

-

새로 불러온 파일은 각각 2010년도 지역별 남성 인구 밀도, 지역별 여성 인구 밀도를 나타냅니다. 믿을 만한 데이터인지는 모르겠지만 당장 크게 중요하진 않으니 넘어가죠. 중요한 건 값이 다른 데이터를 보여주는 것에 있으니까요.

-

여기에 2개의 데이터를 더 만듭니다. 하나는 남성 인구가 여성 인구보다 많은 곳, 나머지는 그 반대로 여성 인구가 남성 인구보다 많은 곳의 데이터입니다.

-

먼저 2차원 배열을 매개변수로 받아 배열 속 배열을 매핑하는 함수를 하나 작성합니다.

-
function mapValues(data, fn) {
-  return data.map((row, rowNdx) => {
-    return row.map((value, colNdx) => {
-      return fn(value, rowNdx, colNdx);
-    });
-  });
-}
-
-

Array.map 메서드와 마찬가지로 mapValues 함수는 배열 속 배열의 요소에 매개변수로 받은 fn 함수를 실행합니다. 이 함수는 해당 배열값과 행 인덱스, 열 인덱스를 매개변수로 사용합니다.

-

다음으로 두 파일을 배교해 새로운 파일을 만드는 함수를 작성합니다.

-
function makeDiffFile(baseFile, otherFile, compareFn) {
-  let min;
-  let max;
-  const baseData = baseFile.data;
-  const otherData = otherFile.data;
-  const data = mapValues(baseData, (base, rowNdx, colNdx) => {
-    const other = otherData[rowNdx][colNdx];
-      if (base === undefined || other === undefined) {
-        return undefined;
-      }
-      const value = compareFn(base, other);
-      min = Math.min(min === undefined ? value : min, value);
-      max = Math.max(max === undefined ? value : max, value);
-      return value;
-  });
-  // baseFile을 복사한 뒤 min, max, data를 새 값으로 교체합니다.
-  return {...baseFile, min, max, data};
-}
-
-

위 함수는 mapValues 안에서 넘겨받은 compareFn으로 값을 비교해 새로운 데이터 그룹을 만듭니다. 또한 값의 min, max를 계속 추적해 baseFile을 기반으로 min, max, data 속성을 교체한 새로운 파일을 만듭니다.

-

이제 이 함수들로 새로운 데이터를 만들어봅시다.

-
{
-  const menInfo = fileInfos[0];
-  const womenInfo = fileInfos[1];
-  const menFile = menInfo.file;
-  const womenFile = womenInfo.file;
-
-  function amountGreaterThan(a, b) {
-    return Math.max(a - b, 0);
-  }
-  fileInfos.push({
-    name: '>50%men',
-    hueRange: [0.6, 1.1],
-    file: makeDiffFile(menFile, womenFile, (men, women) => {
-      return amountGreaterThan(men, women);
-    }),
-  });
-  fileInfos.push({
-    name: '>50% women',
-    hueRange: [0.0, 0.4],
-    file: makeDiffFile(womenFile, menFile, (women, men) => {
-      return amountGreaterThan(women, men);
-    }),
-  });
-}
-
-

이제 간단한 UI를 만들어 각 데이터를 선택할 수 있게 합니다. 먼저 UI용 HTML 요소를 추가합니다.

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="ui"></div>
-</body>
-
-

추가한 요소를 CSS로 상단 왼쪽에 위치하게 합니다.

-
#ui {
-  position: absolute;
-  left: 1em;
-  top: 1em;
-}
-#ui > div {
-  font-size: 20pt;
-  padding: 1em;
-  display: inline-block;
-}
-#ui > div.selected {
-  color: red;
-}
-
-

그리고 각 파일의 육면체 그래프를 만들어 하나로 합친 뒤, 이벤트용 요소를 하나 만듭니다. 이 요소에 마우스를 올리면 대응하는 데이터를 제외한 나머지는 숨기고 해당 데이터만 보이도록 할 겁니다.

-
// 선택한 데이터만 보이게 하고, 나머지는 숨깁니다.
-function showFileInfo(fileInfos, fileInfo) {
-  fileInfos.forEach((info) => {
-    const visible = fileInfo === info;
-    info.root.visible = visible;
-    info.elem.className = visible ? 'selected' : '';
-  });
-  requestRenderIfNotRequested();
-}
-
-const uiElem = document.querySelector('#ui');
-fileInfos.forEach((info) => {
-  const boxes = addBoxes(info.file, info.hueRange);
-  info.root = boxes;
-  const div = document.createElement('div');
-  info.elem = div;
-  div.textContent = info.name;
-  uiElem.appendChild(div);
-  div.addEventListener('mouseover', () => {
-    showFileInfo(fileInfos, info);
-  });
-});
-// 첫 번째 데이터를 먼저 렌더링합니다.
-showFileInfo(fileInfos, fileInfos[0]);
-
-

추가로 이전 예제에서 하드 코딩했던 색상값을 hueRange로 쓰도록 바꿉니다.

-
-function addBoxes(file) {
-+function addBoxes(file, hueRange) {
-
-  ...
-
-    // 색상값을 구합니다
--    const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
-+    const hue = THREE.MathUtils.lerp(...hueRange, amount);
-
-  ...
-
-

이제 4가지 데이터를 볼 수 있을 겁니다. 각 이름에 마우스를 올리거나 터치하면 해당 데이터로 바뀝니다.

-

- -

-

뭔가 앞뒤가 맞지 않는 데이터가 몇 개 보입니다. 대체 무슨 영문인지 모르겠네요. 어쨌든 일단은 이 4가지 데이터가 자연스럽게 바뀌도록 애니메이션을 넣는 데 집중합시다.

-

당장 떠오르는 방법은 3가지 정도입니다.

-
    -
  • Material.opacity를 이용해 페이드 효과를 준다

    -

    이 방법의 문제점은 육면체들이 완전히 같은 위치에 있기에 z-파이팅이 발생할 수 있다는 겁니다. 물론 깊이(depth)와 블렌딩(blending) 옵션을 주면 어쩌어찌 해결할 수 있겠죠. 자세한 방법에 대해서는 해봐야 알겠지만요.

    -
  • -
  • 보여줄 데이터는 커지게, 사라질 데이터는 작게해 전환 효과를 준다

    -

    육면체 그래프가 전부 중점이 지구본 중심이기에 1.0 이하로 크기(scale)을 줄이면 그래프가 지구본 안으로 파고들 겁니다. 얼핏 좋은 생각인 듯 싶었지만 높이가 낮은 그래프는 바로 사라져 다음 그래프의 크기가 1.0 이상이 될 때까지 한참을 빈 공간으로 남겠죠. 이러면 전환 효과가 굉장히 어색할 겁니다. 물론 복잡한 쉐이더를 써서 어느 정도 가릴 수는 있겠지만요.

    -
  • -
  • morphtargets 옵션을 쓴다

    -

    morphtargets는 geometry 각 정점에 새로운 값을 부여해 천천히 변형(morph)시키거나 두 점 사이를 선형 보간(lerp, linear interpolate)하는 것을 말합니다. morphtargets는 주로 3D 캐릭터의 표정을 묘사할 때 쓰지만 꼭 그렇게만 쓰라는 법은 없죠.

    -
  • -
-

그럼 morphtargets를 사용해봅시다.

-

이전처럼 각 데이터를 하나의 geometry로 만들되, 데이터를 만들 때 각 육면체의 position 속성을 추출해 morphtargets으로 사용할 겁니다.

-

먼저 addBoxes 함수가 합친 geometry를 반환하도록 수정합니다. 장면(scene)에 추가하는 대신 말이죠.

-
-function addBoxes(file, hueRange) {
-+function makeBoxes(file, hueRange) {
-  const { min, max, data } = file;
-  const range = max - min;
-
-  ...
-
--  const mergedGeometry = BufferGeometryUtils.mergeGeometries(
--      geometries, false);
--  const material = new THREE.MeshBasicMaterial({
--    vertexColors: true,
--  });
--  const mesh = new THREE.Mesh(mergedGeometry, material);
--  scene.add(mesh);
--  return mesh;
-+  return BufferGeometryUtils.mergeGeometries(
-+     geometries, false);
-}
-
-

여기에 한 가지 예외 방지 처리를 해줘야 합니다. morphtargets에는 정확히 같은 개수의 정점을 지정해야 하는데, 예를 들어 정점 #123에는 상응하는 정점 #123 morphtarget이 있어야 합니다. 예제의 경우는 빈 데이터가 있고, 데이터가 비었다는 건 상응하는 육면체, 정점 데이터가 없을 수 있다는 것을 의미하죠. 그러니 모든 데이터를 검사해 해당 위치에 데이터가 하나라도 있는 경우든, 그냥 데이터가 없는 경우든 임의의 데이터를 지정해야 합니다. 일단은 더 간단한 후자를 선택하도록 하죠.

-
+function dataMissingInAnySet(fileInfos, latNdx, lonNdx) {
-+  for (const fileInfo of fileInfos) {
-+    if (fileInfo.file.data[latNdx][lonNdx] === undefined) {
-+      return true;
-+    }
-+  }
-+  return false;
-+}
-
--function makeBoxes(file, hueRange) {
-+function makeBoxes(file, hueRange, fileInfos) {
-  const { min, max, data } = file;
-  const range = max - min;
-
-  ...
-
-  const geometries = [];
-  data.forEach((row, latNdx) => {
-    row.forEach((value, lonNdx) => {
-+      if (dataMissingInAnySet(fileInfos, latNdx, lonNdx)) {
-+        return;
-+      }
-      const amount = (value - min) / range;
-
-  ...
-
-

다음으로 addBoxesmakeBoxes로 교체한 뒤, morphtargets를 설정합니다.

-
+// 데이터 그룹에 geometry를 각각 만듭니다.
-+const geometries = fileInfos.map((info) => {
-+  return makeBoxes(info.file, info.hueRange, fileInfos);
-+});
-+
-+// 첫 번째 geometry를 기준으로 다른 geometry를 morphtargets로 지정합니다.
-+const baseGeometry = geometries[0];
-+baseGeometry.morphAttributes.position = geometries.map((geometry, ndx) => {
-+  const attribute = geometry.getAttribute('position');
-+  const name = `target${ ndx }`;
-+  attribute.name = name;
-+  return attribute;
-+});
-+baseGeometry.morphAttributes.color = geometries.map((geometry, ndx) => {
-+  const attribute = geometry.getAttribute('color');
-+  const name = `target${ndx}`;
-+  attribute.name = name;
-+  return attribute;
-+});
-+const material = new THREE.MeshBasicMaterial({
-+  vertexColors: true,
-++});
-+const mesh = new THREE.Mesh(baseGeometry, material);
-+scene.add(mesh);
-
-const uiElem = document.querySelector('#ui');
-fileInfos.forEach((info) => {
--  const boxes = addBoxes(info.file, info.hueRange);
--  info.root = boxes;
-  const div = document.createElement('div');
-  info.elem = div;
-  div.textContent = info.name;
-  uiElem.appendChild(div);
-  function show() {
-    showFileInfo(fileInfos, info);
-  }
-  div.addEventListener('mouseover', show);
-  div.addEventListener('touchstart', show);
-});
-// 첫 데이터 그룹을 렌더링합니다.
-showFileInfo(fileInfos, fileInfos[0]);
-
-

위 코드에서는 먼저 데이터 그룹에 각각 geometry를 만들었습니다. 그리고 처음으로 생성한 geometry를 기준으로 삼아 각 geometry의 position 속성을 배열로 매핑한 뒤, 기준 geometry의 morphtargets position 속성에 지정했습니다.

-

이제 각 데이터 그룹에 전환 효과를 줘야 합니다. mesh를 사라지고 나타나게 하는 대신 mesh의 morphTargetInfluences 속성을 바꿔 애니메이션을 구현할 겁니다. 화면에 렌더링할 데이터 그룹의 influence(영향)은 1, 렌더링하지 않을 그룹의 influence는 0으로 설정하는 것이죠.

-

단순히 숫자 0, 1을 바로 지정할 수도 있지만 그러면 애니메이션이 하나도 보이지 않을 겁니다. 아까 썼던 방법과 전혀 차이가 없는 결과가 나오겠죠. 물론 직접 애니메이션 코드를 작성할 수도 있지만 원본 WebGL 지구본이 애니메이션 라이브러리를 썼으므로 같은 라이브러리를 사용해보겠습니다.

-

먼저 라이브러리를 불러옵니다.

-
import * as THREE from 'three';
-import { BufferGeometryUtils } from 'three/addons/utils/BufferGeometryUtils.js';
-import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-+import TWEEN from 'three/addons/libs/tween.module.js';
-
-

그리고 Tween으로 influence 속성에 애니메이션을 줍니다.

-
// 선택한 데이터를 보여주고 나머지는 숨깁니다.
-function showFileInfo(fileInfos, fileInfo) {
-  fileInfos.forEach((info) => {
-    const visible = fileInfo === info;
--    info.root.visible = visible;
-    info.elem.className = visible ? 'selected' : '';
-+    const targets = {};
-+    fileInfos.forEach((info, i) => {
-+      targets[i] = info === fileInfo ? 1 : 0;
-+    });
-+    const durationInMs = 1000;
-+    new TWEEN.Tween(mesh.morphTargetInfluences)
-+      .to(targets, durationInMs)
-+      .start();
-  });
-  requestRenderIfNotRequested();
-}
-
-

또 매 렌더링 프레임에서 TWEEN.update를 호출해야 하지만 좀 문제가 있습니다. "tween.js"는 연속 렌더링을 사용하도록 디자인되었습니다. 하지만 예제에서는 불필요한 렌더링 제거 기법을 사용했죠. 연속 렌더링을 사용하도록 코드를 바꿀 수도 있지만, 아무런 변화가 없을 때 렌더링을 하지 않음으로써 불필요한 자원 낭비를 줄인다는 장점을 버리고 싶진 않습니다. 여기에 불필요한 렌더링 제거 기법을 적용할 수 있을지 살펴보죠.

-

간단히 TweenManager라는 헬퍼 클래스를 만들겠습니다. 이 클래스를 통해 Tween을 만들고 애니메이션을 추적할 겁니다. 이 클래스의 update 메서드는 애니메이션이 진행 중이며 다음 프레임을 요청해야 할 때는 true, 애니메이션이 끝났다면 false를 반환할 겁니다.

-
class TweenManger {
-  constructor() {
-    this.numTweensRunning = 0;
-  }
-  _handleComplete() {
-    --this.numTweensRunning;
-    console.assert(this.numTweensRunning >= 0);
-  }
-  createTween(targetObject) {
-    const self = this;
-    ++this.numTweensRunning;
-    let userCompleteFn = () => {};
-    // Tween 인스턴스를 만들고 onCompelete에 콜백 함수를 설치합니다.
-    const tween = new TWEEN.Tween(targetObject).onComplete(function(...args) {
-      self._handleComplete();
-      userCompleteFn.call(this, ...args);
-    });
-    // Tween 인스턴스의 onComplete 함수를 바꿔 사용자가 콜백 함수를
-    // 지정할 수 있도록 합니다.
-    tween.onComplete = (fn) => {
-      userCompleteFn = fn;
-      return tween;
-    };
-    return tween;
-  }
-  update() {
-    TWEEN.update();
-    return this.numTweensRunning > 0;
-  }
-}
-
-

만든 클래스의 인스턴스를 생성합니다.

-
function main() {
-  const canvas = document.querySelector('#c');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+  const tweenManager = new TweenManger();
-
-  ...
-
-

생성한 인스턴스로 Tween 인스턴스를 생성합니다.

-
// 선택한 데이터를 보여주고 나머지는 숨깁니다.
-function showFileInfo(fileInfos, fileInfo) {
-  fileInfos.forEach((info) => {
-    const visible = fileInfo === info;
-    info.elem.className = visible ? 'selected' : '';
-    const targets = {};
-    fileInfos.forEach((info, i) => {
-      targets[i] = info === fileInfo ? 1 : 0;
-    });
-    const durationInMs = 1000;
--    new TWEEN.Tween(mesh.morphTargetInfluences)
-+    tweenManager.createTween(mesh.morphTargetInfluences)
-      .to(targets, durationInMs)
-      .start();
-  });
-  requestRenderIfNotRequested();
-}
-
-

그리고 tween 애니메이션이 남아있다면 계속 렌더링 루프를 반복하도록 render 함수를 수정합니다.

-
function render() {
-  renderRequested = false;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-+  if (tweenManager.update()) {
-+    requestRenderIfNotRequested();
-+  }
-
-  controls.update();
-  renderer.render(scene, camera);
-}
-render();
-
-

이제 각 데이터 그룹을 전환할 때 애니메이션이 보일 겁니다.

-

- -

-

여기서 살펴본 내용이 유익했다면 좋겠습니다. Three.js가 제공하는 모듈을 만드는 것과, 직접 쉐이더를 만드는 것 둘 다 morphtargets를 이용해 애니메이션을 구현할 때 자주 사용하는 방법입니다. 예를 들어 각 육면체 그래프를 임의의 요소에 두고 해당 위치에서 지구본 위로 이동하는 애니메이션을 줄 수도 있죠. 그것도 그래프를 표현하는 멋진 방법 중 하나일 겁니다.

-

혹시 위 지구본에 각 나라의 이름을 띄워보고 싶진 않나요? 그렇다면 HTML 요소를 3D로 정렬하기를 참고해보세요.

-
-

참고: 예제에서 남성 인구 비율이나 여성 인구 비율 또는 두 데이터의 차이를 견본 데이터로 사용할 수도 있었지만, 에제에 적용한 애니메이션은 땅에서 그래프가 올라오는 형식입니다. 비율로 처리한다면 대게의 값이 비슷비슷할 테고, 그래프의 높이도 2/1 정도는 더 낮아 시각적 효과가 그다지 크지 않았겠죠. amountGreaterThanMath.max(a - b, 0)에서 (a - b) 등으로 바꿔 두 데이터의 차이를 보거나, a / (a + b)로 바꿔 성비를 볼 수 있습니다.

-
- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/optimize-lots-of-objects.html b/manual/ko/optimize-lots-of-objects.html deleted file mode 100644 index 51f69bf1b61e3e..00000000000000 --- a/manual/ko/optimize-lots-of-objects.html +++ /dev/null @@ -1,444 +0,0 @@ - - - 다중 요소 렌더링 최적화하기 - - - - - - - - - - - - - - -
-
-

다중 요소 렌더링 최적화하기

-
-
-
-

※ 이 글은 Three.js의 튜토리얼 시리즈로서, -먼저 Three.js의 기본 구조에 관한 글을 -읽고 오길 권장합니다.

-

Three.js에서 요소를 최적화하는 방법은 아주 다양합니다. 가장 많이 언급되는 방법은 geometry를 합치는 것이죠. Three.js는 사용자가 Mesh를 하나 만들 때마다 매번 시스템에 하나 이상의 렌더링 요청을 보냅니다. 결과물이 완벽히 같더라도 mesh를 1개를 렌더링할 때보다 2개를 렌더링할 때 오버헤드(overhead)가 더 많이 발생한다는 이야기죠. 그러니 이 mesh를 하나로 합친다면 오버헤드를 줄일 수 있습니다.

-

간단한 예제를 통해 이 방법이 어떤 경우 적합한지 알아보도록 하겠습니다. WebGL 지구본을 베껴 직접 구현해보겠습니다.

-

먼저 데이터를 작성합니다. WebGL 지구본 개발진은 SEDAC에서 데이터를 가져왔다고 합니다. 사이트를 뒤져보니 그리드 형태의 인구 통계 데이터가 있네요. resolution 값을 60 minute으로 설정해 데이터를 다운로드합니다.

-

받은 데이터는 아래와 같습니다.

-
 ncols         360
- nrows         145
- xllcorner     -180
- yllcorner     -60
- cellsize      0.99999999999994
- NODATA_value  -9999
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
- 9.241768 8.790958 2.095345 -9999 0.05114867 -9999 -9999 -9999 -9999 -999...
- 1.287993 0.4395509 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999...
- -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
-
-

키/값 쌍 데이터가 몇 줄 있고 나머지는 격자점(grid point) 데이터네요. 데이터의 한 줄은 각 좌표에 대한 데이터입니다.

-

이해를 돕기 위해 데이터를 2D로 구현해보겠습니다.

-

먼저 텍스트 파일을 불러옵니다.

-
async function loadFile(url) {
-  const res = await fetch(url);
-  return res.text();
-}
-
-

위 함수는 url의 파일 내용을 반환하는 Promise를 반환합니다.

-

※참고: fetch API. 역주.

-

다음으로 텍스트 데이터를 파싱하는 함수를 작성합니다.

-
function parseData(text) {
-  const data = [];
-  const settings = { data };
-  let max;
-  let min;
-  // 각 줄을 쪼갭니다.
-  text.split('\n').forEach((line) => {
-    // 해당 줄을 공백을 기준으로 쪼갭니다.
-    const parts = line.trim().split(/\s+/);
-    if (parts.length === 2) {
-      // 2개로 나눠졌다면 키/값 쌍 데이터입니다.
-      settings[parts[0]] = parseFloat(parts[1]);
-    } else if (parts.length > 2) {
-      // 2개보다 많다면 좌표 데이터입니다.
-      const values = parts.map((v) => {
-        const value = parseFloat(v);
-        if (value === settings.NODATA_value) {
-          return undefined;
-        }
-        max = Math.max(max === undefined ? value : max, value);
-        min = Math.min(min === undefined ? value : min, value);
-        return value;
-      });
-      data.push(values);
-    }
-  });
-  return Object.assign(settings, { min, max });
-}
-
-

위 함수는 데이터 파일의 키/값 쌍, 좌표 데이터를 하나의 배열 만든 data 속성, 그리고 좌표 데이터를 기반으로 한 min, max 속성을 가진 객체를 반환합니다.

-

그리고 데이터를 렌더링하는 코드를 작성합니다.

-
function drawData(file) {
-  const { min, max, data } = file;
-  const range = max - min;
-  const ctx = document.querySelector('canvas').getContext('2d');
-  // 데이터와 같은 크기로 캔버스 해상도를 맞춥니다.
-  ctx.canvas.width = ncols;
-  ctx.canvas.height = nrows;
-  // 캔버스 요소의 크기를 두 배로 지정해 너무 작게 보이지 않도록 합니다.
-  ctx.canvas.style.width = px(ncols * 2);
-  ctx.canvas.style.height = px(nrows * 2);
-  // 배경을 짙은 회색으로 채웁니다.
-  ctx.fillStyle = '#444';
-  ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
-  // 각 좌표에 점을 그립니다.
-  data.forEach((row, latNdx) => {
-    row.forEach((value, lonNdx) => {
-      if (value === undefined) {
-        return;
-      }
-      const amount = (value - min) / range;
-      const hue = 1;
-      const saturation = 1;
-      const lightness = amount;
-      ctx.fillStyle = hsl(hue, saturation, lightness);
-      ctx.fillRect(lonNdx, latNdx, 1, 1);
-    });
-  });
-}
-
-function px(v) {
-  return `${ v | 0 }px`;
-}
-
-function hsl(h, s, l) {
-  return `hsl(${ h * 360 | 0 },${ s * 100 | 0 }%,${ l * 100 | 0 }%)`;
-}
-
-

작성한 함수를 순서대로 실행하면 끝입니다.

-
loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
-  .then(parseData)
-  .then(drawData);
-
-

결과를 볼까요?

-

- -

-

데이터가 잘 렌더링된 것 같네요.

-

이제 이걸 3D로 만들어봅시다. 불필요한 렌더링 제거하기에서 썼던 예제를 가져와 각 데이터마다 육면체를 하나씩 만들 겁니다.

-

먼저 아래 텍스처로 간단한 지구본 모형을 만들겠습니다.

-
- -

아래는 지구본을 만드는 코드입니다.

-
{
-  const loader = new THREE.TextureLoader();
-  const texture = loader.load('resources/images/world.jpg', render);
-  const geometry = new THREE.SphereGeometry(1, 64, 32);
-  const material = new THREE.MeshBasicMaterial({ map: texture });
-  scene.add(new THREE.Mesh(geometry, material));
-}
-
-

위 코드에서는 텍스처를 불러온 후 render 함수를 호출하게 했습니다. 화면을 반복해서 렌더링하지 않고 필요할 때만 렌더링하므로, 텍스처를 불러온 뒤 다시 한 번 렌더링해야 합니다.

-

다음으로 데이터를 하나의 점으로 표시하는 대신 좌표 데이터마다 육면체를 하나씩 생성합니다.

-
function addBoxes(file) {
-  const { min, max, data } = file;
-  const range = max - min;
-
-  // 육면체 geometry를 만듭니다.
-  const boxWidth = 1;
-  const boxHeight = 1;
-  const boxDepth = 1;
-  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-  // 중심이 아닌 양의 z축 방향으로 커지게끔 만듭니다.
-  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));
-
-  // 아래 헬퍼 Object3D는 육면체들의 위치 변화를 간단하게 만들어줍니다.
-  // lonHelper를 Y축으로 돌려 경도(longitude)를 맞출 수 있습니다.
-  const lonHelper = new THREE.Object3D();
-  scene.add(lonHelper);
-  // latHelper를 X축으로 돌려 위도(latitude)를 맞출 수 있습니다.
-  const latHelper = new THREE.Object3D();
-  lonHelper.add(latHelper);
-  // positionHelper는 다른 요소의 기준축을 구체의 끝에 맞추는 역할을 합니다.
-  const positionHelper = new THREE.Object3D();
-  positionHelper.position.z = 1;
-  latHelper.add(positionHelper);
-
-  const lonFudge = Math.PI * .5;
-  const latFudge = Math.PI * -0.135;
-  data.forEach((row, latNdx) => {
-    row.forEach((value, lonNdx) => {
-      if (value === undefined) {
-        return;
-      }
-      const amount = (value - min) / range;
-      const material = new THREE.MeshBasicMaterial();
-      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
-      const saturation = 1;
-      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
-      material.color.setHSL(hue, saturation, lightness);
-      const mesh = new THREE.Mesh(geometry, material);
-      scene.add(mesh);
-
-      // 헬퍼들을 특정 위도와 경도로 이동시킵니다.
-      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
-      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
-
-      // positionHelper의 위치를 해당 mesh의 위치로 지정합니다.
-      positionHelper.updateWorldMatrix(true, false);
-      mesh.applyMatrix4(positionHelper.matrixWorld);
-
-      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
-    });
-  });
-}
-
-

위 코드는 아까 만들었던 테스트 코드의 구조를 그대로 사용한 것입니다.

-

각 육면체를 양의 Z축으로 커지게 만든 건 지구본 위에 그래프가 올라와야 하기 때문입니다. 이 설정이 없다면 그래프가 지구본 안쪽으로 파고들어 가겠죠.

-
-
-
-
기본값
-
-
-
-
Z축을 조정한 결과
-
-
- -

씬 그래프에 관한 글에서 배웠듯 육면체를 다른 THREE.Object3D의 자식으로 두는 것도 한 가지 해결 방법이지만, 씬 그래프 요소가 많아지만 그만큼 성능이 떨어집니다.

-

lonHelper, latHelper, positionHelper를 계층 구조로 만든 건 구체 주위에 육면체를 배치할 좌표를 찾기 위해서입니다.

-
-
-
- -

위 예제의 초록색 막대lonHelper를 상징합니다. 자전축을 중심으로 경도(longitude)를 찾는 역할을 하죠. 파란색 막대latHelper입니다. latHelper는 적도 위 아래로 위도(latitude)를 찾는 역할을 합니다. 빨간 구체positionHelper로, 육면체의 좌표값을 나타냅니다.

-

물론 좌표를 계산할 때 수학적으로 접근할 수도 있지만, 이렇게 라이브러리가 대신 계산하도록 하면 복잡하게 머리를 쥐어짜야할 필요가 없습니다.

-

위 코드에서는 각 데이터 좌표마다 MeshBasicMaterialMesh를 생성했습니다. 그리고 positionHelper의 전역 좌표를 구해 Mesh에 적용하고, 데이터의 양만큼 이 mesh를 키웠죠.

-

아까와 마찬가지로 각 데이터마다 lonHelper, latHelper, positionHelper를 따로 생성할 수도 있지만, 그렇게 하면 성능이 훨씬 느려질 겁니다.

-

이러면 육면체를 최대 360x145개, 거의 최대 52000개를 만드는 셈입니다. "NO_DATA"라고 표시된 곳도 있기에 그걸 제외하면 육면체의 개수는 대략 19000개가 될 겁니다. 그런데 여기에 각 육면체에 따로 3개씩 헬퍼를 생성한다면 Three.js는 거의 80000개의 씬 그래프 요소를 계산하게 되죠. 대신 헬퍼를 공통으로 사용하면 연산 요청을 약 60000번 정도 줄일 수 있습니다.

-

lonFudgelatFudge에 대해 짧게 언급하겠습니다. lonFudge는 π/2, 90도입니다. 이건 크게 신경쓸 게 없네요. 그냥 텍스처나 텍스처 좌표가 다른 각도에서 시작한다는 소리니까요. 하지만 latFudge의 경우는 왜 π * -0.135가 필요한지 모르겠습니다. 그냥 좌표가 저 축을 기준으로 정렬되어 있습니다.

-

이제 만든 함수를 호출합니다.

-
loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
-  .then(parseData)
--  .then(drawData)
-+  .then(addBoxes)
-+  .then(render);
-

필요할 때만 렌더링 함수를 호출하게 해놨으므로 지구본과 육면체들을 추가한 뒤 render 함수를 직접 호출해야 합니다.

-

- -

-

위 예제를 드래그해 지구본을 돌려보면 뭔가 버벅임을 느낄 수 있을 겁니다.

-

개발자 도구를 열어 브라우저의 FPS 미터를 켜면 프레임율을 확인할 수 있습니다.

-
- -

제 환경에서는 평균 프레임이 20fps보다 낮네요.

-
- -

아마 더 안 좋은 컴퓨터에서는 이보다 더 심할 겁니다. 최적화할 방법을 찾아봐야겠네요.

-

예제의 경우 모든 정육면체를 하나의 geometry로 합치는 방법을 적용할 수 있습니다. 현재 거의 육면체를 거의 19000개 정도 렌더링했는데, 이를 하나로 합치면 연산 요청을 18999회 정도 줄일 수 있습니다.

-
function addBoxes(file) {
-  const {min, max, data} = file;
-  const range = max - min;
-
--  // 육면체 geometry를 만듭니다.
--  const boxWidth = 1;
--  const boxHeight = 1;
--  const boxDepth = 1;
--  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
--  // 중심이 아닌 양의 z축 방향으로 커지게끔 만듭니다.
--  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));
-
-  // 아래 헬퍼 Object3D는 육면체들의 위치 변화를 간단하게 만들어줍니다.
-  // lonHelper를 Y축으로 돌려 경도(longitude)를 맞출 수 있습니다.
-  const lonHelper = new THREE.Object3D();
-  scene.add(lonHelper);
-  // latHelper를 X축으로 돌려 위도(latitude)를 맞출 수 있습니다.
-  const latHelper = new THREE.Object3D();
-  lonHelper.add(latHelper);
-  // positionHelper는 다른 요소의 기준축을 구체의 끝에 맞추는 역할을 합니다.
-  const positionHelper = new THREE.Object3D();
-  positionHelper.position.z = 1;
-  latHelper.add(positionHelper);
-+  // 육면체의 중심을 옮겨 양의 Z축 방향으로 커지게 합니다.
-+  const originHelper = new THREE.Object3D();
-+  originHelper.position.z = 0.5;
-+  positionHelper.add(originHelper);
-
-  const lonFudge = Math.PI * .5;
-  const latFudge = Math.PI * -0.135;
-+  const geometries = [];
-  data.forEach((row, latNdx) => {
-    row.forEach((value, lonNdx) => {
-      if (value === undefined) {
-        return;
-      }
-      const amount = (value - min) / range;
-
--      const material = new THREE.MeshBasicMaterial();
--      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
--      const saturation = 1;
--      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
--      material.color.setHSL(hue, saturation, lightness);
--      const mesh = new THREE.Mesh(geometry, material);
--      scene.add(mesh);
-
-+      const boxWidth = 1;
-+      const boxHeight = 1;
-+      const boxDepth = 1;
-+      const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-
-      // 헬퍼들을 특정 위도와 경도로 이동시킵니다.
-      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
-      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
-
--      // positionHelper의 위치를 해당 mesh의 위치로 지정합니다.
--      positionHelper.updateWorldMatrix(true, false);
--      mesh.applyMatrix4(positionHelper.matrixWorld);
--
--      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
-
-+      // originHelper의 위치를 해당 geometry의 위치로 지정합니다.
-+      positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
-+      originHelper.updateWorldMatrix(true, false);
-+      geometry.applyMatrix4(originHelper.matrixWorld);
-+
-+      geometries.push(geometry);
-    });
-  });
-
-+  // 생성한 geometry를 전부 합칩니다.
-+  const mergedGeometry = BufferGeometryUtils.mergeGeometries(
-+      geometries, false);
-+  const material = new THREE.MeshBasicMaterial({ color:'red' });
-+  const mesh = new THREE.Mesh(mergedGeometry, material);
-+  scene.add(mesh);
-
-}
-
-

위 코드에서는 육면체의 중심을 옮기는 대신 originHelper를 새로 만들어 중심축을 옮겼습니다. 이전에는 같은 geometry를 19000번 재활용했지만, 이번에는 데이터마다 geometry를 새로 생성했죠. 또한 applyMatrix를 이용해 육면체 자체의 정점을 이동시키므로 메서드를 두 번 쓰는 대신 한 번만 썼습니다.

-

그리고 생성한 육면체를 전부 배열에 저장한 뒤 이 배열을 BufferGeometryUtils.mergeGeometries에 넘겨 하나의 geometry로 합쳤습니다.

-

물론 BufferGeometryUtils을 불러와야죠.

-
import { BufferGeometryUtils } from 'three/addons/utils/BufferGeometryUtils.js';
-
-

이제 제 컴퓨터에서는 적어도 60 프레임 이상이 나오네요.

-

- -

-

성능 문제는 해결했지만 육면체가 하나의 mesh이기에 이전과 달리 육면체의 색이 전부 같습니다. 여기서 색을 따로 지정하려면? 여러 방법이 있겠지만 정점 색(vertex color)을 쓰는 방법이 제일 간단할 겁니다.

-

정점 색은 정점마다 색을 지정합니다. 각 육면체의 각 정점을 다른 색으로 지정하면 육면체의 색을 다르게 지정할 수 있겠죠.

-
+const color = new THREE.Color();
-
-const lonFudge = Math.PI * .5;
-const latFudge = Math.PI * -0.135;
-const geometries = [];
-data.forEach((row, latNdx) => {
-  row.forEach((value, lonNdx) => {
-    if (value === undefined) {
-      return;
-    }
-    const amount = (value - min) / range;
-
-    const boxWidth = 1;
-    const boxHeight = 1;
-    const boxDepth = 1;
-    const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-
-    // 헬퍼들을 특정 위도와 경도로 이동시킵니다.
-    lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
-    latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
-
-    // originHelper의 위치를 해당 geometry의 위치로 지정합니다.
-    positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
-    originHelper.updateWorldMatrix(true, false);
-    geometry.applyMatrix4(originHelper.matrixWorld);
-
-+    // 색상값을 계산합니다.
-+    const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
-+    const saturation = 1;
-+    const lightness = THREE.MathUtils.lerp(0.4, 1.0, amount);
-+    color.setHSL(hue, saturation, lightness);
-+    // RGB 색상값을 0부터 255까지의 배열로 변환합니다.
-+    const rgb = color.toArray().map(v => v * 255);
-+
-+    // 각 정점의 색을 배열로 저장합니다.
-+    const numVerts = geometry.getAttribute('position').count;
-+    const itemSize = 3;  // r, g, b
-+    const colors = new Uint8Array(itemSize * numVerts);
-+
-+    // 색상값을 각 정점에 지정할 색상으로 변환합니다.
-+    colors.forEach((v, ndx) => {
-+      colors[ndx] = rgb[ndx % 3];
-+    });
-+
-+    const normalized = true;
-+    const colorAttrib = new THREE.BufferAttribute(colors, itemSize, normalized);
-+    geometry.setAttribute('color', colorAttrib);
-
-    geometries.push(geometry);
-  });
-});
-
-

추가한 코드에서는 먼저 geometry의 position 속성을 가져와 정점의 개수를 파악했습니다. 그런 다음 색상을 지정하기 위해 Uint8Array로 변환한 뒤, 이를 geometry.setAttribute 메서드로 geometry의 color 속성에 지정했죠.

-

마지막으로 재질(material)이 정점 색상을 사용하도록 설정합니다.

-
const mergedGeometry = BufferGeometryUtils.mergeGeometries(
-    geometries, false);
--const material = new THREE.MeshBasicMaterial({color:'red'});
-+const material = new THREE.MeshBasicMaterial({
-+  vertexColors: true,
-+});
-const mesh = new THREE.Mesh(mergedGeometry, material);
-scene.add(mesh);
-
-

이제 색이 다시 정상적으로 보입니다.

-

- -

-

geometry를 합치는 건 꽤 자주 사용하는 최적화 기법입니다. 예를 들어 나무 100개를 하나의 geometry로 합치거나, 돌무더기를 하나의 돌 geometry로 합치거나, 울타리의 각 기둥을 하나의 mesh로 합치는 경우가 해당되죠. 마인크래프트의 경우도 모든 타일을 하나하나 다 렌더링하기보다 타일을 하나로 합쳐 보이지 않는 면은 제거하는 기법을 사용할 확률이 높습니다.

-

하지만 요소를 하나의 mesh로 합쳐버리면 별도의 요소였던 특정 부분을 조작하기가 어렵습니다. 상황에 따라 좋은 방법도 다 다를 테죠. 다음 글에서는 그 방법 중 하나를 살펴보겠습니다.

-

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/picking.html b/manual/ko/picking.html deleted file mode 100644 index 9560fbc6b0647f..00000000000000 --- a/manual/ko/picking.html +++ /dev/null @@ -1,396 +0,0 @@ - - - 피킹(Picking) - - - - - - - - - - - - - - -
-
-

피킹(Picking)

-
-
-
-

피킹(picking)이란 사용자가 클릭 또는 터치한 물체를 가려내는 작업을 말합니다. 피킹을 구현하는 방법은 수없이 많지만, 각자 단점이 있습니다. 이 글에서는 이 방법 중 흔히 사용하는 2가지 방법만 살펴보겠습니다.

-

아마 피킹을 구현하는 가장 흔한 방법은 광선 투사(ray casting)일 겁니다. 광선 투사란 포인터(커서)에서 장면의 절두체로 광선을 쏴 광선이 닿는 물체를 감지하는 기법을 말하죠. 이론적으로 가장 간단한 방법입니다.

-

먼저 포인터의 좌표를 구한 뒤, 이 좌표를 카메라의 시선과 방향에 따라 3D 좌표로 변환합니다. 그리고 near 면에서 far 면까지의 광선을 구해 이 광선이 장면 안 각 물체의 삼각형과 교차하는지 확인합니다. 만약 장면 안에 1000개의 삼각형을 가진 물체가 1000개 있다면 백만 개의 삼각형을 일일이 확인해야 하는 셈이죠.

-

이를 최적화하려면 몇 가지 방법을 시도해볼 수 있습니다. 하나는 먼저 물체를 감싼 경계(bounding) 좌표가 광선과 교차하는지 확인하고, 교차하지 않는다면 해당 물체의 삼각형을 확인하지 않는 것이죠.

-

Three.js에는 이런 작업을 대신해주는 RayCaster 클래스가 있습니다.

-

한번 물체 100개가 있는 장면을 만들어 여기서 피킹을 구현해봅시다. 예제는 반응형 디자인에서 썼던 예제를 가져와 사용하겠습니다.

-

우선 카메라를 별도 Object3D의 자식으로 추가해 카메라가 셀카봉처럼 장면 주위를 돌 수 있도록 합니다.

-
*const fov = 60;
-const aspect = 2;  // 캔버스 기본값
-const near = 0.1;
-*const far = 200;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-*camera.position.z = 30;
-
-const scene = new THREE.Scene();
-+scene.background = new THREE.Color('white');
-
-+// 카메라를 봉(pole)에 추가합니다.
-+// 이러면 봉을 회전시켜 카메라가 장면 주위를 돌도록 할 수 있습니다
-+const cameraPole = new THREE.Object3D();
-+scene.add(cameraPole);
-+cameraPole.add(camera);
-
-

그리고 render 함수 안에서 카메라 봉을 돌립니다.

-
cameraPole.rotation.y = time * .1;
-
-

또한 카메라에 조명을 추가해 조명이 카메라와 같이 움직이도록 합니다.

-
-scene.add(light);
-+camera.add(light);
-
-

정육면체 100개의 위치, 방향, 크기를 무작위로 설정해 생성합니다.

-
const boxWidth = 1;
-const boxHeight = 1;
-const boxDepth = 1;
-const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-
-function rand(min, max) {
-  if (max === undefined) {
-    max = min;
-    min = 0;
-  }
-  return min + (max - min) * Math.random();
-}
-
-function randomColor() {
-  return `hsl(${ rand(360) | 0 }, ${ rand(50, 100) | 0 }%, 50%)`;
-}
-
-const numObjects = 100;
-for (let i = 0; i < numObjects; ++i) {
-  const material = new THREE.MeshPhongMaterial({
-    color: randomColor(),
-  });
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  cube.position.set(rand(-20, 20), rand(-20, 20), rand(-20, 20));
-  cube.rotation.set(rand(Math.PI), rand(Math.PI), 0);
-  cube.scale.set(rand(3, 6), rand(3, 6), rand(3, 6));
-}
-
-

이제 피킹을 구현해봅시다.

-

피킹을 관리할 간단한 클래스를 만들겠습니다.

-
class PickHelper {
-  constructor() {
-    this.raycaster = new THREE.Raycaster();
-    this.pickedObject = null;
-    this.pickedObjectSavedColor = 0;
-  }
-  pick(normalizedPosition, scene, camera, time) {
-    // 이미 다른 물체를 피킹했다면 색을 복원합니다
-    if (this.pickedObject) {
-      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
-      this.pickedObject = undefined;
-    }
-
-    // 절두체 안에 광선을 쏩니다
-    this.raycaster.setFromCamera(normalizedPosition, camera);
-    // 광선과 교차하는 물체들을 배열로 만듭니다
-    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
-    if (intersectedObjects.length) {
-      // 첫 번째 물체가 제일 가까우므로 해당 물체를 고릅니다
-      this.pickedObject = intersectedObjects[0].object;
-      // 기존 색을 저장해둡니다
-      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
-      // emissive 색을 빨강/노랑으로 빛나게 만듭니다
-      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
-    }
-  }
-}
-
-

위 클래스는 먼저 RayCaster 인스턴스를 만들고 pick 메서드를 호출하면 장면에 광선을 쏠 수 있게 해줍니다. 그리고 광선에 맞는 요소가 있으면 해당 요소 중 가장 첫 번째 요소의 색을 변경합니다.

-

사용자가 마우스를 눌렀을 때(down)만 이 함수가 작동하도록 할 수도 있지만, 예제에서는 마우스 포인터 아래의 있는 요소를 피킹하도록 하겠습니다. 이를 구현하려면 먼저 포인터를 추적해야 합니다.

-
const pickPosition = { x: 0, y: 0 };
-clearPickPosition();
-
-...
-
-function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function setPickPosition(event) {
-  const pos = getCanvasRelativePosition(event);
-  pickPosition.x = (pos.x / canvas.width ) *  2 - 1;
-  pickPosition.y = (pos.y / canvas.height) * -2 + 1;  // Y 축을 뒤집었음
-}
-
-function clearPickPosition() {
-  /**
-   * 마우스의 경우는 항상 위치가 있어 그다지 큰
-   * 상관이 없지만, 터치 같은 경우 사용자가 손가락을
-   * 떼면 피킹을 멈춰야 합니다. 지금은 일단 어떤 것도
-   * 선택할 수 없는 값으로 지정해두었습니다
-   **/
-  pickPosition.x = -100000;
-  pickPosition.y = -100000;
-}
-
-window.addEventListener('mousemove', setPickPosition);
-window.addEventListener('mouseout', clearPickPosition);
-window.addEventListener('mouseleave', clearPickPosition);
-
-

위 예제에서는 마우스의 좌표를 정규화(normalize)했습니다. 캔버스의 크기와 상관없이 왼쪽 끝이 -1, 오른쪽 끝이 +1인 벡터값이 필요하기 때문이죠. 마찬가지로 아래쪽 끝은 -1, 위쪽 끝은 +1입니다.

-

모바일도 환경도 지원하기 위해 리스너를 더 추가하겠습니다.

-
window.addEventListener('touchstart', (event) => {
-  event.preventDefault(); // 스크롤 이벤트 방지
-  setPickPosition(event.touches[0]);
-}, { passive: false });
-
-window.addEventListener('touchmove', (event) => {
-  setPickPosition(event.touches[0]);
-});
-
-window.addEventListener('touchend', clearPickPosition);
-
-

마지막으로 render 함수에서 PickHelperpick 메서드를 호출합니다.

-
+const pickHelper = new PickHelper();
-
-function render(time) {
-  time *= 0.001;  // 초 단위로 변환
-
-  ...
-
-+  pickHelper.pick(pickPosition, scene, camera, time);
-
-  renderer.render(scene, camera);
-
-  ...
-
-

결과를 볼까요?

-

- -

-

딱히 문제는 없어 보입니다. 실제로 사용하는 경우도 대부분 문제 없이 잘 되겠지만, 이 방법에는 몇 가지 문제점이 있습니다.

-
    -
  1. CPU의 자원을 사용한다

    -

    자바스크립트 엔진은 각 요소를 돌며 광선이 요소의 경계 좌표 안에 교차하는지 확인합니다. 만약 교차할 경우, 해당 요소의 삼각형을 전부 돌며 광선과 교차하는 삼각형이 있는지 확인합니다.

    -

    이 방식의 장점은 자바스크립트가 교차하는 지점을 정확히 계산해 해당 데이터를 넘겨줄 수 있다는 점입니다. 예를 들어 교차가 발생한 지점에 특정 표시를 할 수 있겠죠.

    -

    대신 CPU가 할 일이 더 늘어난다는 점이 단점입니다. 요소가 가진 삼각형이 많을수록 더 느려지겠죠.

    -
  2. -
  3. 특이한 방식의 쉐이더나 변이를 감지하지 못한다

    -

    만약 장면에서 geometry를 변형하는 쉐이더를 사용한다면, 자바스크립트는 이 변형을 감지하지 못하기에 잘못된 값을 내놓을 겁니다. 제가 테스트해본 결과 스킨이 적용된 요소에는 이 방법이 먹히지 않습니다.

    -
  4. -
  5. 요소의 투명한 구멍을 처리하지 못한다.

    -
  6. -
-

예제를 하나 만들어보죠. 아래와 같은 텍스처를 정육면체에 적용해봅시다.

-
- -

그다지 추가할 건 많지 않습니다.

-
+const loader = new THREE.TextureLoader();
-+const texture = loader.load('resources/images/frame.png');
-
-const numObjects = 100;
-for (let i = 0; i < numObjects; ++i) {
-  const material = new THREE.MeshPhongMaterial({
-    color: randomColor(),
-    +map: texture,
-    +transparent: true,
-    +side: THREE.DoubleSide,
-    +alphaTest: 0.1,
-  });
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  ...
-
-

예제를 실행시키면 바로 문제가 보일 겁니다.

-

- -

-

정육면체의 빈 공간을 통해 무언가를 선택할 수가 없죠.

-
- -

이는 자바스크립트가 텍스처나 재질을 보고 해당 요소가 투명한지 판단하기가 어렵기 때문입니다.

-

이 문제를 해결하려면 GPU 기반 피킹을 구현해야 합니다. 이론적으로는 간단하지만 위에서 사용한 광선 투사법보다는 좀 더 복잡하죠.

-

GPU 피킹을 구현하려면 각 요소를 별도의 화면에서 고유한 색상으로 렌더링해야 합니다. 그리고 포인터 아래에 있는 픽셀의 색을 가져와 해당 요소가 선택됐는지 확인하는 거죠.

-

이러면 위에서 언급한 문제점 2, 3번이 해결됩니다. 1번, 성능의 경우는 상황에 따라 천차만별이죠. 눈에 보이는 화면을 위해 한 번, 피킹을 위해 한 번, 이렇게 매 요소를 총 두 번씩 렌더링해야 합니다. 더 복잡한 해결책을 쓰면 렌더링을 한 번만 할 수도 있지만, 이 글에서는 일단 더 간단한 방법을 사용하겠습니다.

-

성능 최적화를 위해 시도할 수 있는 방법이 하나 있습니다. 어차피 픽셀을 하나만 읽을 것이니, 카메라를 픽셀 하나만 렌더링하도록 설정하는 것이죠. PerspectiveCamera.setViewOffset 메서드를 사용하면 카메라의 특정 부분만 렌더링하도록 할 수 있습니다. 이러면 성능 향상에 조금이나마 도움이 되겠죠.

-

현재 Three.js에서 이 기법을 구현하려면 장면 2개를 사용해야 합니다. 하나는 기존 mesh를 그대로 쓰고, 나머지 하나는 피킹용 재질을 적용한 mesh를 쓸 겁니다.

-

먼저 두 번째 장면을 추가하고 배경을 검정으로 지정합니다.

-
const scene = new THREE.Scene();
-scene.background = new THREE.Color('white');
-const pickingScene = new THREE.Scene();
-pickingScene.background = new THREE.Color(0);
-
-

각 정육면체를 장면에 추가할 때 pickingScene의 같은 위치에 "피킹용 정육면체"를 추가합니다. 그리고 각 피킹용 정육면체에는 id로 쓸 고유 색상값을 지정한 뒤, 이 id 색상값으로 재질을 만들어 추가합니다. id 색상값을 정육면체의 키값으로 매핑해 놓으면 나중에 상응하는 정육면체를 바로 불러올 수 있겠죠.

-
const idToObject = {};
-+const numObjects = 100;
-for (let i = 0; i < numObjects; ++i) {
-+  const id = i + 1;
-  const material = new THREE.MeshPhongMaterial({
-    color: randomColor(),
-    map: texture,
-    transparent: true,
-    side: THREE.DoubleSide,
-    alphaTest: 0.1,
-  });
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-+  idToObject[id] = cube;
-
-  cube.position.set(rand(-20, 20), rand(-20, 20), rand(-20, 20));
-  cube.rotation.set(rand(Math.PI), rand(Math.PI), 0);
-  cube.scale.set(rand(3, 6), rand(3, 6), rand(3, 6));
-
-+  const pickingMaterial = new THREE.MeshPhongMaterial({
-+    emissive: new THREE.Color().setHex(id, THREE.NoColorSpace),
-+    color: new THREE.Color(0, 0, 0),
-+    specular: new THREE.Color(0, 0, 0),
-+    map: texture,
-+    transparent: true,
-+    side: THREE.DoubleSide,
-+    alphaTest: 0.5,
-+    blending: THREE.NoBlending,
-+  });
-+  const pickingCube = new THREE.Mesh(geometry, pickingMaterial);
-+  pickingScene.add(pickingCube);
-+  pickingCube.position.copy(cube.position);
-+  pickingCube.rotation.copy(cube.rotation);
-+  pickingCube.scale.copy(cube.scale);
-}
-
-

위 코드에서는 MeshPhongMaterial로 편법을 사용했습니다. emissive 속성을 id 색상값으로, colorspecular 속성을 0으로 설정하면 텍스처의 알파값이 alphaTest보다 큰 부분만 id 색상값으로 보이겠죠. 또 blending 속성을 THREE.NoBlending으로 설정해 id 색상값이 알파값의 영향을 받지 않도록 했습니다.

-

제가 사용한 편법이 최적의 해결책은 아닙니다. 여러가지 옵션을 껐다고 해도 여전히 조명 관련 연산을 실행할 테니까요. 코드를 더 최적화하려면 alphaTest 값보다 높은 경우에만 id 색상을 렌더링하는 쉐이더를 직접 만들어야 합니다.

-

광선 투사법을 쓸 때와 달리 픽셀을 하나만 사용하므로 위치값이 픽셀 하나만 가리키게 변경합니다.

-
function setPickPosition(event) {
-  const pos = getCanvasRelativePosition(event);
--  pickPosition.x = (pos.x / canvas.clientWidth ) *  2 - 1;
--  pickPosition.y = (pos.y / canvas.clientHeight) * -2 + 1;  // Y 축을 뒤집었음
-+  pickPosition.x = pos.x;
-+  pickPosition.y = pos.y;
-}
-
-

PickHelper 클래스도 GPUPickHelper로 변경합니다. 렌더 타겟(render target)에 관한 글에서 다룬 WebGLRenderTarget을 써 구현하되, 이번 렌더 타겟의 크기는 1x1, 1픽셀입니다.

-
-class PickHelper {
-+class GPUPickHelper {
-  constructor() {
--    this.raycaster = new THREE.Raycaster();
-+    // 1x1 픽셀 크기의 렌더 타겟을 생성합니다
-+    this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
-+    this.pixelBuffer = new Uint8Array(4);
-    this.pickedObject = null;
-    this.pickedObjectSavedColor = 0;
-  }
-  pick(cssPosition, scene, camera, time) {
-+    const {pickingTexture, pixelBuffer} = this;
-
-    // 기존에 선택된 요소가 있는 경우 색을 복원합니다
-    if (this.pickedObject) {
-      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
-      this.pickedObject = undefined;
-    }
-
-+    // view offset을 마우스 포인터 아래 1픽셀로 설정합니다
-+    const pixelRatio = renderer.getPixelRatio();
-+    camera.setViewOffset(
-+        renderer.getContext().drawingBufferWidth,   // 전체 너비
-+        renderer.getContext().drawingBufferHeight,  // 전체 높이
-+        cssPosition.x * pixelRatio | 0,             // 사각 x 좌표
-+        cssPosition.y * pixelRatio | 0,             // 사각 y 좌표
-+        1,                                          // 사각 좌표 width
-+        1,                                          // 사각 좌표 height
-+    );
-+    // 장면을 렌더링합니다
-+    renderer.setRenderTarget(pickingTexture)
-+    renderer.render(scene, camera);
-+    renderer.setRenderTarget(null);
-+
-+    // view offset을 정상으로 돌려 원래의 화면을 렌더링하도록 합니다
-+    camera.clearViewOffset();
-+    // 픽셀을 감지합니다
-+    renderer.readRenderTargetPixels(
-+        pickingTexture,
-+        0,   // x
-+        0,   // y
-+        1,   // width
-+        1,   // height
-+        pixelBuffer);
-+
-+    const id =
-+        (pixelBuffer[0] << 16) |
-+        (pixelBuffer[1] <<  8) |
-+        (pixelBuffer[2]      );
-
-    // 절두체 안에 광선을 쏩니다
--    this.raycaster.setFromCamera(normalizedPosition, camera);
-    // 광선과 교차하는 물체들을 배열로 만듭니다
--    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
--    if (intersectedObjects.length) {
-      // 첫 번째 물체가 제일 가까우므로 해당 물체를 고릅니다
--      this.pickedObject = intersectedObjects[0].object;
-
-+    const intersectedObject = idToObject[id];
-+    if (intersectedObject) {
-      // 첫 번째 물체가 제일 가까우므로 해당 물체를 고릅니다
-+      this.pickedObject = intersectedObject;
-      // 기존 색을 저장해둡니다
-      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
-      // emissive 색을 빨강/노랑으로 빛나게 만듭니다
-      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
-    }
-  }
-}
-
-

인스턴스를 만드는 쪽도 수정합니다.

-
-const pickHelper = new PickHelper();
-+const pickHelper = new GPUPickHelper();
-
-

pick 메서드를 호출할 때 scene 대신 pickScene을 넘겨줍니다.

-
-  pickHelper.pick(pickPosition, scene, camera, time);
-+  pickHelper.pick(pickPosition, pickScene, camera, time);
-
-

이제 투명한 부분을 관통해 요소를 선택할 수 있습니다.

-

- -

-

이 글이 피킹을 구현하는 데 도움이 되었으면 좋겠네요. 나중에 요소를 마우스로 조작하는 법에 대해서도 한 번 써보겠습니다.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/post-processing.html b/manual/ko/post-processing.html deleted file mode 100644 index e2911dcb909785..00000000000000 --- a/manual/ko/post-processing.html +++ /dev/null @@ -1,220 +0,0 @@ - - - 후처리 - - - - - - - - - - - - - - -
-
-

후처리

-
-
-
-

후처리(post processing)란 보통 2D 이미지에 어떤 효과나 필터를 넣는 것을 의미합니다. Three.js는 다양한 mesh로 이루어진 장면을 2D 이미지로 렌더링하죠. 일반적으로 이 이미지는 바로 캔버스를 통해 브라우저 화면에 렌더링됩니다. 하지만 대신 이 이미지를 렌더 타겟에 렌더링하고 캔버스에 보내기 전 임의의 후처리 효과를 줄 수 있습니다.

-

인스타그램 필터, 포토샵 필터 등이 후처리의 좋은 예이죠.

-

Three.js에는 후처리를 순차적으로 처리해주는 모범 클래스가 있습니다. 일단 EffectComposer의 인스턴스를 만들고 여기에 Pass 객체(효과, 필터)들을 추가합니다. 그리고 EffectComposer.render 메서드를 호출하면 현재 장면을 렌더 타겟에 렌더링한 뒤 각 pass*를 순서대로 적용합니다.

-

※ 편의상 Pass 인스턴스를 pass로 번역합니다.

-

이 pass는 비넷(vignette), 흐림(blur), 블룸(bloom), 필름 그레인(film grain) 효과 또는 hue, 채도(saturation), 대비(contrast) 조정 등의 후처리 효과로, 이 효과를 모두 적용한 결과물을 최종적으로 캔버스에 렌더링합니다.

-

여기서 어느 정도 EffectComposer의 원리를 이해할 필요가 있습니다. EffectComposer는 두 개의 렌더 타겟을 사용합니다. 편의상 이 둘을 rtA, rtB라고 부르도록 하죠.

-

EffectComposer.addPass를 각 pass를 적용할 순서대로 호출하고 EffectComposer.render를 호출하면 pass*는 아래 그림과 같은 순서로 적용됩니다.

-
- -

먼저 RenderPass에 넘긴 장면을 rtA에 렌더링합니다. 그리고 rtA를 다음 pass에 넘겨주면 해당 pass는 rtA에 pass를 적용한 결과를 rtB에 렌더링합니다. 그런 다음 rtB를 다음 pass로 넘겨 적용한 결과를 rtA에, rtA에 pass를 적용한 결과를 다시 rtB에, 이런 식으로 모든 pass가 끝날 때까지 계속 반복합니다.

-

Pass에는 공통적으로 4가지 옵션이 있습니다.

-

enabled

-

이 pass를 사용할지의 여부입니다.

-

needsSwap

-

이 pass를 적용한 후 rtArtB를 바꿀지의 여부입니다.

-

clear

-

이 pass를 적용하기 전에 화면을 초기화할지의 여부입니다.

-

renderToScreen

-

지정한 렌더 타겟이 아닌 캔버스에 렌더링할지의 여부입니다. 보통 EffectComposer에 추가하는 마지막 pass에 이 옵션을 true로 설정합니다.

-

간단한 예제를 만들어봅시다. 반응형 디자인에 관한 글에서 썼던 예제를 가져오겠습니다.

-

추가로 먼저 EffectComposer 인스턴스를 생성합니다.

-
const composer = new EffectComposer(renderer);
-
-

다음으로 RenderPass를 첫 pass로 추가합니다. 이 pass는 넘겨 받은 장면을 첫 렌더 타겟에 렌더링할 겁니다.

-
composer.addPass(new RenderPass(scene, camera));
-
-

다음으로 BloomPass를 추가합니다. BloomPass는 장면을 원래의 장면보다 작게 렌더링해 흐림(blur) 효과를 줍니다. 그리고 효과가 적용된 장면을 원래 장면에 덮어 씌우는 식으로 블룸 효과를 구현합니다.

-
const bloomPass = new BloomPass(
-    1,    // 강도
-    25,   // 커널(kernel) 크기
-    4,    // 시그마 ?
-    256,  // 렌더 타겟의 해상도를 낮춤
-);
-composer.addPass(bloomPass);
-
-

마지막으로 원본 장면에 노이즈와 스캔라인(scanline)을 추가하는 FilmPass를 추가합니다.

-
const filmPass = new FilmPass(
-    0.5,   // 강도
-    false,  // 흑백
-);
-composer.addPass(filmPass);
-
-

또 이 클래스들을 사용하기 위해 여러 스크립트를 불러와야 합니다.

-
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
-import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
-import { BloomPass } from 'three/addons/postprocessing/BloomPass.js';
-import { FilmPass } from 'three/addons/postprocessing/FilmPass.js';
-import {OutputPass} from 'three/addons/postprocessing/OutputPass.js';
-
-

대부분의 후처리에는 EffectComposer.jsRenderPass.js가 필수입니다.

-

이제 WebGLRenderer.render 대신 EffectComposer.render를 사용하고 EffectComposer가 결과물을 캔버스의 크기에 맞추도록 해야 합니다.

-
-function render(now) {
--  time *= 0.001;
-+let then = 0;
-+function render(now) {
-+  now *= 0.001;  // 초 단위로 변환
-+  const deltaTime = now - then;
-+  then = now;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-+    composer.setSize(canvas.width, canvas.height);
-  }
-
-  cubes.forEach((cube, ndx) => {
-    const speed = 1 + ndx * .1;
--    const rot = time * speed;
-+    const rot = now * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
--  renderer.render(scene, camera);
-+  composer.render(deltaTime);
-
-  requestAnimationFrame(render);
-}
-
-

EffectComposer.render 메서드는 인자로 마지막 프레임을 렌더링한 이후의 시간값인 deltaTime을 인자로 받습니다. pass에 애니메이션이 필요할 경우를 대비해 이 값을 넘겨주기 위해서이죠. 예제의 경우에는 FilmPass에 애니메이션이 있습니다.

-

- -

-

런타임에 효과의 속성을 변경할 때는 보통 uniform의 value 값을 바꿉니다. GUI를 추가해 이 속성을 조정할 수 있게 만들어보죠. 어떤 속성을 어떻게 조작할 수 있는지는 해당 효과의 소스 코드를 열어봐야 알 수 있습니다.

-

BloomPass.js에서 -아래 코드를 찾았습니다.

-
this.copyUniforms[ "opacity" ].value = strength;
-
-

아래처럼 하면 강도를 런타임에 바꿀 수 있겠네요.

-
bloomPass.copyUniforms.opacity.value = someValue;
-
-

마찬가지로 FilmPass.js에서 -아래 코드를 찾았습니다.

-
this.uniforms.intensity.value = intensity;
-this.uniforms.grayscale.value = grayscale;
-
-

이제 어떻게 값을 지정해야 하는지 알았으니 이 값을 조작하는 GUI를 만들어봅시다.

-
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
-
-

일단 모듈을 로드합니다.

-
const gui = new GUI();
-{
-  const folder = gui.addFolder('BloomPass');
-  folder.add(bloomPass.copyUniforms.opacity, 'value', 0, 2).name('strength');
-  folder.open();
-}
-{
-  const folder = gui.addFolder('FilmPass');
-  folder.add(filmPass.uniforms.grayscale, 'value').name('grayscale');
-  folder.add(filmPass.uniforms.intensity, 'value', 0, 1).name('intensity');
-  folder.open();
-}
-
-

이제 각 설정을 조작할 수 있습니다.

-

- -

-

여기까지 잘 따라왔다면 이제 효과를 직접 만들어볼 수 있습니다.

-

후처리 효과는 쉐이더를 사용합니다. 쉐이더는 GLSL (Graphics Library Shading Language)이라는 언어를 사용하죠. 언어가 방대해 이 글에서 전부 다루기는 어렵습니다. 기초부터 알아보고 싶다면 이 글쉐이더란 무엇인가(The Book of Shaders)를 읽어보기 바랍니다.

-

직접 예제를 만들어보는 게 도움이 될 테니 간단한 GLSL 후처리 쉐이더를 만들어봅시다. 이미지에 특정 색을 혼합하는 쉐이더를 만들 겁니다.

-

Three.js에는 후처리를 도와주는 ShaderPass 헬퍼 클래스가 있습니다. 인자로 vertex 쉐이더, fragment 쉐이더, 기본값으로 이루어진 객체를 받죠. 이 클래스는 이전 pass의 결과물에서 어떤 텍스처를 읽을지, 그리고 EffectComposer의 렌더 타겟과 캔버스 중 어디에 렌더링할지를 결정할 겁니다.

-

아래는 이전 pass의 결과물에 특정 색을 혼합하는 간단한 후처리 쉐이더입니다.

-
const colorShader = {
-  uniforms: {
-    tDiffuse: { value: null },
-    color:    { value: new THREE.Color(0x88CCFF) },
-  },
-  vertexShader: `
-    varying vec2 vUv;
-    void main() {
-      vUv = uv;
-      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1);
-    }
-  `,
-  fragmentShader: `
-    varying vec2 vUv;
-    uniform sampler2D tDiffuse;
-    uniform vec3 color;
-    void main() {
-      vec4 previousPassColor = texture2D(tDiffuse, vUv);
-      gl_FragColor = vec4(
-          previousPassColor.rgb * color,
-          previousPassColor.a);
-    }
-  `,
-};
-
-

위 코드에서 tDiffuse는 이전 pass의 결과물을 받아오기 위한 것으로 거의 모든 경우에 필수입니다. 그리고 그 바로 밑에 color 속성을 Three.js의 Color로 선언했습니다.

-

다음으로 vertex 쉐이더를 작성해야 합니다. 위 코드에서 작성한 vertex 쉐이더는 후처리에서 거의 표준처럼 사용하는 코드로, 대부분의 경우 바꿀 필요가 없습니다. 뭔가 많이 설정한 경우(아까 언급한 링크 참조)가 아니라면 uv, projectionMatrix, modelViewMatrix, position 변수는 Three.js가 알아서 넣어줍니다.

-

마지막으로 fragment 쉐이더를 생성합니다. 아래 코드로 이전 pass에서 넘겨준 결과물의 픽셀 색상값을 가져올 수 있습니다.

-
vec4 previousPassColor = texture2D(tDiffuse, vUv);
-
-

여기에 지정한 색상을 곱해 gl_FragColor에 결과를 저장합니다.

-
gl_FragColor = vec4(
-    previousPassColor.rgb * color,
-    previousPassColor.a);
-
-

추가로 간단한 GUI를 만들어 rgb의 각 색상값을 조정할 수 있도록 합니다.

-
const gui = new GUI();
-gui.add(colorPass.uniforms.color.value, 'r', 0, 4).name('red');
-gui.add(colorPass.uniforms.color.value, 'g', 0, 4).name('green');
-gui.add(colorPass.uniforms.color.value, 'b', 0, 4).name('blue');
-
-

색을 혼합하는 간단한 후처리 쉐이더를 완성했습니다.

-

- -

-

언급했듯 이 글에서 GLSL의 작성법과 사용자 지정 쉐이더를 만드는 법을 모두 다루기는 무리입니다. WebGL이 어떻게 동작하는지 알고 싶다면 이 시리즈를 참고하세요. Three.js의 후처리 쉐이더 소스 코드를 분석하는 것도 좋은 방법입니다. 상대적으로 복잡한 쉐이더도 있지만 작은 것부터 차근차근 살펴본다면 언젠가 전체를 이해할 수 있을 거예요.

-

아쉽게도 Three.js의 후처리 효과 대부분은 공식 문서가 없어 예제를 참고하거나 후처리 효과의 소스 코드를 직접 분석해야 합니다. 부디 이 글과 이 시리즈의 렌더 타겟에 관한 글이 좋은 출발점을 마련해주었으면 좋겠네요.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/prerequisites.html b/manual/ko/prerequisites.html deleted file mode 100644 index 1d742eb2a15ccb..00000000000000 --- a/manual/ko/prerequisites.html +++ /dev/null @@ -1,321 +0,0 @@ - - - 먼저 알아야 할 것들 - - - - - - - - - - - - - - -
-
-

먼저 알아야 할 것들

-
-
-
-

이 시리즈는 Three.js에 입문하는 초심자를 위한 튜토리얼입니다. 이 시리즈는 -독자가 최소한 자바스크립트 기본 프로그래밍에 익숙하며, DOM이 무엇인지 설명할 -수 있고, HTML과 자바스크립트로 DOM 요소를 조작할 수 있다는 전제 하에 작성하였습니다. -또한 <script type="module"> 태그로 ES2015 모듈을 -불러올 수 있으며, CSS 셀렉터가 무엇인지도 -알고, ES5, ES2015, 약간의 ES2016도 알며, 브라우저가 자바스크립트를 이벤트와 콜백으로만 -실행한다는 것도 알고, 클로저가 무엇인지까지 안다고 가정했죠.

-

이번 글에서는 시리즈를 읽는 데 필요한 이런 기본 전제에 대해 간단히 환기하고 -넘어가겠습니다.

-

ES2015 모듈

-

ES2015 모듈은 스크립트 안에서 import 키워드나, 인라인 <script type="module"> -태그로 불러올 수 있습니다. 두 가지 예시를 동시에 써보죠.

-
<script type="module">
-import * as THREE from 'three';
-
-...
-
-</script>
-
-

모듈의 경로는 반드시 상대 경로나 절대 경로여야 합니다. <img>, <a> 태그, CSS 경로와 -달리 여기서 상대 경로란 ./이나 ../로 시작하는 경로를 말합니다.

-

더 자세한 것은 이 글의 마지막 부분을 참고하세요.

-

document.querySelectordocument.querySelectorAll

-

요소를 선택할 때는 document.querySelectordocument.querySelectorAll을 -사용하면 됩니다. 첫 번째는 CSS 셀렉터와 일치하는 첫 번째 요소를 반환하고, 두 번째는 -CSS 셀렉터와 일치하는 모든 요소를 반환하죠.

-

onbody를 쓰지 마세요

-

옛날에 개발된 많은 페이지가 body 태그의 onload 속성을 사용합니다.

-
<body onload="somefunction()">
-

이런 스타일은 더 이상 권장하지 않습니다. script 태그를 페이지의 끝에 삽입하세요.

-
<html>
-  <head>
-    ...
-  </head>
-  <body>
-     ...
-  </body>
-  <script>
-    // inline javascript
-  </script>
-</html>
-
-

또는 defer 속성을 사용하는 -것이 좋습니다.

-

클로저(closure)란?

-
function a(v) {
-  const foo = v;
-  return function() {
-     return foo;
-  };
-}
-
-const f = a(123);
-const g = a(456);
-console.log(f());  // 123 출력
-console.log(g());  // 456 출력
-
-

위 예제에서 함수 a는 매번 호출할 때마다 새로운 함수를 반환합니다. 이는 상수 foo에 -대한 클로저이죠. 자세한 건 여기를 -참고하기 바랍니다.

-

this 이해하기

-

this는 마법이 아닙니다. this는 다른 인자들처럼 자동으로 함수에 넘겨지는 일종의 변수이죠. -간단히 함수를 직접 호출하는 경우를 예로 들어보겠습니다.

-
somefunction(a, b, c);
-

여기서 thisnull입니다(엄격(strict) 모드나 모듈 안에서). 반면 . 연산자를 붙여 메서드로 -호출하는 경우,

-
someobject.somefunction(a, b, c);
-

thissomeobject로 지정될 겁니다.

-

많은 사람들이 헷갈리는 부분은 메서드를 콜백 처리할 때이죠.

-
 const callback = someobject.somefunction;
- loader.load(callback);
-

자바스크립트에 능숙하지 않은 사람이 보기에는 문제가 없을지 모르나, loader.load는 콜백 함수를 -. 연산자를 써서 호출하지 않으므로-loader가 별도로 this를 지정하지 않는 한-this는 null이 -됩니다. 콜백 함수를 호출할 때 thissomeobject로 지정하려면 명시적으로 this를 종속시켜야(binding) -합니다.

-
 const callback = someobject.somefunction.bind(someobject);
- loader.load(callback);
-

this를 이해하기 어렵다면, this에 관한 문서를 -한 번 읽어보길 권합니다.

-

ES5/ES2015/ES2016

-

var 대신 constlet 사용하기

-

constlet을 쓸 수 있는 환경에서 var를 써야할 이유는 전혀 없고, 지금 var를 사용하는 -것은 실력 향상에 전혀 도움이 되지 않습니다. 변수를 재할당할 일이 없을 경우 const를 사용하세요. -변수를 재할당하는 것은 흔치 않은 일이니, 주로 const를 더 많이 쓰게 될 겁니다. 변수의 값을 바꿔야 -한다면 let을 사용하세요. 이런 습관을 들이면 버그를 훨씬 더 많이 줄일 수 있습니다.

-

for(elem in collection) 대신 for(elem of collection) 사용하기

-

for offor in의 문제를 해결하기 위해 새로 추가된 문법입니다.

-

예를 들어 객체의 키/값 쌍을 반복문으로 돌릴 경우 다음과 같이 쓸 수 있죠.

-
for (const [key, value] of Object.entries(someObject)) {
-  console.log(key, value);
-}
-
-

forEach, map, filter 등을 적절히 활용하기

-

ES5에서 배열에 forEach, -map -메서드 등이 추가되었고, ES2015에서는 filter -메서드 등 여러 유용한 메서드가 추가되었습니다.

-

구조분해할당(destructuring) 사용하기

-

const dims = { width: 300, height: 150 }. 이런 객체가 있다고 해보죠. 객체의 -각 속성을 별도의 변수에 할당하고자 합니다.

-

기존 방법

-
const width = dims.width;
-const height = dims.height;
-
-

새로운 방법

-
const { width, height } = dims;
-
-

구조분해할당은 배열에도 적용할 수 있습니다. const position = [1, 2, 3, 4]. -이런 배열이 있다고 해보죠.

-

기존 방법

-
const x = position[2];
-const y = position[1];
-
-

새로운 방법

-
const [ , y, x ] = position;
-
-

또한 매개변수에도 구조분해할당을 적용할 수 있습니다.

-
const dims = { width: 300, height: 150 };
-const position = [1, 2, 3, 4];
-
-function distFromOrig([x, y]) {
-  return Math.sqrt(x * x + y * y);
-}
-
-const dist = distFromOrig(position);  // dist = 2.236...
-
-function area({ width, height }) {
-  return width * height;
-}
-const a = area(dims);  // a = 45000
-
-

객체 선언 시 축약 문법 사용

-

기존 방법

-
 const width = 300;
- const height = 150;
- const obj = {
-   width: width,
-   height: height,
-   area: function() {
-     return this.width * this.height
-   },
- };
-
-

새로운 방법

-
 const width = 300;
- const height = 150;
- const obj = {
-   width,
-   height,
-   area() {
-     return this.width * this.height;
-   },
- };
-
-

전개 연산자 ... 사용하기

-

전개 연산자는 매우 유용합니다. 예를 들어보죠.

-
 function log(className, ...args) {
-   const elem = document.createElement('div');
-   elem.className = className;
-   elem.textContent = args.join(' ');
-   document.body.appendChild(elem);
- }
-
-

또 배열을 인자로 넘겨줄 때도 유용합니다.

-
const position = [1, 2, 3];
-somemesh.position.set(...position);
-
-

배열을 얕은 복사할 때 사용할 수도 있고

-
const copiedPositionArray = [...position];
-copiedPositionArray.push(4); // [1,2,3,4]
-console.log(position); // [1,2,3] 기존 배열은 영향을 받지 않음
-
-

객체를 합칠 때도 사용할 수 있죠.

-
const a = { abc: 123 };
-const b = { def: 456 };
-const c = { ...a, ...b };  // c = { abc: 123, def: 456 }
-

class 사용하기

-

ES5 이하의 문법으로 클래스 스타일의 객체를 만드는 방법은 다른 개발자들에게 낯선 요소 -중 하나였습니다. ES2015부터는 C++/C#/Java 등 다른 객체지향 언어처럼 class 키워드를 사용해 -클래스를 생성할 수 있습니다.

-

getter와 setter

-

모던 프로그래밍 언어에는 대부분 -getter와 -setter가 -있습니다. ES2015의 class 문법을 사용하면 훨씬 쉽게 getter와 setter를 설정할 수 있죠.

-

화살표 함수(arrow function) 활용하기

-

화살표 함수는 특히 콜백과 프로미스를 처리할 때 유용합니다.

-
loader.load((texture) => {
-  // 불러온 텍스처를 사용
-});
-
-

화살표 함수는 this 값을 지정하지 않습니다.

-

(※ 원문에서는 "Arrow functions bind this.(화살표 함수는 this를 바인딩한다.)"라고 적었지만, -표준에 기재된 바 화살표 함수는 익명 함수로 this, arguments, super 또는 new.target을 지정하지 -않습니다. 혼돈을 막기 위해 달리 번역하였으니 참고 바랍니다. 역주. 출처: MDN)

-
const foo = (args) => {/* code */};
-
-

위 예제는 다음과 과 같죠.

-
const foo = (function(args) {/* code */}).bind(this));
-
-

프로미스(Promise)와 async/await

-

프로미스는 비동기 처리를 도와줍니다. async/await는 프로미스를 좀 더 쉽게 쓰도록 도와주죠.

-

이 둘은 짧게 다루기 어려우므로 다른 글(프로미스, -async/await)을 -참고하기 바랍니다.

-

템플릿 리터럴(Template Literals)

-

템플릿 리터럴은 따옴표 대신 백틱(backticks, 억음 부호)을 사용한 문자열입니다.

-
const foo = `템플릿 리터럴 문자열입니다`;
-

템플릿 리터럴은 문자열에 2가지 기능을 더한 것인데요. 하나는 여러 줄에 걸쳐 쓸 수 -있는 기능입니다.

-
const foo = `템플릿
-리터럴
-문자열입니다`;
-const bar = "템플릿\n리터럴\n문자열입니다";
-
-

예제의 foobar는 같은 문자열이죠.

-

다른 하나는 문자열 중간에 자바스크립트 표현식을 끼워넣을 수 있는 기능으로, -${ 자바스크립트 표현식 }처럼 사용합니다.

-
const r = 192;
-const g = 255;
-const b = 64;
-const rgbCSSColor = `rgb(${r},${g},${b})`;
-
-

함수를 실행하거나

-
const color = [ 192, 255, 64 ];
-const rgbCSSColor = `rgb(${ color.join(',') })`;
-
-

계산식을 넣을 수도 있습니다.

-
const aWidth = 10;
-const bWidth = 20;
-someElement.style.width = `${ aWidth + bWidth }px`;
-
-

자바스크립트 네이밍 컨벤션(coding convection, 코딩 스타일 규약) 지키기

-

코드를 작성하는 것은 여러분의 자유지만, 웬만하면 한 가지 규약은 지키는 것이 좋습니다. -자바스크립트의 변수, 메서드, 함수 이름은 전부 lowerCamelCase이죠. 생성자나, 클래스의 -이름은 UpperCamelCase입니다. 이 규약만 따른다면 자바스크립트의 세계에서 크게 문제될 -것은 없죠. 대부분의 린터(linter)나 린팅 프로그램이 위 네이밍 -컨벤션을 지키지 않는 코드에 대해 에러나 경고를 던집니다.

-

(※ 문서에 따라 lower camel case는 camelCase, upper camel case는 PascalCase로 부르기도 -합니다. 역주.)

-
const v = new vector(); // 모든 클래스가 대문자로 시작할 경우 에러
-const v = Vector();     // 모든 함수가 소문자로 시작할 경우 에러
-
-

Visual Studio Code 사용해보기

-

이 세상에는 훌륭한 에디터가 많습니다. 자신이 좋아하는 에디터를 쓰는 것이 가장 -좋죠. 하지만 아직 마음에 드는 자바스크립트 에디터가 없다면 Visual Studio Code를 -써보세요! 에디터를 설치하고 eslint를 설정 -하면-환경에 따라 시간이 좀 걸릴지 모르나-무수한 자바스크립트 코드의 버그를 -잡는 데 도움이 될 겁니다.

-

예를 들어 no-undef 규칙을 활성화하면 -VSCode는 ESLint를 통해 변수의 값이 지정되지 않은 경우 경고를 출력할 겁니다.

-
- -

위 예시에서 작성자는 doTheThing 함수를 doThing로 잘못 적었습니다. 그러자 doThing -아래에 밑줄이 생겼죠. 그리고 그 위에 커서를 올리면 해당 함수를 선언하지 않았다고 -알려줍니다. 미리 에러를 하나 줄인 셈이죠.

-

THREE를 쓸 때도 경고가 나타날 것이므로 코드 상단에 /* global THREE */를 선언해 -THREE가 미리 선언되었음을 알려줄 수 있습니다.

-
- -

위 이미지에서 ESLint는 대문자로 시작하는 이름은 생성자이니 new 키워드를 사용하라고 -알려줍니다. new-cap 규칙 덕에 에러를 하나 -더 줄였네요.

-

이 외에도 설정할 수 있는 규칙이 거의 100개 정도 더 -있습니다. 아까 var 대신 constlet을 쓰라고 했었죠.

-

아래 이미지에서 작성자는 var를 썼는데, let이나 const를 쓰라는 경고를 받았습니다.

-
- -

let을 쓰고 재할당을 하지 않자 const를 쓰라고 제안합니다.

-
- -

물론 var가 더 좋다고 생각하면 이 규칙을 꺼버리면 됩니다. 이 규칙을 쓰는 건 제가 성능과 -버그 예방 면에서 var보다 letconst를 쓰는 걸 더 좋아하기 때문이죠.

-

일부 파일이나 코드의 일부분에서 규칙을 덮어 씌워야 할 경우, 주석을 추가해 규칙을 끌 수 -있습니다.

-

구형 브라우저를 지원해야 한다면 트랜스파일러를 쓰세요

-

대부분의 모던 브라우저가 자동 업데이트 기능을 사용하기에 최신 문법을 사용해 생산성과 -버그 예방 두 마리의 토끼를 동시에 잡을 수 있습니다. 만약 꼭 구형 브라우저를 지원해야 -한다면 ES5/ES2015 등의 최신 문법을 구형 문법으로 변환시켜주는 트랜스파일러를 -사용하길 권장합니다.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/primitives.html b/manual/ko/primitives.html deleted file mode 100644 index 05a18112c3456f..00000000000000 --- a/manual/ko/primitives.html +++ /dev/null @@ -1,333 +0,0 @@ - - - 원시 모델(primitives) - - - - - - - - - - - - - - -
-
-

원시 모델(primitives)

-
-
-
-

※ 이 글은 Three.js의 튜토리얼 시리즈로서, -먼저 Three.js의 기본 구조에 관한 글을 -읽고 오길 권장합니다.

-

Three.js에는 다양한 원시 모델이 있습니다. 먼저 Three.js의 원시 모델이란, -주로 런타임에서 다양한 인자들로 정의한 3D 모양을 의미합니다.

-

원시 모델은 주로 구체로 공 모양을 만든다거나, 수많은 육면체를 모아 -3D 그래프를 만드는 데 사용합니다. 또한 3D에 입문한다거나, 모의 -프로젝트를 만들 때 사용하기도 하죠. 물론 대부분의 3D 앱은 그래픽 -전문가가 블렌더(Blender), 마야(Maya), -시네마 4D(Cinema 4D) 등으로 -만든 그래픽 모델을 사용합니다. Three.js도 이런 외부 모델을 불러올 수 -있지만, 이건 나중에 알아보기로 하고 일단 이 글에서는 사용 가능한 원시 -모델에 무엇이 있는지 살펴보도록 하죠.

-

앞으로 소개할 원시 모델들은 대부분 기본값이 있으므로 필요에 따라 -인자를 넣어주면 됩니다.

-
육면체(Box)
-
원(flat circle)
-
원뿔(Cone)
-
원통(Cylinder)
-
십이면체(Dodecahedron)
-
사각(bevel)을 주어 깍아낸(extruded) 2D 모양입니다. -아래에서는 하트 모양으로 깍아냈죠. ExtrudedGeometry는 나중에 설명할 -TextGeometryTextGeometry의 기초 모델입니다.
-
이십면체(Icosahedron)
-
선(line)을 회전시켜 만든 모양입니다. 램프, 볼링핀, 초, 초 받침, 와인잔, 유리잔 등이 있죠(물레로 도자기를 만드는 것처럼. 역주). 2D 형태를 점(point, Vector2 클래스를 말함. 역주)을 사용해 지정하고, Three.js에게 축을 따라 세분값(아래 예제의 segments 값. 역주)과 회전값(아래 예제의 phiLength 값. 역주)을 지정해주면 됩니다.
-
팔면체(Octahedron)
-
2D 격자값(격자 하나의 벡터값)을 받아 3D 값을 반환하는 함수를 인자로 전달하여 면을 만듭니다.
-
2D 평면(2D plane)
-
다면체입니다. 주어진 3D 점들(아래 verticesOfCube. 역주)을 중심으로 삼각형(아래 indicesOfFaces. 역주)을 구 형태로 잇습니다.
-
중앙이 빈 2D 디스크(disc)입니다.
-
삼각형으로 이루어진 2D 윤곽선입니다.
-
구(Sphere)
-
사면체(tetrahedron)
-
3D 폰트와 문자열로 만든 3D 텍스트입니다.
-
원환체(torus), 도넛(donut)
-
원환체 매듭(torus knot)
-
패스를 따라 이어진 원입니다.
-
다른 geometry를 받는 헬퍼 객체로, 각 면 사이의 각이 일정 값 이상일 때만 모서리를 표시합니다. 상단의 육면체 예제를 보면 육면체를 만드는 삼각형이 표면에 전부 표시된 것을 확인할 수 있는데, EdgesGeometry를 사용할 경우 표면에 있던 선들이 전부 사라집니다. 아래 예제의 thresholdAngle 값을 조정해 해당 값 이하인 모서리가 전부 사라지는 것을 확인해보세요.
-
매개변수로 받은 geometry의 모서리 하나당 하나의 선분(2개의 점)을 가진 geometry를 생성합니다. WebGl은 보통 선분 하나당 2개의 점을 필요로 합니다. 때문에 이 모델을 사용하지 않는 경우, 모서리가 없어지거나 추가되는 현상이 발생할 수 있습니다. 예를 들어 2D 삼각형을 만드는 경우, 대부분 3개의 점을 이용해 삼각형을 만들려고 할 겁니다. wireframe: true라는 옵션이 있기는 하나, 이를 이용해 삼각형을 만들면 (WebGl은 삼각형을 만들 때 6개의 점을 요구하므로. 역주) 출력되는 건 선 하나 뿐일 겁니다. 삼각형 geometryWireframeGeometry에 넘겨주면 6개의 점과 3개의 선분을 가진 새 geometry를 생성합니다.
- -

커스텀 geometry를 만드는 법에 대해서는 -나중에 자세히 다룰 것이므로, 지금은 각 원시 모델로 예제를 만들어 보겠습니다. -예제 코드는 지난 글에서 썼던 예제를 쓸 거에요.

-

먼저 배경색을 지정합니다.

-
const scene = new THREE.Scene();
-+scene.background = new THREE.Color(0xAAAAAA);
-
-

이는 옅은 회색으로 배경을 칠하라는 의미이죠.

-

모든 물체를 봐야 하므로 카메라도 수정합니다.

-
-const fov = 75;
-+const fov = 40;
-const aspect = 2;  // the canvas default
-const near = 0.1;
--const far = 5;
-+const far = 1000;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
--camera.position.z = 2;
-+camera.position.z = 120;
-
-

다음으로 x, y 좌표와 Object3D를 매개변수로 받아 씬에 추가하는 addObject -함수를 만듭니다.

-
const objects = [];
-const spread = 15;
-
-function addObject(x, y, obj) {
-  obj.position.x = x * spread;
-  obj.position.y = y * spread;
-
-  scene.add(obj);
-  objects.push(obj);
-}
-
-

물체를 무작위로 채색하는 함수도 하나 만듭니다. hue, 채도, 명도로 -색을 지정하는 Color의 기능을 활용할 거에요.

-

hue는 0부터 1까지의 색상값을 고리 모양으로 배치한 것으로, -12시는 빨강, 4시는 녹색, 8시는 파랑입니다. 채도(saturation) -또한 마찬가지로 0부터 1까지이며, 0은 색이 가장 옅은 것, 1은 -색이 가장 진한 것을 의미합니다. 명도(luminance)에서 0.0은 -검정, 1.0은 하양으로, 0.5가 가장 색이 가장 풍부합니다. 쉽게 -말해 명도가 0.0에서 0.5로 갈수록 검정에서 hue에 가까워지고, -0.5에서 1.0으로 갈수록 hue에서 하양에 가까워지는 것이죠.

-
function createMaterial() {
-  const material = new THREE.MeshPhongMaterial({
-    side: THREE.DoubleSide,
-  });
-
-  const hue = Math.random();
-  const saturation = 1;
-  const luminance = .5;
-  material.color.setHSL(hue, saturation, luminance);
-
-  return material;
-}
-
-

위 예제에서는 materialside: THREE.DoubleSide 옵션을 -지정했습니다. 이는 Three.js에게 삼각형의 양면 모두를 렌더링하라고 -알려주는 것이죠. 구나 정육면체 같은 물체는 보이지 않는 안쪽 면을 -굳이 렌더링할 이유가 없지만, 예제의 경우 PlaneGeometry나 -ShapeGeometry 등 안쪽 면이 없는 물체를 만들 것이므로 -side: THREE.DoubleSide 옵션을 설정하지 않으면 반대편에서 봤을 때 -물체가 사라진 것처럼 보일 겁니다.

-

중요한 건 side: THREE.DoubleSide 옵션은 렌더링 속도에 영향을 줍니다. -실제로 사용할 때는 필요한 물체에만 지정하는 게 좋겠지만, 지금은 -물체의 수가 많지 않으므로 일단 넘어가겠습니다.

-

다음으로 addSolidGeometry 함수를 만듭니다. 이 함수는 매개변수로 -받은 geometry와 앞서 만든 createMaterial 함수를 사용해 -무작위로 색칠한 물체를 만들고, addObject 함수로 씬에 추가합니다.

-
function addSolidGeometry(x, y, geometry) {
-  const mesh = new THREE.Mesh(geometry, createMaterial());
-  addObject(x, y, mesh);
-}
-
-

이제 이를 활용해 주요 원시 모델을 생성할 수 있습니다. -예를 들어 정육면체를 만든다고 해보죠.

-
{
-  const width = 8;
-  const height = 8;
-  const depth = 8;
-  addSolidGeometry(-2, -2, new THREE.BoxGeometry(width, height, depth));
-}
-
-

아래 코드를 보면 각 geometry마다 비슷한 단락으로 이루어진 것을 확인할 수 있습니다.

-

- -

-

몇몇 예외가 보일 텐데, 가장 크게 두드러진 것은 아마 TextGeometry일 겁니다. -TextGeometry는 텍스트의 mesh를 생성하기 위해 3D 폰트 데이터를 필요로 합니다. -이 데이터는 비동기로 로드되므로, 객체를 생성하기 전에 3D 폰트 데이터가 로드되기를 기다려야 -하죠. 폰트 로드 과정을 프로미스화 하면 이 과정를 더 쉽게 만들 수 있습니다. 먼저 FontLoader를 -생성하고, Promise를 반환하는 loadFont 함수를 만들어 요청을 Promise로 감쌉니다. -그리고 doit이라는 비동기 함수를 만들어 await 키워드로 폰트를 로드한 후, geometry를 -만들고 addObject 함수로 씬에 추가하죠.

-
{
-  const loader = new FontLoader();
-  // promisify font loading
-  function loadFont(url) {
-    return new Promise((resolve, reject) => {
-      loader.load(url, resolve, undefined, reject);
-    });
-  }
-
-  async function doit() {
-    const font = await loadFont('resources/threejs/fonts/helvetiker_regular.typeface.json');  /* threejs.org: url */
-    const geometry = new TextGeometry('three.js', {
-      font: font,
-      size: 3.0,
-      height: .2,
-      curveSegments: 12,
-      bevelEnabled: true,
-      bevelThickness: 0.15,
-      bevelSize: .3,
-      bevelSegments: 5,
-    });
-    const mesh = new THREE.Mesh(geometry, createMaterial());
-    geometry.computeBoundingBox();
-    geometry.boundingBox.getCenter(mesh.position).multiplyScalar(-1);
-
-    const parent = new THREE.Object3D();
-    parent.add(mesh);
-
-    addObject(-1, -1, parent);
-  }
-  doit();
-}
-
-

또 다른 차이점은 Three.js의 텍스트는 기본적으로 중앙을 중심으로 돌지 -않는다는 것입니다. 기본 회전축은 왼쪽 모서리로, 중앙을 중심으로 돌게 -하려면 Three.js에게 geometry의 bounding box(경계 좌표)를 계산해 -달라고 요청한 뒤, bounding box의 getCenter 메서드에 해당 mesh의 -위치값 객체를 넘겨주어야 합니다. 이러면 getCenter 메서드는 넘겨받은 -위치값의 중앙 좌표값을 자신의 위치값으로 복사합니다. 그리고 변경된 위치값 -객체를 반환하는데, 이 객체의 multiplyScalar(-1) 메서드로 전체 텍스트의 -회전 중심이 텍스트의 중앙에 오도록 조정할 수 있습니다.

-

만약 이대로 다른 예제처럼 addSolidGeometry 함수를 호출한다면 위치값을 -재할당해버릴 겁니다. 그러니 대신 Three.js의 씬 그래프의 기본 요소(node)인 -Object3D를 하나 만듭니다(Mesh 또한 Object3D의 자식 요소임). -씬 그래프가 어떻게 작동하는가에 대해서는 다른 글에서 자세히 다룰 것이므로, -당장은 DOM 요소처럼 자식 요소가 부모 요소를 기반으로 생성된다는 -것만 알아둡시다. Object3D를 생성해 텍스트를 감싸면 텍스트의 회전 중심은 -유지한 채로 위치값을 얼마든지 조정할 수 있습니다.

-

만약 addSolidGeometry를 그냥 사용한다면 아래 왼쪽의 예제처럼 -회전축이 아예 빗나가겠죠.

-

- -

-

예제의 왼쪽 텍스트는 회전축이 중앙에서 벗어났지만, 오른쪽 텍스트는 -중앙을 중심으로 회전합니다.

-

다른 예외는 2개의 선을 기반으로 한 EdgesGeometryWireframeGeometry입니다. -addSolidGeometry 함수 대신 아래와 같은 addLineGeometry 함수를 호출했죠.

-
function addLineGeometry(x, y, geometry) {
-  const material = new THREE.LineBasicMaterial({color: 0x000000});
-  const mesh = new THREE.LineSegments(geometry, material);
-  addObject(x, y, mesh);
-}
-
-

이 함수는 검은 LineBasicMaterial을 만들고 이를 기반으로 LineSegments를 -만듭니다. LineSegmentsMesh의 자식 객체로, Three.js의 선분(line segments, 선분 하나당 점 2개) -렌더링을 도와주는 객체입니다.

-

원시 모델을 생성할 때 인자는 차이가 있으니 공식 문서를 참고하시거나, -각 원시 모델 예시 위에 공식 문서 링크가 있으니 해당 링크를 참고하시기 바랍니다.

-

소개하지 않은 클래스 중에 위 패턴으로는 사용하기 어려운 클래스가 있습니다. -PointsMaterialPoints 클래스인데요. PointsGeometryBufferGeometry를 -매개변수로 받는다는 점에서 위 예제의 LineSegments와 비슷하나, 선 대신 각 정점에 -점(point)을 그린다는 점이 다릅니다. 또 점의 크기를 지정하는 size도 -PointsMaterial에 함께 넘겨주어야 하죠.

-
const radius = 7;
-const widthSegments = 12;
-const heightSegments = 8;
-const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
-const material = new THREE.PointsMaterial({
-    color: 'red',
-    size: 0.2,     // 글로벌 단위
-});
-const points = new THREE.Points(geometry, material);
-scene.add(points);
-
-
-
-
- -

sizeAttenuationfalse로 지정하면 -카메라로부터의 거리에 상관없이 점의 크기가 일정하게 보입니다.

-
const material = new THREE.PointsMaterial({
-    color: 'red',
-+    sizeAttenuation: false,
-+    size: 3,       // 픽셀
--    size: 0.2,     // 글로벌 단위
-});
-...
-
-
-
-
- -

또 하나 집고 넘어가야 하는 것은 Three.js의 물체 대부분이 -세분값에 대한 설정이 다양하다는 겁니다. 좋은 예로 구체가 -있죠. 구체는 얼마나 물체를 세분할지에 대한 값을 매개변수로 -받습니다.

-
-
-
-
-
- -

위 그림에서 첫 번째 구체는 둘레로 5개, 높이로 3개의 면으로 분할되었습니다. -이는 15개의 면 또는 30개의 삼각형이죠. 두 번째 구체는 24 x 10, 240면 또는 -480개의 삼각형입니다. 마지막 구체는 50 x 50으로 무려 2500면 또는 5000개의 -삼각형에 해당하죠.

-

얼마나 많이 세분할지는 필요에 따라 다르게 설정하면 됩니다. 위 예제만 보면 -많이 분할할수록 좋아보이나, 선과 플랫 쉐이딩(flat shading)만 제거해도 -아래와 같은 결과가 나옵니다.

-
-
-
-
-
- -

5000 삼각형인 오른쪽 구체가 480 삼각형인 중간 구체보다 훨씬 좋다고 -이야기하기 모호합니다. 만약 지구본을 만들기 위한 구체 하나를 만든다고 -하면, 10000개의 삼각형으로 구체를 만드는 것이 나쁜 선택은 아닙니다. -하지만 1000개의 삼각형으로 만든 구체 1000개를 렌더링할 경우, 이는 총 -천만개의 삼각형이 됩니다. 이를 부드럽게 움직이려면 브라우저가 1초에 -60프레임을 렌더링해야 하니, 결과적으로 이는 1초에 6억개의 삼각형을 -렌더링하라고 하는 것과 같죠. 절대 간단한 연산이 아닙니다.

-

물론 선택이 쉬운 경우도 있습니다. 예를 들어 평면을 분할한다고 해보죠.

-
-
-
-
- -

왼쪽의 평면은 2 삼각형입니다. 오른쪽 평면은 200 삼각형이죠. -구체와 다르게 평면은 대부분의 경우 퀄리티 저하가 아예 없습니다. -평면을 변형해 다른 것을 만드는 경우가 아니면, 평면을 분할해야할 -이유는 없죠.

-

그러니 상황에 따라 적절한 값을 선택하기 바랍니다. 물체를 덜 분할할수록 -성능도 올라가고 메모리 점유율도 낮아질 테니까요. 상황에 따라 어떤 것을 -포기할지 결정하는 것은 순전히 여러분의 몫입니다.

-

원시 모델 중 어떤 것도 실제 프로젝트에 적용하기가 어렵다면, -.obj 파일 또는 .gltf 파일을 -로드하여 사용할 수 있습니다. 또는 -커스텀 BufferGeometry를 생성할 수도 있죠.

-

다음 장에서는 씬 그래프와 그 사용법에 대해 -알아보겠습니다.

-

- - - -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/rendering-on-demand.html b/manual/ko/rendering-on-demand.html deleted file mode 100644 index 6925261ecce6ed..00000000000000 --- a/manual/ko/rendering-on-demand.html +++ /dev/null @@ -1,228 +0,0 @@ - - - 불필요한 렌더링 없애기 - - - - - - - - - - - - - - -
-
-

불필요한 렌더링 없애기

-
-
-
-

대부분의 개발자에게 이 주제는 너무 뻔할 수 있지만, 필요한 누군가를 위해 -글을 써보려 합니다. 대부분의 Three.js 예제는 렌더링 과정을 계속 반복합니다. -그러니까 아래와 같이 재귀적으로 requestAnimationFrame 함수를 사용한다는 -거죠.

-
function render() {
-  ...
-  requestAnimationFrame(render);
-}
-requestAnimationFrame(render);
-
-

계속 애니메이션이 있는 경우에야 별 상관이 없지만, 애니메이션이 없는 경우라면 -어떨까요? 이 경우 불필요한 렌더링을 반복하는 것은 연산 낭비일 뿐더러 사용 -환경이 모바일이라면 사용자의 배터리까지 낭비하는 셈입니다.

-

처음 한 번만 렌더링하고, 그 후에 변화가 있을 때만 렌더링하는 것이 가장 정확한 -해결책일 겁니다. 여기서 변화란 텍스처나 모델의 로딩이 끝났을 때, 외부에서 -데이터를 받았을 때, 사용자가 카메라를 조정하거나, 설정을 바꾸거나, 인풋 값이 -변경된 경우 등 다양하겠죠.

-

반응형 디자인에 관한 글에서 썼던 예제를 수정해 -필요에 따른 렌더링을 구현해봅시다.

-

먼저 뭔가 변화를 일으킬 수 있는 요소가 필요하니 OrbitControls를 추가합니다.

-
import * as THREE from 'three';
-+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-
-...
-
-const fov = 75;
-const aspect = 2;  // canvas 기본값
-const near = 0.1;
-const far = 5;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-camera.position.z = 2;
-
-+const controls = new OrbitControls(camera, canvas);
-+controls.target.set(0, 0, 0);
-+controls.update();
-
-

정육면체에 애니메이션을 넣지 않을 것이니 이들을 참조할 필요가 없습니다.

-
-const cubes = [
--  makeInstance(geometry, 0x44aa88,  0),
--  makeInstance(geometry, 0x8844aa, -2),
--  makeInstance(geometry, 0xaa8844,  2),
--];
-+makeInstance(geometry, 0x44aa88,  0);
-+makeInstance(geometry, 0x8844aa, -2);
-+makeInstance(geometry, 0xaa8844,  2);
-
-

애니메이션과 requestAnimationFrame 관련 코드도 제거합니다.

-
-function render(time) {
--  time *= 0.001;
-+function render() {
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
--  cubes.forEach((cube, ndx) => {
--    const speed = 1 + ndx * .1;
--    const rot = time * speed;
--    cube.rotation.x = rot;
--    cube.rotation.y = rot;
--  });
-
-  renderer.render(scene, camera);
-
--  requestAnimationFrame(render);
-}
-
--requestAnimationFrame(render);
-
-

그리고 render 함수를 직접 호출합니다.

-
render();
-
-

이제 OrbitControls가 카메라 설정을 바꿀 때마다 직접 render 함수를 호출해야 -합니다. 뭔가 복잡할 것 같지만 다행히 OrbitControls에는 change 이벤트가 있습니다.

-
controls.addEventListener('change', render);
-
-

또한 창 크기가 바뀔 때의 동작도 직접 처리해야 합니다. render 함수를 계속 호출할 -때는 해당 동작을 자동으로 처리했지만, 지금은 render 함수를 수동으로 호출하므로 -창의 크기가 바뀔 때 render 함수를 호출하도록 하겠습니다.

-
window.addEventListener('resize', render);
-
-

이제 불필요한 렌더링을 반복하지 않습니다.

-

- -

-

OrbitControls에는 관성(inertia) 옵션이 있습니다. enableDamping 속성을 true로 -설정하면 동작이 좀 더 부드러워지죠.

-

※ damping: 감쇠.

-
controls.enableDamping = true;
-
-

또한 OrbitControls가 부드러운 동작을 구현할 때 변경된 카메라 값을 계속 넘겨주도록 -render 함수 안에서 controls.update 메서드를 호출해야 합니다. 하지만 이렇게 하면 -change 이벤트가 발생했을 때 render 함수가 무한정 호출될 겁니다. controls가 change -이벤트를 보내면 render 함수가 호출되고, render 함수는 controls.update 메서드를 -호출해 다시 change 이벤트를 보내게 만들 테니까요.

-

requestAnimationFrame이 직접 render 함수를 호출하게 하면 이 문제를 해결 할 수 -있습니다. 너무 많은 프레임을 막기 위해 변수 하나를 두어 요청한 프레임이 없을 경우에만 -프레임을 요청하도록 하면 되겠네요.

-
+let renderRequested = false;
-
-function render() {
-+  renderRequested = false;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-+ controls.update();
-  renderer.render(scene, camera);
-}
-render();
-
-+function requestRenderIfNotRequested() {
-+  if (!renderRequested) {
-+    renderRequested = true;
-+    requestAnimationFrame(render);
-+  }
-+}
-
--controls.addEventListener('change', render);
-+controls.addEventListener('change', requestRenderIfNotRequested);
-
-

창 크기 변화가 일어났을 때도 requestRenderIfNotRequested를 호출하도록 합니다.

-
-window.addEventListener('resize', render);
-+window.addEventListener('resize', requestRenderIfNotRequested);
-
-

차이점을 느끼기 어려울지도 모르겠습니다. 화살표 키를 쓰거나 예제를 드래그해 보고 -다시 위 예제를 이리저리 돌려보세요. 차이점이 느껴질 거예요. 위 예제는 화살표 키를 -눌렀을 때 일정 거리만큼 순간이동하지만 아래의 예제는 약간 미끄러집니다.

-

- -

-

간단한 lil-gui를 추가해 반복 렌더링 여부를 제어할 수 있도록 하겠습니다.

-
import * as THREE from 'three';
-import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-+import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
-
-

먼저 각 정육면체의 색과 x축 스케일을 조정하는 GUI를 추가합니다. 조명에 관한 글에서 -썼던 ColorGUIHelper를 가져와 쓰도록 하죠.

-

먼저 GUI를 생성합니다.

-
const gui = new GUI();
-
-

그리고 각 정육면체에 material.color, cube.scale.x 설정을 폴더로 묶어 -추가합니다.

-
function makeInstance(geometry, color, x) {
-  const material = new THREE.MeshPhongMaterial({color});
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  cube.position.x = x;
-
-+  const folder = gui.addFolder(`Cube${ x }`);
-+  folder.addColor(new ColorGUIHelper(material, 'color'), 'value')
-+      .name('color')
-+      .onChange(requestRenderIfNotRequested);
-+  folder.add(cube.scale, 'x', .1, 1.5)
-+      .name('scale x')
-+      .onChange(requestRenderIfNotRequested);
-+  folder.open();
-
-  return cube;
-}
-
-

lil-gui 컨트롤(control)의 onChange 메서드에 콜백 함수를 넘겨주면 GUI 값이 바뀔 -때마다 콜백 함수를 호출합니다. 예제의 경우에는 단순히 requestRenderIfNotRequested -함수를 넘겨주면 되죠. 그리고 folder.open 메서드를 호출해 폴더를 열어 둡니다.

-

- -

-

이 글이 불필요한 렌더링 제거에 대한 개념을 조금이라도 잡아주었길 바랍니다. 보통 -Three.js를 사용할 때는 이렇게 렌더링 루프를 제어할 일이 없습니다. 대게 게임 또는 -애니메이션이 들어간 3D 컨텐츠이기 때문이죠. 하지만 지도나, 3D 에디터, 3D 그래프, -상품 목록 등에서는 이런 기법이 필요할 수도 있습니다.

- -
-
-
- - - - - - - - diff --git a/manual/ko/rendertargets.html b/manual/ko/rendertargets.html deleted file mode 100644 index c7dd3fafa1caff..00000000000000 --- a/manual/ko/rendertargets.html +++ /dev/null @@ -1,173 +0,0 @@ - - - 렌더 타겟(Render Targets) - - - - - - - - - - - - - - -
-
-

렌더 타겟(Render Targets)

-
-
-
-

Three.js의 렌더 타겟이란, 직접 렌더링할 수 있는 텍스처(texture)를 말합니다. -한 번 텍스처로 렌더링한 뒤에는 다른 텍스처처럼 사용할 수 있죠.

-

간단한 예제를 만들어보겠습니다. 반응형 디자인에 관한 글에서 -썼던 예제를 가져오도록 하죠.

-

렌더 타겟을 만드는 방법은 기존 렌더링 방법과 유사합니다. 먼저 WebGLRenderTarget 인스턴스를 -생성합니다.

-
const rtWidth = 512;
-const rtHeight = 512;
-const renderTarget = new THREE.WebGLRenderTarget(rtWidth, rtHeight);
-
-

그리고 CameraScene(장면)을 추가합니다.

-
const rtFov = 75;
-const rtAspect = rtWidth / rtHeight;
-const rtNear = 0.1;
-const rtFar = 5;
-const rtCamera = new THREE.PerspectiveCamera(rtFov, rtAspect, rtNear, rtFar);
-rtCamera.position.z = 2;
-
-const rtScene = new THREE.Scene();
-rtScene.background = new THREE.Color('red');
-
-

위 예제에서는 렌더 타겟의 가로세로비(aspect, 종횡비)를 canvas가 아닌 렌더 타겟 -자체의 사이즈로 구했습니다. 렌더 타켓의 가로세로비는 텍스처를 사용할 물체에 맞춰 -정해야 하기 때문이죠. 예제의 경우 렌더 타겟을 정육면체의 텍스처로 사용할 것이고, -정육면체의 모든 면은 정사각형이므로 가로세로비는 1.0입니다.

-

그리고 이전 글에서 썼던 조명과 정육면체 3개를 추가하겠습니다.

-
{
-  const color = 0xFFFFFF;
-  const intensity = 1;
-  const light = new THREE.DirectionalLight(color, intensity);
-  light.position.set(-1, 2, 4);
-*  rtScene.add(light);
-}
-
-const boxWidth = 1;
-const boxHeight = 1;
-const boxDepth = 1;
-const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-
-function makeInstance(geometry, color, x) {
-  const material = new THREE.MeshPhongMaterial({color});
-
-  const cube = new THREE.Mesh(geometry, material);
-*  rtScene.add(cube);
-
-  cube.position.x = x;
-
-  return cube;
-}
-
-*const rtCubes = [
-  makeInstance(geometry, 0x44aa88,  0),
-  makeInstance(geometry, 0x8844aa, -2),
-  makeInstance(geometry, 0xaa8844,  2),
-];
-
-

이전 글의 SceneCamera는 그대로 둡니다. 이 둘은 canvas를 렌더링하는 -데 사용할 거예요.

-

먼저 렌더 타겟의 텍스처를 사용하는 정육면체를 추가합니다.

-
const material = new THREE.MeshPhongMaterial({
-  map: renderTarget.texture,
-});
-const cube = new THREE.Mesh(geometry, material);
-scene.add(cube);
-
-

그리고 render 함수 안에서 렌더 타겟의 장면을 먼저 렌더링한 뒤,

-
function render(time) {
-  time *= 0.001;
-
-  ...
-
-  // 렌더 타겟의 장면 안에서 정육면체를 각각 회전시킵니다
-  rtCubes.forEach((cube, ndx) => {
-    const speed = 1 + ndx * .1;
-    const rot = time * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
-  // 렌더 타겟의 장면을 렌더 타겟에 렌더링합니다
-  renderer.setRenderTarget(renderTarget);
-  renderer.render(rtScene, rtCamera);
-  renderer.setRenderTarget(null);
-
-

canvas에 렌더 타겟의 텍스처를 사용하는 정육면체를 렌더링합니다.

-
  // 장면 중앙의 정육면체를 회전시킵니다
-  cube.rotation.x = time;
-  cube.rotation.y = time * 1.1;
-
-  // 장면은 canvas에 렌더링합니다
-  renderer.render(scene, camera);
-
-

붐!

-

- -

-

정육면체가 빨간 건 정육면체를 잘 보이도록 하기 위해 rtScenebackground -속성을 빨강으로 설정했기 때문입니다.

-

렌더 타겟의 용도는 무궁무진합니다. 그림자가 렌더 타겟을 -사용하고, 피킹(picking)도 렌더 타겟을 사용할 수 있죠. -많은 후처리 효과를 사용할 때 렌더 타겟이 필수 -요소인 경우도 있고, 차의 후사경(rear view mirror, 백미러)이나 모니터 화면 등에도 -렌더 타겟을 활용할 수 있습니다.

-

이번 글은 여기까지입니다. 마지막으로 WebGLRenderTarget을 사용할 때의 주의해야 -할 점 몇 가지만 살펴보고 끝내도록 하죠.

-
    -
  • 기본적으로 WebGLRenderTarget은 2개의 텍스처를 생성합니다. 하나는 색상 텍스처이고, 다른 하나는 깊이/스텐실(depth/stencil) 텍스처이죠. 깊이 텍스처나 스텐실 텍스처를 사용하지 않을 거라면 인스턴스 생성 시 옵션을 지정해 텍스처를 아예 생성하지 않도록 할 수 있습니다.

    -
      const rt = new THREE.WebGLRenderTarget(width, height, {
    -    depthBuffer: false,
    -    stencilBuffer: false,
    -  });
    -
    -
  • -
  • 렌더 타겟의 크기를 바꿔야 한다면

    -

    앞선 예제에서는 렌더 타겟을 생성할 때 고정 사이즈, 512x512를 사용했습니다. 하지만 후처리 등에서 렌더 타겟을 사용할 경우, canvas 크기와 렌더 타겟의 크기를 똑같이 설정하는 것이 일반적입니다. 예제를 바탕으로 이를 구현하려면 canvas의 사이즈가 변경되었을 때 카메라와 렌더 타겟의 사이즈를 변경해주어야 하죠.

    -
    function render(time) {
    -  time *= 0.001;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -
    -+    renderTarget.setSize(canvas.width, canvas.height);
    -+    rtCamera.aspect = camera.aspect;
    -+    rtCamera.updateProjectionMatrix();
    -}
    -
  • -
- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/resources/fonts/LICENSE.md b/manual/ko/resources/fonts/LICENSE.md deleted file mode 100644 index 3c20052f9a971e..00000000000000 --- a/manual/ko/resources/fonts/LICENSE.md +++ /dev/null @@ -1,73 +0,0 @@ -Link: https://www.jeonnam.go.kr/contentsView.do?menuId=jeonnam0603080000 - -# 제 1유형: 출처 표시 - -이 페이지는 공공누리 이용약관과 등록된 저작물의 유형안내에 대하여 이해하기 쉽게 요약한 것입니다. - -## 이용자가 가지는 권리 - -1. 온·오프라인 상에 공유 및 이용 : 온·오프라인을 통하여 공유 및 이용 가능 -2. 저작물 변경 : 2차적 저작물로 변경하여 이용 가능 -3. 이 저작물은 영리 목적으로 이용할 수 있습니다. - -## 저작물 사용 조건 -- 출처 표시 : 저작물의 출처를 표시하셔야 합니다. - -※ 공공기관이 후원한다거나 공공기관과 특수한 관계에 있는 것처럼 제 3자가 오인하게 하는 표시를 해서는 안됩니다. - -## 알아야 할 사항 - -### I. 이용조건의 표시 및 변경 - -1. 이용자가 공공누리 저작물 활용시 출처표시를 꼭 해주셔야 합니다. -2. 공공누리 저작물의 이용조건은 변경될 수 있습니다. 다만 이용자가 이용조건 변경전 사용하셨다면 해당저작물 한해 용도변경 없이 계속 이용할 수 있습니다. - -### II. 이용조건의 위반 - -1. 이용자가 공공누리 이용조건을 위반할 경우 그 즉시 이용허락이 종료됩니다. -2. 이용자가 이용조건 위반 후 지속적으로 공공저작물을 이용할 경우 저작권 침해가 성립되므로 형사상, 민사상 책임을 부담 하실 수 있습니다. - -### III. 공공기관의 면책 - -1. 공공기관은 공공저작물의 정확성이나 지속적인 제공 등을 보장하지 않습니다. -2. 공공기관 및 그 직원은 이용자가 공공저작물을 이용함으로써 발생할 수 있는 어떠한 손해나 불이익에 대해서도 책임을 지지 않습니다. - -### IV. 기타 - -1. 이용자가 저작물을 보유하고 있는 공공기관으로부터 별도의 이용허락을 받은 경우 공공누리 조건을 적용하지 않으셔도 됩니다. - ---- - -# 공공누리 제 1유형의 이용조건 - -위 공공누리에 따라 이용자는 다음에서 제시하는 조건을 준수할 경우 공공저작물을 상업적 활용 여부에 관계없이 무료로 자유롭게 -이용하고 2차적 저작물 작성 등 변형하여 이용할 수 있습니다. - -## 출처 표시 의무 - -1. 이용자는 공공저작물을 이용할 경우, 다음과 같이 출처 또는 저작권자를 표시해야 합니다. -ex) "본 저작물은 'OOO(기관명)'에서 'OO년' 작성하여 공공누리 제O유형으로 개방한 '저작물명(작성자:OOO)'을 이용하였으며, -해당 저작물은 'OOO(기관명), OOO(홈페이지 주소)'에서 무료로 다운받으실 수 있습니다." - - * 위 내용은 예시이므로 작성연도 및 해당 기관명과 홈페이지 주소, 작성자명 기입 - -2. 온라인에서 출처 웹사이트에 대한 하이퍼링크를 제공하는 것이 가능한 경우에는 링크를 제공하여야 합니다. - -3. 이용자는 공공기관이 이용자를 후원한다거나 공공기관과 이용자가 특수한 관계에 있는 것처럼 제3자가 오인하게 하는 표시를 해서는 안됩니다. -이용이 제한되는 정보 - -## 다음과 같은 정보는 공공저작물에 포함되어 있더라도 관련 법령에 따라 이용이 제한됩니다. - -1. 개인정보보호법, 정보통신망 이용촉진 및 정보보호에 관한 법률 등에서 보호하는 개인정보 -2. 신용정보의 이용 및 보호에 관한 법률 등에서 보호하는 신용정보 -3. 군사기밀보호법 등에서 보호하는 군사기밀 -4. 상표권, 디자인권, 특허권 등 다른 권리의 대상이 되는 정보 또는 제3자의 저작권 등이 있는 정보 -5. 기타 다른 법령에 따라 이용이 제한되는 정보 - -## 공공기관의 면책 -1. 공공기관은 공공저작물의 정확성이나 지속적인 제공 등을 보장하지 않습니다. -2. 공공기관은 이용자가 공공저작물을 이용함으로써 발생할 수 있는 어떠한 손해나 불이익에 대해서도 공공기관 및 그 직원은 책임을 지지 않습니다. - -## 이용허락조건 위반의 효과 - -이용자는 공공누리의 이용허락의 조건 중 어느 하나라도 위반한 경우 이용허락이 자동으로 종료되며, 이용자는 즉시 공공저작물의 이용을 중단해야 합니다. \ No newline at end of file diff --git a/manual/ko/resources/fonts/PureunJeonnam-Bold.ttf b/manual/ko/resources/fonts/PureunJeonnam-Bold.ttf deleted file mode 100644 index e6ad23fbc2dbf5..00000000000000 Binary files a/manual/ko/resources/fonts/PureunJeonnam-Bold.ttf and /dev/null differ diff --git a/manual/ko/resources/fonts/PureunJeonnam-Bold.woff b/manual/ko/resources/fonts/PureunJeonnam-Bold.woff deleted file mode 100644 index e6fdcb9ea3a483..00000000000000 Binary files a/manual/ko/resources/fonts/PureunJeonnam-Bold.woff and /dev/null differ diff --git a/manual/ko/resources/fonts/PureunJeonnam.ttf b/manual/ko/resources/fonts/PureunJeonnam.ttf deleted file mode 100644 index eb866f2a66825b..00000000000000 Binary files a/manual/ko/resources/fonts/PureunJeonnam.ttf and /dev/null differ diff --git a/manual/ko/resources/fonts/PureunJeonnam.woff b/manual/ko/resources/fonts/PureunJeonnam.woff deleted file mode 100644 index d10e1c2e664b8b..00000000000000 Binary files a/manual/ko/resources/fonts/PureunJeonnam.woff and /dev/null differ diff --git a/manual/ko/responsive.html b/manual/ko/responsive.html deleted file mode 100644 index df09040d8ee952..00000000000000 --- a/manual/ko/responsive.html +++ /dev/null @@ -1,262 +0,0 @@ - - - 반응형 디자인 - - - - - - - - - - - - - - -
-
-

반응형 디자인

-
-
-
-

Three.js 두 번째 튜토리얼에 오신 것을 환영합니다! -첫 번째 튜토리얼은 Three.js의 기초에 관한 내용이었죠. -아직 이전 장을 보지 않았다면 (예제가 이어지므로. 역주) 이전 글을 먼저 읽어보기 바랍니다.

-

이 장에서는 Three.js 앱을 어떤 환경에서든 구동할 수 있도록 -반응형으로 만드는 법에 대해 알아볼 것입니다. 웹에서 반응형이란 -웹 페이지를 PC, 타블렛, 스마트폰 등 다양한 환경에서 이용하기 -용이하도록 사이즈에 맞춰 콘텐츠를 최적화하는 것을 의미하죠.

-

Three.js의 경우 일반 웹보다 고려해야 할 요소가 많습니다. -예를 들어 상하좌우에 컨트롤 패널이 있는 3D 에디터라든가, -문서 사이에 들어가는 동적 그래프를 상상해볼 수 있죠.

-

이전 글에서는 사이즈나 CSS 스타일을 정의하지 않은 canvas를 썼었죠.

-
<canvas id="c"></canvas>
-
-

canvas 요소는 기본적으로 300x150 픽셀입니다.

-

웹에서 어떤 요소의 크기를 지정할 때는 보통 CSS를 권장하죠.

-

canvas가 페이지 전체를 차지하도록 CSS를 작성해봅시다.

-
<style>
-html, body {
-   margin: 0;
-   height: 100%;
-}
-#c {
-   width: 100%;
-   height: 100%;
-   display: block;
-}
-</style>
-
-

body 요소는 기본적으로 5픽셀의 margin이 지정되어 있으니 margin: 0으로 -설정해 여백을 모두 없앱니다. html과 body 요소의 높이를 지정하지 않으면 -컨텐츠의 높이만큼만 커지니, 높이를 100%로 맞춰 창 전체를 채우도록 합니다.

-

그리고 id=c인 요소의 크기를 100%로 지정해 컨테이너, 이 예제에서는 body -요소의 크기와 동일하게 맞춥니다.

-

canvas 요소의 기본 display 속성은 inline입니다. inline 속성은 글자 -처럼 취급되어 흰 공백을 남길 수 있으니 display 속성을 block으로 지정합니다.

-

아래는 이전 장에서 만든 예제에 방금 작성한 CSS 스타일을 덧붙인 것입니다.

-

- -

-

canvas가 창 전체를 채우긴 했지만 문제가 좀 있네요. 정육면체가 창 크기에 -따라 늘어나 정육면체라기보다 너무 길거나 넓은 육면체처럼 보입니다. 예제를 -새 창에서 열어 창 크기를 조절해보세요. 정육면체가 어떻게 변하는지 확인할 -수 있을 겁니다.

-

-

또 다른 문제는 저화질, 그러니까 깨지고 흐릿하게 보인다는 점입니다. -창을 아주 크게 조절하면 문제를 바로 알 수 있을 거에요.

-

-

창 크기에 따라 늘어나는 문제부터 해결해봅시다. 먼저 카메라의 aspect(비율) -속성을 canvas의 화면 크기에 맞춰야 합니다. 이는 canvas의 clientWidth와 -clientHeight 속성을 이용해 간단히 해결할 수 있죠.

-

그리고 렌더링 함수를 다음처럼 수정합니다.

-
function render(time) {
-  time *= 0.001;
-
-+  const canvas = renderer.domElement;
-+  camera.aspect = canvas.clientWidth / canvas.clientHeight;
-+  camera.updateProjectionMatrix();
-
-  ...
-
-

이제 정육면체는 더이상 늘어나거나 찌그러들지 않을 겁니다.

-

- -

-

예제를 새 창에서 열어 창 크기를 조절해보면, 정육면체의 비율이 -창 크기와 상관없이 그대로 유지되는 것을 확인할 수 있을 겁니다.

-

-

이제 계단현상을 없애 봅시다.

-

canvas 요소에는 두 종류의 크기값이 있습니다. 하나는 아까 CSS로 -설정한 canvas 요소의 크기이고, 다른 하나는 canvas 원본 픽셀 수에 -대한 값입니다. 예를 들어 128x64 픽셀인 이미지가 있다고 합시다. -우리는 CSS를 이용해 이 이미지 요소를 400x200 픽셀로 보이도록 할 수 -있죠. canvas도 마찬가지입니다. 편의상 CSS로 지정한 크기를 디스플레이 -크기라고 부르겠습니다.

-
<img src="some128x64image.jpg" style="width:400px; height:200px">
-
-

canvas의 원본 크기, 해상도는 드로잉버퍼(drawingbuffer)라고 불립니다. -Three.js에서는 renderer.setSize 메서드를 호출해 canvas의 드로잉버퍼 -크기를 지정할 수 있죠. 어떤 크기를 골라야 하냐구요? 당연히 "canvas의 -디스플레이 크기"죠! 다시 canvas의 clientWidthclientHeight를 -이용합시다.

-

canvas의 원본 크기와 디스플레이 크기를 비교해 원본 크기를 변경할지 결정하는 -함수를 하나 만들어줍니다.

-
function resizeRendererToDisplaySize(renderer) {
-  const canvas = renderer.domElement;
-  const width = canvas.clientWidth;
-  const height = canvas.clientHeight;
-  const needResize = canvas.width !== width || canvas.height !== height;
-  if (needResize) {
-    renderer.setSize(width, height, false);
-  }
-  return needResize;
-}
-
-

canvas를 리사이징할 필요가 있는지 검사했다는 점에 주의하세요. -canvas 스펙상 리사이징은 화면을 다시 렌더링해야만 하므로, 같은 -사이즈일 때는 리사이징을 하지 않으므로써 불필요한 자원 낭비를 -막는 것이 좋습니다.

-

canvas의 크기가 다르다면, renderer.setSize 메서드를 호출해 -새로운 width와 height를 넘겨줍니다. renderer.setSize 메서드는 -기본적으로 CSS의 크기를 설정하니 마지막 인자로 false를 넘겨주는 -것을 잊지 마세요. canvas가 다른 요소와 어울리려면 Three.js에서 -CSS를 제어하는 것 보다 다른 요소들처럼 CSS로 제어하는 것이 일관성 -있는 프로그래밍일 테니까요.

-

위 함수는 canvas를 리사이징했으면 true를 반환합니다. 이 값을 -이용해 다른 요소들이 업데이트 해야 할지도 결정할 수 있겠네요. -새로 만든 함수를 이용해 render 함수를 수정합시다.

-
function render(time) {
-  time *= 0.001;
-
-+  if (resizeRendererToDisplaySize(renderer)) {
-+    const canvas = renderer.domElement;
-+    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-+    camera.updateProjectionMatrix();
-+  }
-
-  ...
-
-

canvas의 비율이 변하려면 canvas의 사이즈가 변해야 하므로, -resizeRendererToDisplaySize 함수가 true를 반환했을 때만 -카메라의 비율을 변경합니다.

-

- -

-

이제 디스플레이 크기에 맞는 해상도로 렌더링될 겁니다.

-

CSS가 디스플레이 크기를 제어하도록 해야 한다는 주장을 보충해보겠습니다. -일단 이 코드를 별도의 js 파일로 저장해주세요. -아래의 예시들은 CSS가 디스플레이 크기를 제어하도록 한 예시입니다. -잘 살펴보면 추가로 다른 코드를 써야할 필요가 없다는 걸 알 수 있죠.

-

먼저 canvas를 텍스트 사이에 끼워 넣어보죠.

-

- -

-

다음은 우측 컨트롤 패널 크기를 조정할 수 있는 에디터 형태의 -레이아웃에서 활용한 예시입니다.

-

- -

-

HTML, CSS만 바뀌고 자바스크립트 코드는 한 줄도 바뀌지 않았습니다.

-

HD-DPI 디스플레이 다루기

-

HD-DPI는 고해상도(high-density dot per inch)의 줄임말입니다. -많은 Windows 기기나 맥, 스마트폰이 이 디스플레이를 사용하죠(스마트폰의 -실제 화면 크기가 데스크탑에 비해 훨씬 작지만, 해상도는 비슷한 경우를 -생각하면 됩니다. 한 픽셀을 선명하게 표현하기 위해 다수의 작은 픽셀을 -넣는 것. 역주).

-

브라우저에서는 이에 대응하기 위해 픽셀의 집적도에 상관 없이 -CSS 픽셀을 이용해 요소의 크기를 지정합니다. 스마트폰이든, -데스크탑이든 브라우저는 요소를 같은 크기로 좀 더 촘촘하게 -할 뿐이죠.

-

Three.js로 HD-DPI를 다루는 방법은 아주 다양합니다.

-

첫째는 아무것도 하지 않는 것입니다. 3D 렌더링은 많은 GPU 자원을 -소모하기 때문에 아마 가장 흔한 경우일 겁니다. 2018년의 이야기이긴 -하지만, 모바일 기기는 데스크탑에 비해 GPU 성능이 부족함에도 더 높은 -해상도를 가진 경우가 대부분입니다. 현재 플래그쉽 스마트폰은 HD-DPI -약 3배의 해상도를 지녔습니다. 쉽게 말해 HD-DPI가 아닌 기기와 비교했을 -때 한 픽셀 당 픽셀 수가 1:9라는 것이고 이는 9배나 더 많은 렌더링 -작업을 처리해야 한다는 것을 의미하죠.

-

9배 많은 픽셀을 처리하는 건 굉장히 까다로운 작업이지만, 만약 코드를 -저대로 내버려 둔다면 우리의 코드가 1픽셀을 계산할 때마다 브라우저는 -해당 픽셀보다 3배 큰 픽셀을 렌더링해야 합니다(3배 곱하기 3배 = 9배 많은 픽셀).

-

이는 낮은 FPS, 즉 화면이 버벅거리게 만들 것이므로 특히 무거운 Three.js -앱을 만들 때는 지양해야 하는 요소이죠.

-

물론 지양해야 한다는 건 기기의 해상도에 따라 화면을 렌더링할 -다른 방법들이 더 있다는 의미입니다.

-

하나는 rederer.setPixelRatio 메서드를 이용해 해상도 배율을 알려주는 -것입니다. 브라우저로부터 CSS 픽셀과 실제 기기 픽셀의 배율을 받아 -Three.js에게 넘겨주는 것이죠.

-
    renderer.setPixelRatio(window.devicePixelRatio);
-
-

그러면 renderer.setSize는 이제 알아서 사이즈에 배율을 곱해 -리사이징할 것입니다....만 이 방법은 추천하지 않습니다.

-

다른 방법은 canvas를 리사이징할 때 직접 계산하는 것입니다.

-
    function resizeRendererToDisplaySize(renderer) {
-      const canvas = renderer.domElement;
-      const pixelRatio = window.devicePixelRatio;
-      const width  = Math.floor( canvas.clientWidth  * pixelRatio );
-      const height = Math.floor( canvas.clientHeight * pixelRatio );
-      const needResize = canvas.width !== width || canvas.height !== height;
-      if (needResize) {
-        renderer.setSize(width, height, false);
-      }
-      return needResize;
-    }
-
-

객관적으로 따져봐도 이 방법이 훨씬 낫습니다. 이 방법으로는 개발자가 -원하는 결과가 나오니까요. Three.js로 앱을 만들 때 언제 canvas의 -드로잉버퍼 사이즈를 가져와야 할지 특정하기란 어렵습니다. 예를 들어 -전처리 필터를 만든다거나, gl_FragCoord에 접근하는 쉐이더를 만든다거나, -스크린샷을 찍는다거나, GPU가 제어하는 픽셀 수를 가져 온다거나, 2D -canvas에 뭔가를 그린다던가 하는 경우가 있죠. 실제 크기 대신 setPixelRatio를 -사용하면 대부분의 경우 반환값이 개발자가 예상한 것과 다를 뿐더러, -이 반환값을 언제 사용할지, Three.js가 쓰는 크기는 무엇인지 일일이 -계산해야 합니다. 직접 배율을 계산하면 어떤 값을 Three.js가 쓰는지 -확실히 알 수 있고, 예외도 줄어듭니다.

-

아래는 맨 마지막 방법을 적용한 예시입니다.

-

- -

-

결과물로는 차이를 구별하기 어렵지만, HD-DPI 기기에서 예시를 비교해 -보면 이전 예시의 모서리가 좀 더 깨진 것이 보일 겁니다.

-

이 장에서는 아주 기초적인 예시만을 다루었습니다. 다음 장에서는 -Three.js의 원시 모델에 대해서 빠르게 -훑어보겠습니다.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/scenegraph.html b/manual/ko/scenegraph.html deleted file mode 100644 index 6551684e7be40d..00000000000000 --- a/manual/ko/scenegraph.html +++ /dev/null @@ -1,467 +0,0 @@ - - - 씬 그래프(Scene graph) - - - - - - - - - - - - - - -
-
-

씬 그래프(Scene graph)

-
-
-
-

※ 이 글은 Three.js의 튜토리얼 시리즈로서, -먼저 Three.js의 기본 구조에 관한 글을 -읽고 오길 권장합니다.

-

Three.js에서 가장 중요한 것은 무엇보다 씬 그래프(Scene graph)입니다. -3D 엔진에서 씬 그래프란 요소(node)의 계층 구조를 그림으로 나타낸 것으로, -여기서 각 요소는 각각의 "지역 공간(local space)"을 가리킵니다.

-

-

예시가 다소 추상적이니 좀 더 이해하기 쉬운 걸 예로 들어보겠습니다.

-

태양계, 그 중에서도 태양, 지구, 달이 적당하겠네요.

-

-

지구는 태양을 중심으로 공전합니다. 달은 지구를 중심으로 공전하죠. -달의 공전 궤도는 원과 유사합니다. 달의 관점에서 달은 지구의 "지역 -공간" 안에서 공전하는 셈이죠. 태양이 봤을 때 달은 취한 사람처럼 -스피로그래프(spirograph, 용수철 모양의 그래프)를 그리며 돌지만, -달은 그저 지구의 "지역 공간"을 도는 것에만 집중할 뿐입니다.

-

- -
- -

-

좀 더 가까운 예를 들어보죠. 우리는 지구에서 살지만 지구의 자전이나 -자전축, 태양을 공전하는 일은 크게 신경쓰지 않습니다. 이건 지구의 -일이니까요. 우리가 걷거나, 뭔가를 타고 이동하거나 수영하거나 달리거나 -하는 일들은 지구의 일과는 무관해 보입니다. 그래서 옛날 사람들은 지구가 -공전, 자전한다는 사실을 쉽게 받아들이지 못했죠. 우리가 걷든, 헤엄을 -치든, 우리의 삶은 지구의 "지역 공간" 안에서 이루어집니다. 태양에서 -봤을 때 여러분은 지구를 시속 약 1,600km로 돌고 태양의 주위를 시속 약 -107,800km로 도는 셈이지만, 우리는 이렇게 빨리 움직이기 위해 따로 -노력할 필요가 없습니다. 달과 마찬가지로 우리가 신경써야 하는 건 지구의 -"지역 공간" 뿐이죠.

-

이제 위 예제를 Three.js로 하나씩 구현해볼 겁니다. 먼저 중점에 -태양의 역할을 할 구체를 하나 놓는 것으로 시작하죠.

-

※ 앞으로 설명할 예제는 씬 그래프를 설명하기 위해 태양, 지구, 달을 -활용합니다. 실제 태양, 지구, 달의 운행을 구현하려면 물리를 사용해야 -하지만, 목적이 씬 그래프이니 씬 그래프로 실제 운행을 모방할 것입니다.

-
// 회전값을 업데이트할 객체들
-const objects = [];
-
-// 하나의 geometry로 모든 태양, 지구, 달을 생성
-const radius = 1;
-const widthSegments = 6;
-const heightSegments = 6;
-const sphereGeometry = new THREE.SphereGeometry(
-    radius, widthSegments, heightSegments);
-
-const sunMaterial = new THREE.MeshPhongMaterial({emissive: 0xFFFF00});
-const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
-sunMesh.scale.set(5, 5, 5);  // 태양의 크기를 키움
-scene.add(sunMesh);
-objects.push(sunMesh);
-
-

예제에서는 로우-폴리(low poly) 구체를 사용할 겁니다. 적도를 중심으로 -딱 6분할만 한 구체이죠. 이렇게 하면 자전 운동을 쉽게 확인할 수 있습니다.

-

같은 구체를 재활용할 것이므로 태양의 mesh를 5배로 설정해줍니다.

-

다음으로 MeshPhongMaterialemissive(방사성) 속성(property)을 -노랑으로 지정합니다. 퐁-메터리얼의 emissive 속성은 빛을 반사하지 않는 -표면 색상으로, 대신 광원에 해당 색상이 더해집니다.

-

씬 가운데에 단방향 조명(single point light)도 하나 넣습니다. 조명에 -대해서는 나중에 자세히 다루기로 하고, 지금은 한 점에서 발산하는 광원 -정도로 알아둡시다.

-
{
-  const color = 0xFFFFFF;
-  const intensity = 500;
-  const light = new THREE.PointLight(color, intensity);
-  scene.add(light);
-}
-
-

예제를 쉽게 확인하기 위해 카메라를 중점 바로 위에서 아래로 내려다보게 -설치합니다. 카메라의 시점을 바꾸는 가장 간단한 방법은 lookAt 메서드를 -활용하는 것으로, 이 메서드는 카메라가 넘겨받은 좌표를 바라보게끔 회전시켜줍니다. -하지만 이전에 먼저 카메라에게 어떤 방향이 위인지 알려줘야 합니다. 대부분의 -경우 양의 y(positive y) 방향을 위로 설정하면 되지만, 예제의 경우 위에서 -아래를 내려다 볼 것이므로 양의 z 방향이 위가 됩니다.

-
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-camera.position.set(0, 50, 0);
-camera.up.set(0, 0, 1);
-camera.lookAt(0, 0, 0);
-
-

이전 예제처럼 렌더링 루프에서 objects 배열의 모든 객체를 회전시키겠습니다.

-
objects.forEach((obj) => {
-  obj.rotation.y = time;
-});
-
-

sunMeshobjects 배열 안에 넣어놨으므로 태양 모델이 회전하는 것을 -확인할 수 있습니다.

-

- -

-

다음으로 지구를 추가하겠습니다.

-
const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
-const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
-earthMesh.position.x = 10;
-scene.add(earthMesh);
-objects.push(earthMesh);
-
-

지구는 푸른색을 사용했으나, 약간의 방사성(emissive) 파랑을 섞어 -검은 배경에서 잘 보이도록 만들었습니다.

-

그리고 이전에 썼던 sphereGeometry와 방금 만든 earthMaterial을 -이용해 earthMesh를 만들고, 태양의 10칸 옆에 위치하도록 설정한 뒤 -씬에 추가했습니다. 마지막으로 objects 배열에 추가했으므로, 지구도 -태양과 마찬가지로 자전하게 됩니다.

-

- -

-

하지만 지구가 태양의 주위를 돌진 않습니다. 지구를 바로 씬에 추가하는 -대신, 태양의 자식으로 추가하면...

-
-scene.add(earthMesh);
-+sunMesh.add(earthMesh);
-
-

...

-

- -

-

뭔가 이상합니다. 왜 지구의 크기와 태양의 크기가 같고 또 왜 저렇게 -멀리 떨어졌을까요? 기존 카메라로는 지구가 보이지 않아 카메라의 위치도 -150칸 위로 옮겼습니다.

-

방금 우리는 earthMeshsunMesh의 자식으로 추가했습니다. 이전에 -sunMesh를 만들 때 sunMesh.scale.set(5, 5, 5)라는 코드로 크기를 -5배로 설정했죠. 이는 sunMesh의 "지역 공간" 자체를 5배 키우겠다는 -의미입니다. 그래서 지구의 크기도 5배가 되었고, 거리(earthMesh.position.x = 10)도 -5배로 적용된 것이죠.

-

현재 예제의 씬 그래프는 다음과 같습니다.

-

-

이를 해결하기 위해 빈 씬 그래프 요소를 하나 추가합니다. 그리고 태양과 -지구 둘 다 이 요소의 자식으로 추가할 겁니다.

-
+const solarSystem = new THREE.Object3D();
-+scene.add(solarSystem);
-+objects.push(solarSystem);
-
-const sunMaterial = new THREE.MeshPhongMaterial({emissive: 0xFFFF00});
-const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
-sunMesh.scale.set(5, 5, 5);
--scene.add(sunMesh);
-+solarSystem.add(sunMesh);
-objects.push(sunMesh);
-
-const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
-const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
-earthMesh.position.x = 10;
--sunMesh.add(earthMesh);
-+solarSystem.add(earthMesh);
-objects.push(earthMesh);
-
-

여기서는 Object3D를 생성했습니다. Object3DMesh와 마찬가지로 -씬 그래프의 한 요소지만, material이나 geometry가 없다는 점이 다릅니다. -그저 하나의 빈 "지역 공간"인 셈이죠.

-

이제 씬 그래프는 다음과 같습니다.

-

-

sunMeshearthMeshsolarSystem의 자식입니다. 이 3 객체는 각각 -회전하죠. 이제 earthMeshsunMesh의 자식이 아니므로 5배 커지지도 -않았습니다.

-

- -

-

훨씬 낫네요. 지구는 태양보다 작고 태양을 공전하는 동시에 자전까지 합니다.

-

같은 패턴으로 달도 추가해봅시다.

-
+const earthOrbit = new THREE.Object3D();
-+earthOrbit.position.x = 10;
-+solarSystem.add(earthOrbit);
-+objects.push(earthOrbit);
-
-const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
-const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
--solarSystem.add(earthMesh);
-+earthOrbit.add(earthMesh);
-objects.push(earthMesh);
-
-+const moonOrbit = new THREE.Object3D();
-+moonOrbit.position.x = 2;
-+earthOrbit.add(moonOrbit);
-
-+const moonMaterial = new THREE.MeshPhongMaterial({color: 0x888888, emissive: 0x222222});
-+const moonMesh = new THREE.Mesh(sphereGeometry, moonMaterial);
-+moonMesh.scale.set(.5, .5, .5);
-+moonOrbit.add(moonMesh);
-+objects.push(moonMesh);
-
-

이전처럼 Object3D를 이용해 eathOrbit "지역 공간"을 만들고 거기에 -earthMeshmoonMesh를 추가했습니다. 씬 그래프는 다음과 같죠.

-

-

그리고 결과물입니다.

-

- -

-

처음에 봤던 예제처럼 달이 스피로그래프를 그리며 돌지만, 복잡한 수학적 -연산이 하나도 들어가지 않았습니다. 우리가 한 건 씬 그래프에게 그 연산을 -대신 맡긴 것 뿐이죠.

-

때론 씬 그래프의 요소를 시각화하는 것이 도움이 될 때도 있습니다. -Three.js는 유용한.. 음... 그러니까 이 거시기를 도와줄 -헬퍼 클래스가 있습니다.

-

그 중 하나는 AxesHelper로, 이 클래스는 지역 -X, -Y, -Z 축을 표시해줍니다. -한 번 여태까지 만든 요소에 모두 추가해보죠.

-
// AxesHelper 클래스를 각 요소에 지정
-objects.forEach((node) => {
-  const axes = new THREE.AxesHelper();
-  axes.material.depthTest = false;
-  axes.renderOrder = 1;
-  node.add(axes);
-});
-
-

우리는 축이 구체 내부에 있더라도 전부 보이길 원하므로, 각 축의 depthTest를 -false로 설정합니다. 이러면 Three.js는 어떤 물체 뒤에 있는 요소를 그릴지 -말지 검사하는 과정을 생략하므로, 어떤 방향에서라도 축을 볼 수 있습니다. 그리고 -renderOrder를 1로 설정(기본값은 0)해 구체를 전부 렌더링한 후 축을 렌더링하도록 -합니다. 그렇지 않으면 축을 그린 후 구체가 그려져 보이지 않을 수도 있으니까요.

-

- -

-

x축(빨강) 그리고 -z축(파랑) 축이 보이나요? 카메라가 바로 위에서 -아래를 내려다 보고, 각 물체도 y축을 따라 회전하므로 y축(초록)은 -보여도 거의 점처럼 보일 겁니다.

-

몇몇 축은 2개의 축이 겹쳐져 구별이 어려울 수 있습니다. sunMeshsolarSystem, -earthMeshearthOrbit이 같은 위치에 있기 때문이죠. 각 노드의 축을 켜고 -끌 수 있는 간단한 컨트롤 패널을 한 번 만들어보죠. 동시에 다른 헬퍼 클래스인 -GridHelper도 추가해보겠습니다. GridHelper는 X, Z축으로 2D 격자(grid)를 -만다는 클래스로, 기본값은 10x10 칸입니다.

-

또 Three.js와 함께 사용하기로 유명한 lil-gui도 -사용할 겁니다. lil-gui는 UI 라이브러리로, 객체와 속성 이름을 넘겨받고, 해당 속성의 -타입을 기반으로 속성값을 UI로 조정할 수 있게 해줍니다.

-

각 요소에 GridHelperAxesHelper를 추가하겠습니다. 각 노드에 헬퍼를 -추가하기 위해 각 노드의 이름이 필요하니, 기존 렌더링 루프를 제거하고 특정 -함수를 호출하게 변경하겠습니다.

-
-// add an AxesHelper to each node
--objects.forEach((node) => {
--  const axes = new THREE.AxesHelper();
--  axes.material.depthTest = false;
--  axes.renderOrder = 1;
--  node.add(axes);
--});
-
-+function makeAxisGrid(node, label, units) {
-+  const helper = new AxisGridHelper(node, units);
-+  gui.add(helper, 'visible').name(label);
-+}
-+
-+makeAxisGrid(solarSystem, 'solarSystem', 25);
-+makeAxisGrid(sunMesh, 'sunMesh');
-+makeAxisGrid(earthOrbit, 'earthOrbit');
-+makeAxisGrid(earthMesh, 'earthMesh');
-+makeAxisGrid(moonMesh, 'moonMesh');
-
-

makeAxisGrid 함수는 나중에 만들 AxisGridHelper를 생성하여 -lil-gui에 붙이는 역할을 합니다. 예제에서는 체크박스를 만들 것이므로, -boolean 타입으로 속성을 지정해주겠습니다. 또 하나의 속성이 바뀔 때 -축과 격자가 동시에 나타나고 사라지게 할 것이니 getter와 setter가 -있는 간단한 클래스를 하나 만들겠습니다. 이러면 lil-gui가 하나의 -속성을 바꿀 때 요소의 AxesHelperGridHelper의 속성을 -동시에 조작할 수 있죠.

-
/*
- * 축과 격자를 동시에 켜고 끕니다
- * lil-gui가 체크박스를 만들게 하려면 boolean 타입의
- * 속성을 지정해줘야 하므로, `visible` 속성에
- * getter와 setter를 지정해 lil-gui가 이 속성을
- * 바라보도록 합니다
- */
-class AxisGridHelper {
-  constructor(node, units = 10) {
-    const axes = new THREE.AxesHelper();
-    axes.material.depthTest = false;
-    axes.renderOrder = 2;  // 격자 다음에 렌더링
-    node.add(axes);
-
-    const grid = new THREE.GridHelper(units, units);
-    grid.material.depthTest = false;
-    grid.renderOrder = 1;
-    node.add(grid);
-
-    this.grid = grid;
-    this.axes = axes;
-    this.visible = false;
-  }
-  get visible() {
-    return this._visible;
-  }
-  set visible(v) {
-    this._visible = v;
-    this.grid.visible = v;
-    this.axes.visible = v;
-  }
-}
-
-

격자가 축을 가릴 수 있으니, AxesHelperrenderOrder를 -2로 설정하고 GridHelper를 2로 설정해 축을 격자 다음에 -렌더링하도록 합니다.

-

- -

-

solarSystem을 체크하면 위에서 설정했듯 지구가 정확히 중앙으로부터 -10칸 떨어진 것을 확인할 수 있습니다. 지구가 solarSystem "지역 공간" -안에 있는 것도 확인할 수 있죠. earthOrbit을 켜면 달도 마찬가지로 -earthOrbit의 "지역 공간"의 중심으로부터 정확히 2칸 떨어진 것을 -확인할 수 있을 겁니다.

-

씬 그래프의 다른 예시로 자동차를 들 수 있습니다.

-

-

차체(Car body)를 움직이면 바퀴(wheel)도 같이 움직입니다. 차체가 -바퀴와는 별도로 튀게 하려면(서스펜션. 역주) 차체와 바퀴를 하나의 -차체의 "프레임" 요소의 자식으로 설정할 수 있죠.

-

다른 예로 게임 속 인간형 캐릭터를 한 번 봅시다.

-

-

인간형 캐릭터의 씬 그래프는 꽤 복잡하네요. 위 씬 그래프는 상당히 축소된 -버젼인데도 말이죠. 좀 더 세세하게 만든다면 손가락 하나하나(최소한 28마디)와 -발가락 하나하나(또 다른 28마디), 얼굴과 턱, 눈 등등으로 나눠야 합니다.

-

약간 복잡한 씬 그래프를 만들어 봅시다. 탱크가 좋겠네요. 바퀴 6개와 -포탑으로 이루어진 간단한 탱크입니다. 또 탱크의 주위를 돌아다니는 구체를 -하나 만들어 탱크가 그 구체를 조준하도록 해보겠습니다.

-

아래는 예제를 구현하기 위한 씬 그래프입니다. mesh는 녹색으로 칠했고, -Object3D는 청색, 광원은 갈색, 카메라는 보라색으로 칠했습니다. 하나의 -카메라는 씬 그래프에 포함하지 않았습니다.

-
- -

모든 요소를 어떻게 설정했는지 코드를 하나씩 살펴보죠.

-

탱크가 조준할 목표를 만들기 위해 먼저 위 예제의 earthOrbit과 유사한 -targetOrbit(Object3D)을 만듭니다. 그리고 targetOrbit의 상대 좌표를 넘겨줄 -targetElevation(Object3D)을 만들어 targetOrbit의 자식으로 추가한 뒤, -또 다른 Object3D, targetBob을 만들어 targetElevation의 자식으로 추가합니다. -이 targetBob은 위아래로 보빙(bob은 낙시찌, 권투에서 bobbing은 몸을 숙이는 동작을 말함. 역주)하는 -역할을 할 겁니다. 마지막으로 색이 색이 바뀌는 동시에 회전할 targetMesh 육면체를 -만듭니다.

-
// 움직이는 목표
-targetOrbit.rotation.y = time * .27;
-targetBob.position.y = Math.sin(time * 2) * 4;
-targetMesh.rotation.x = time * 7;
-targetMesh.rotation.y = time * 13;
-targetMaterial.emissive.setHSL(time * 10 % 1, 1, .25);
-targetMaterial.color.setHSL(time * 10 % 1, 1, .25);
-
-

탱크는 먼저 tank라는 이름으로 다른 요소를 감쌀 Object3D를 하나 생성합니다. -예제에서는 커브에 따라 위치값을 반환받을 수 있는 SplineCurve를 이용하겠습니다. -0.0은 커브의 시작점이고, 1.0은 커브의 끝점으로, 먼저 탱크의 위치를 넘겨주어 탱크의 -다음 위치를 정한 뒤(아래 tankPosition. 역주), 커브의 다음 값을 받아 탱크가 어디를 -바라봐야할지 구합니다(아래 tankTarget. 역주). 그리고 구한 값을 Object3D.lookAt -메서드에 넘겨주어 탱크가 그 방향을 바라보도록 합니다.

-
const tankPosition = new THREE.Vector2();
-const tankTarget = new THREE.Vector2();
-
-...
-
-// move tank
-const tankTime = time * .05;
-curve.getPointAt(tankTime % 1, tankPosition);
-curve.getPointAt((tankTime + 0.01) % 1, tankTarget);
-tank.position.set(tankPosition.x, 0, tankPosition.y);
-tank.lookAt(tankTarget.x, 0, tankTarget.y);
-
-

그 다음 탱크의 포탑을 탱크의 자식으로 지정해서 탱크를 따라 움직이게 합니다. -그리고 목표물의 전역 위치값(global position)을 구한 뒤 Object3D.lookAt -메서드를 이용, 포탑이 목표물을 조준하게 합니다.

-
const targetPosition = new THREE.Vector3();
-
-...
-
-// 목표를 조준하도록
-targetMesh.getWorldPosition(targetPosition);
-turretPivot.lookAt(targetPosition);
-
-

turretCameraturretMesh의 자식으로 지정해 포탑과 함께 카메라가 -움직이도록 설정합니다. 또 카메라도 목표물을 바라보게 변경합니다.

-
// 포탑 카메라가 목표물을 바라보도록
-turretCamera.lookAt(targetPosition);
-
-

targetCameraPivottargetBob의 자식으로 지정해 목표물과 함께 -돌아다니도록 하고, 탱크의 뒤쪽을 바라보도록 합니다. 이는 targetCamera가 -목표물의 위치에서 살짝 벗어나게 하기 위함으로, 만약 카메라를 targetBob의 -자식으로 바로 추가한다면 목표물 안에서 탱크를 보게 될 겁니다.

-
// targetCameraPivot이 탱크를 바라보도록
-tank.getWorldPosition(targetPosition);
-targetCameraPivot.lookAt(targetPosition);
-
-

다음으로 바퀴를 회전시킵니다.

-
wheelMeshes.forEach((obj) => {
-  obj.rotation.x = time * 3;
-});
-
-

그리고 카메라를 간단한 설명과 함께 배열로 묶은 뒤,

-
const cameras = [
-  { cam: camera, desc: 'detached camera', },
-  { cam: turretCamera, desc: 'on turret looking at target', },
-  { cam: targetCamera, desc: 'near target looking at tank', },
-  { cam: tankCamera, desc: 'above back of tank', },
-];
-
-const infoElem = document.querySelector('#info');
-
-

시간에 따라 카메라를 변경하도록 합니다.

-
const camera = cameras[time * .25 % cameras.length | 0];
-infoElem.textContent = camera.desc;
-
-

- -

-

자, 이번 장은 여기까지입니다. 이 글이 씬 그래프가 어떻게 작동하는지, -어떻게 사용해야할지 감을 잡는 데 도움이 되었으면 좋겠네요. Object3D -요소를 만들어 부모로 만드는 것은 Three.js 뿐만 아니라 다른 3D 엔진을 -쓸 때도 중요한 요소입니다. 뭔가를 만들다보면 종종 복잡한 수학이 필요한 -것처럼 느껴질 수 있는데, 이때 씬 그래프를 사용하지 않는다면 달의 궤도를 -계산하거나 자동차 바퀴의 위치를 계산하는 건 굉장히 복잡할 겁니다. 씬 -그래프를 적절히 활용하면 이런 복잡한 동작을 더 쉽게 구현할 수 있죠.

-

다음 장에서는 재질(material)에 대해 알아보겠습니다.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/setup.html b/manual/ko/setup.html deleted file mode 100644 index 1efbfdeff9a42b..00000000000000 --- a/manual/ko/setup.html +++ /dev/null @@ -1,86 +0,0 @@ - - - 개발 환경 구성하기 - - - - - - - - - - - - - - -
-
-

개발 환경 구성하기

-
-
-
-

※ 이 글은 Three.js의 튜토리얼 시리즈로서, -먼저 Three.js의 기본 구조에 관한 글을 -읽고 오길 권장합니다.

-

Three.js의 다른 요소를 더 살펴보기 전에, 개발 환경을 구축하는 방법에 대해 -알아보겠습니다. WebGL은 보안 때문에 기본적으로 로컬 파일을 직접 가져다 쓸 -수 없습니다. 말인즉 실제 프로젝트를 배포/테스트하려면 웹 서버를 써야한다는 -이야기죠. 다행히도 웹 서버 관련 라이브러리는 굉장히 많고, 사용하기도 쉽습니다.

-

먼저 서버에 올릴 예시를 준비해야 합니다. 원한다면 이 사이트 전체를 다운 -받을 수도 있죠. 다운 받은 뒤에는 압축 프로그램으로 압축을 풀어주세요.

-

다음으로 간단한 웹 서버를 하나 다운 받습니다.

-

만약 UI가 있는 웹 서버를 찾는다면 -Servez를 추천합니다.

-

- -
- -

-

압축을 푼 경로를 지정하고, "Start" 버튼을 클릭하세요. 그런 다음 -http://localhost:8080/로 이동하기만 -하면 됩니다. 만약 예제를 보고 싶다면 http://localhost:8080/threejs로 -이동하세요.

-

서버를 중지하려면 Servez를 멈추거나 종료하면 됩니다.

-

만약 CLI(Command Line Interface, 명령 줄 인터페이스)를 선호한다면(전 선호합니다) -node.js를 써도 좋습니다. 다운 받아 설치한 다음 프롬프트 / -콘솔 / 터미널 창을 엽니다. 윈도우를 사용한다면 설치 마법사가 "Node Command Prompt"를 -추가할 테니 그걸 사용해도 좋습니다.

-

창을 띄웠으면 servez를 설치합니다.

-
npm -g install servez
-

OS X를 사용한다면 다음과 같이 설치할 수 있습니다.

-
sudo npm -g install servez
-

설치가 완료되면 다음과 같이 Servez를 실행합니다.

-
servez path/to/folder/where/you/unzipped/files
-

사족이지만, 다음처럼 쓸 수도 있죠.

-
cd path/to/folder/where/you/unzipped/files
-servez
-

정상적으로 작동했다면 다음과 같은 메시지가 뜰 겁니다.

-

- -
- -

-

다음으로 브라우저에서 http://localhost:8080/로 -접속하세요. 경로를 지정하지 않으면 현재 경로를 서버의 ROOT 경로로 사용합니다.

-

만약 Servez가 마음에 들지 않는다면, 다른 간단한 웹 서버를 사용해도 좋습니다.

-

이제 개발 환경을 갖추었으니, 텍스처에 대해 알아봅시다.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/shadertoy.html b/manual/ko/shadertoy.html deleted file mode 100644 index c86cbdbda86bd3..00000000000000 --- a/manual/ko/shadertoy.html +++ /dev/null @@ -1,323 +0,0 @@ - - - 쉐이더토이(Shadertoy) 활용하기 - - - - - - - - - - - - - - -
-
-

쉐이더토이(Shadertoy) 활용하기

-
-
-
-

쉐이더토이(Shadertoy)는 다양한 쉐이더를 제공하는 유명한 사이트입니다. 시리즈를 진행하다보니 쉐이더토이에서 받은 쉐이더를 Three.js에 적용하는 법을 물어보시는 분들이 꽤 있더군요.

-

하지만 쉐이더토이라고 불리는 데는 이유가 있습니다. 쉐이더토이에 올라온 쉐이더는 정석대로 만들어진 쉐이더가 아닙니다. 드위터(dwitter)(140자 내로 코드를 작성하는 사이트)나 js13kGames(13kb 이하의 게임을 만드는 사이트)처럼 여러 사람이 쉐이더-챌린지를 진행하는 곳이죠.

-

쉐이더토이의 미션은 주어진 픽셀 위치값으로 무언가 재밌는 것을 렌더링하는 함수를 만드는 것입니다. 재밌는 미션이고 많은 결과물을 보면 대단하다는 소리가 절로 나옵니다. 하지만 초보자가 보고 배우기에 좋은 예제들은 아니죠.

-

아래의 쉐이더로 도시 전체를 렌더링한 쉐이더토이 예제를 한 번 봅시다.

-
- -

제 컴퓨터에서 FHD 해상도를 기준으로 약 5 프레임 내외가 나옵니다. 이를 시티즈: 스카이라인(Cities: Skylines) 같은 게임과 비교해보면

-
- -

같은 컴퓨터에서 30-60 프레임이 나옵니다. 이 게임이 텍스처를 입힌 삼각형을 렌더링하는 등 좀 더 일반적인 기법을 사용했기 때문이죠.

-

뭐 그렇다고 해도 쉐이더토이의 쉐이더를 Three.js에 한 번 불러와보는 건 나쁘지 않을 겁니다.

-

아래는 쉐이더토이에서 "New"를 클릭했을 때 나오는 기본 쉐이더입니다(2019년 1월 기준).

-
// By iq: https://www.shadertoy.com/user/iq
-// license: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
-void mainImage( out vec4 fragColor, in vec2 fragCoord )
-{
-    // Normalized pixel coordinates (from 0 to 1)
-    vec2 uv = fragCoord/iResolution.xy;
-
-    // Time varying pixel color
-    vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx+vec3(0,2,4));
-
-    // Output to screen
-    fragColor = vec4(col,1.0);
-}
-
-

여기서 중요한 건 쉐이더는 GLSL(Graphic Library Shading Language)로 작성한다는 겁니다. GLSL은 3D 수학을 위해 C 언어 기반으로 고안된 상위 언어로, GLSL만의 고유한 타입이 있습니다. 위 코드의 vec4, vec2, vec3이 그런 고유 타입들이죠. vec2는 값이 2개, vec3는 3개, vec4는 4개인데, 이들은 각각 다양한 값으로 사용되지만 x, y, z 그리고 w로 사용하는 게 보통입니다.

-
vec4 v1 = vec4(1.0, 2.0, 3.0, 4.0);
-float v2 = v1.x + v1.y;  // adds 1.0 + 2.0
-
-

GLSL은 자바스크립트와 달리 C나 C++처럼 변수를 선언할 때 해당 타입을 사용해야 합니다. 예를 들어 실수(float)를 변수에 담을 때 자바스크립트는 var v = 1.2;와 같이 쓰지만, GLSL에서는 float v = 1.2;와 같이 씁니다.

-

이 글에서 GLSL을 자세히 설명하는 건 주제에 벗어나니 이 글을 읽어보거나 이 시리즈를 정주행해보기 바랍니다.

-

또한 2019년 1월 기준으로 쉐이더토이fragment shaders만 지원합니다. fragment shader는 아까 말한 미션처럼 픽셀의 좌표를 받아 해당 픽셀에 특정 색을 지정하는 역할을 하죠.

-

위 코드를 보면 mainImage 함수에 fragColor라는 이름의 out 매개변수가 보일 겁니다. outoutput의 줄임말로, 바로 이 매개변수가 색상값을 지정할 변수 합니다.

-

fragCoord, in(input의 줄임말) 매개변수는 out의 색상값을 사용할 픽셀의 좌표입니다. 이 픽셀 좌표로 새로운 색상값을 만들 수 있죠. 만약 400x300짜리 캔버스에 이 쉐이더를 적용한다면 이 함수는 400x300번, 그러니까 총 120,000번 호출되는 셈입니다.

-

코드에 선언부가 없긴 하지만 사용할 수 있는 변수가 2개 더 있습니다. 하나는 캔버스의 해상도를 설정하는 iResolution으로, 캔버스를 400x300으로 만들려면 iResolution400, 300으로 설정해야 합니다. 그리고 iResolution에 의해 픽셀 좌표가 바뀌면 uv 변수는 텍스처 크기의 0.0에서 1.0만큼 위, 옆으로 갑니다. 이렇듯 어떤 값을 정규화(normalize)하는 것이 작업을 간단히 하는 데 도움이 되기에 쉐이더토이의 주 함수는 대개 저런 식으로 시작합니다.

-

다른 하나는 iTime으로, 이는 페이지가 로드된 이후의 초 단위 시간값입니다.

-

쉐이더의 세계에서 이 전역 변수들은 균등(uniform) 변수라고 불립니다. 균등이라고 불리는 이유는 쉐이더 한 루프 안에서는 전혀 변하지 않는 변수이기 때문이죠. 다만 위에서 언급한 변수들은 GLSL의 표준 변수가 아니라 쉐이더토이에서 자체적으로 제공하는 변수입니다.

-

쉐이더토이 공식 문서에는 몇 가지 변수를 더 언급해 놓았지만, 일단은 위 두 변수를 활용해 예제를 하나 만들어보겠습니다.

-

먼저 캔버스 전체를 채울 평면을 하나 만듭니다. 배경과 하늘상자 추가하기에서 정육면체를 뺀 예제를 가져오겠습니다. 코드가 길지 않으니 아래에 전부 적도록 하죠.

-
function main() {
-  const canvas = document.querySelector('#c');
-  const renderer = new THREE.WebGLRenderer({ canvas });
-  renderer.autoClearColor = false;
-
-  const camera = new THREE.OrthographicCamera(
-    -1, // left
-     1, // right
-     1, // top
-    -1, // bottom
-    -1, // near,
-     1, // far
-  );
-  const scene = new THREE.Scene();
-  const plane = new THREE.PlaneGeometry(2, 2);
-  const material = new THREE.MeshBasicMaterial({
-      color: 'red',
-  });
-  scene.add(new THREE.Mesh(plane, material));
-
-  function resizeRendererToDisplaySize(renderer) {
-    const canvas = renderer.domElement;
-    const width = canvas.clientWidth;
-    const height = canvas.clientHeight;
-    const needResize = canvas.width !== width || canvas.height !== height;
-    if (needResize) {
-      renderer.setSize(width, height, false);
-    }
-    return needResize;
-  }
-
-  function render() {
-    resizeRendererToDisplaySize(renderer);
-
-    renderer.render(scene, camera);
-
-    requestAnimationFrame(render);
-  }
-
-  requestAnimationFrame(render);
-}
-
-main();
-
-

배경과 하늘상자 추가하기에서도 설명했지만 위와 같이 OrthographicCamera를 설정하면 2칸짜리 평면이 캔버스 전체를 채우게 됩니다. 당장은 평면이 빨간 MeshBasicMaterial을 사용했기에 캔버스 전체가 빨갛게 보입니다.

-

- -

-

이제 쉐이더토이에서 쉐이더를 가져와 적용해봅시다.

-
const fragmentShader = `
-#include <common>
-
-uniform vec3 iResolution;
-uniform float iTime;
-
-// By iq: https://www.shadertoy.com/user/iq
-// license: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
-void mainImage( out vec4 fragColor, in vec2 fragCoord )
-{
-    // Normalized pixel coordinates (from 0 to 1)
-    vec2 uv = fragCoord/iResolution.xy;
-
-    // Time varying pixel color
-    vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx+vec3(0,2,4));
-
-    // Output to screen
-    fragColor = vec4(col,1.0);
-}
-
-void main() {
-  mainImage(gl_FragColor, gl_FragCoord.xy);
-}
-`;
-
-

위 코드에서는 아까 설명했던 균등 변수 2개를 선언했습니다. 그런 다음 쉐이더토이에서 만든 GLSL 코드를 복사해 넣었죠. 그리고 하단에서 mainImagegl_FragColorgl_FragCoord.xy를 넘겨 호출했습니다. 여기서 사용한 gl_FragColor는 WebGL의 공식 전역 변수로, 쉐이더는 여기에 해당 픽셀의 색상값을 지정해야 합니다. gl_FragCoord 또한 WebGL 공식 전역 변수로, 현재 색상을 적용해야 하는 픽셀의 좌표값을 나타내죠.

-

다음으로 쉐이더에 데이터를 전달할 전달해야 하니 Three.js 균등 변수를 생성합니다.

-
const uniforms = {
-  iTime: { value: 0 },
-  iResolution:  { value: new THREE.Vector3() },
-};
-
-

Three.js의 균등 변수에는 value 속성을 넣어야 합니다. 물론 값으로 들어가는 데이터도 균등 변수의 타입과 맞아야 하죠.

-

fragment 쉐이더와 균등 변수를 ShaderMaterial에 넘겨줍니다.

-
-const material = new THREE.MeshBasicMaterial({
--    color: 'red',
--});
-+const material = new THREE.ShaderMaterial({
-+  fragmentShader,
-+  uniforms,
-+});
-
-

또한 매 프레임마다 균등 변수의 값을 변경하도록 합니다.

-
-function render() {
-+function render(time) {
-+  time *= 0.001;  // 초 단위로 변환
-
-  resizeRendererToDisplaySize(renderer);
-
-+  const canvas = renderer.domElement;
-+  uniforms.iResolution.value.set(canvas.width, canvas.height, 1);
-+  uniforms.iTime.value = time;
-
-  renderer.render(scene, camera);
-
-  requestAnimationFrame(render);
-}
-
-
-

참고: 쉐이더토이 공식 문서를 뒤져봤지만 iResolution가 왜 vec3여야 하는지, 3번째 값은 어디에 쓰이는 건지 알아내지 못했습니다. 일단 예제에서는 쓰지 않는 값이니 1로 설정하고 넘어가야겠네요. ¯\_(ツ)_/¯

-
-

- -

-

쉐이더토이에서 "New"를 클릭했을 때 나왔던 결과와 똑같네요. 물론 2019년 1월 기준으로 말이죠😉. 이 쉐이더는 어떻게 이런 결과를 만들어낸 걸까요?

-
    -
  • uv가 시간에 따라 서서히 0에서 1로 바뀝니다.
  • -
  • cos(uv.xyx)는 입력한 값에 코사인 함수를 적용해 vec3 형식으로 반환합니다. 하나는 uv.x에, 다른 하나는 uv.y에, 마지막은 다시 uv.x에 코사인 함수를 실행한 결과값이죠.
  • -
  • 이전에 cos(iTime+uv.xyx) 이렇게 시간값을 더해 애니메이션을 구현합니다.
  • -
  • 이전에 vec3(0,2,4)를 더해 cos(iTime+uv.xyx+vec3(0,2,4))와 같이 하면 코사인 파도가 생깁니다.
  • -
  • cos의 결과값은 -1부터 1까지이므로, 0.5 * 0.5 + cos(...)을 적용하면 -1 <-> 1, 0.0 <-> 1.0 이런 식으로 바뀝니다.
  • -
  • 이 결과값을 현재 픽셀에 대한 RGB 값으로 씁니다.
  • -
-

코드를 조금 수정하면 코사인 파도가 더 잘 보일 겁니다. 지금은 uv의 값이 0부터 1까지죠. 코사인의 주기는 2π이니 uv에 40.0을 곱해 uv값이 0부터 40까지 되도록 해보겠습니다. 이러면 화면에 파도가 약 6.3번 반복될 거예요.

-
-vec3 col = 0.5 + 0.5 * cos(iTime + uv.xyx + vec3(0,2,4));
-+vec3 col = 0.5 + 0.5 * cos(iTime + uv.xyx * 40.0 + vec3(0,2,4));
-
-

아래 예제를 보니 파도가 약 6개 하고 1/3 정도 보입니다. 파란선 사이에 빨간선이 보이는 건 파란색의 위치를 + vec3(0,2,4)로 4만큼 옮겼기 때문이죠. 이렇게 하지 않았다면 빨강과 파랑이 완전히 겹쳐 자주색으로 보였을 겁니다.

-

- -

-

주어지는 값이 이렇게 단순한데 이걸로 도심 운하, , 달팽이, 버섯 등을 구현하다니 정말 놀랍네요. 다만 삼각형으로 장면을 구성하는 일반적인 방법에 비해 왜 이 방법이 안 좋은지 분명히 하지 않은 게 아쉽습니다. 한 픽셀 한 픽셀 정성들여 픽셀의 색상을 연산하니 그만큼 한 프레임을 만드는 데 시간이 많이 걸릴 수밖에 없죠.

-

쉐이더토이 쉐이더 중에는 텍스처를 받아서 사용하는 경우도 있습니다.

-
// By Daedelus: https://www.shadertoy.com/user/Daedelus
-// license: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
-#define TIMESCALE 0.25
-#define TILES 8
-#define COLOR 0.7, 1.6, 2.8
-
-void mainImage( out vec4 fragColor, in vec2 fragCoord )
-{
-    vec2 uv = fragCoord.xy / iResolution.xy;
-    uv.x *= iResolution.x / iResolution.y;
-
-    vec4 noise = texture2D(iChannel0, floor(uv * float(TILES)) / float(TILES));
-    float p = 1.0 - mod(noise.r + noise.g + noise.b + iTime * float(TIMESCALE), 1.0);
-    p = min(max(p * 3.0 - 1.8, 0.1), 2.0);
-
-    vec2 r = mod(uv * float(TILES), 1.0);
-    r = vec2(pow(r.x - 0.5, 2.0), pow(r.y - 0.5, 2.0));
-    p *= 1.0 - pow(min(1.0, 12.0 * dot(r, r)), 2.0);
-
-    fragColor = vec4(COLOR, 1.0) * p;
-}
-
-

쉐이더에 텍스처를 넘겨주는 건 재질(material)에 텍스처를 넘겨주는 것과 비슷하나, 대신 텍스처를 균등 변수에 지정해야 합니다.

-

먼저 쉐이더에 균등 변수를 추가합니다. 텍스처는 GLSL에서 sampler2D라고 불립니다.

-
const fragmentShader = `
-#include <common>
-
-uniform vec3 iResolution;
-uniform float iTime;
-+uniform sampler2D iChannel0;
-
-...
-
-

다음으로 이 글에서 했던 것처럼 텍스처를 불러와 균등 변수에 지정합니다.

-
+const loader = new THREE.TextureLoader();
-+const texture = loader.load('resources/images/bayer.png');
-+texture.minFilter = THREE.NearestFilter;
-+texture.magFilter = THREE.NearestFilter;
-+texture.wrapS = THREE.RepeatWrapping;
-+texture.wrapT = THREE.RepeatWrapping;
-const uniforms = {
-  iTime: { value: 0 },
-  iResolution:  { value: new THREE.Vector3() },
-+  iChannel0: { value: texture },
-};
-
-

- -

-

여태까지는 쉐이더토이 사이트에 나와있는 대로 캔버스 전체에 쉐이더를 구현했습니다. 하지만 쉐이더를 사용할 때 꼭 예제 형식에 얽매일 필요는 없겠죠. 쉐이더토이의 작가들은 대부분 fragCoordiResolution을 사용한다는 것만 기억하면 됩니다. fragCoord가 꼭 픽셀의 좌표여야할 이유는 없다는 말입니다. 이를 텍스처 좌표로 바꿔 쉐이더를 텍스처처럼 사용할 수도 있죠. 이렇게 쉐이더 함수로 텍스처를 만드는 기법을 절차적 텍스처(procedural texture)라고 합니다.

-

예제에 이 기법을 적용해봅시다. Three.js에서 텍스처 좌표를 받아 여기에 iResolution을 곱해 fragCoord에 넘기는 게 제일 간단할 듯하네요.

-

먼저 fragment 쉐이더에서 쓸 varying을 추가합니다. varying은 vertex(정점) 쉐이더에서 fragment 쉐이더에 넘겨주는 값으로, 각 정점 사이를 보간한(점진적으로 채운(varied)) 값입니다. Three.js가 텍스처 좌표를 uv 앞에 varying을 이니셜인 v를 붙여 표시하니 그 이름을 그대로 사용하겠습니다.

-
...
-
-+varying vec2 vUv;
-
-void main() {
--  mainImage(gl_FragColor, gl_FragCoord.xy);
-+  mainImage(gl_FragColor, vUv * iResolution.xy);
-}
-
-

추가로 vertex 쉐이더를 만들어야 합니다. 아래는 가장 간단한 형태의 Three.js vertex 쉐이더로, uv, projectionMatrix, modelViewMatrix, position 등의 변수는 Three.js가 선언해줄 겁니다.

-
const vertexShader = `
-  varying vec2 vUv;
-  void main() {
-    vUv = uv;
-    gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
-  }
-`;
-
-

vertex 쉐이더도 같이 ShaderMaterial에 넘겨줍니다.

-
const material = new THREE.ShaderMaterial({
-  vertexShader,
-  fragmentShader,
-  uniforms,
-});
-
-

텍스처의 크기는 바뀔 일이 없으니 iResolution 균등 변수의 초기값을 미리 설정합니다.

-
const uniforms = {
-  iTime: { value: 0 },
--  iResolution:  { value: new THREE.Vector3() },
-+  iResolution:  { value: new THREE.Vector3(1, 1, 1) },
-  iChannel0: { value: texture },
-};
-
-

render 함수 안에 있던 코드도 삭제합니다.

-
-const canvas = renderer.domElement;
--uniforms.iResolution.value.set(canvas.width, canvas.height, 1);
-uniforms.iTime.value = time;
-
-

여기에 반응형 디자인에 관한 글에서 카메라와 회전하는 정육면체 3개를 가져왔습니다. 이제 한 번 실행해보죠.

-

- -

-

이 글이 Three.js에서 쉐이더토이의 쉐이더를 활용하는 데 도움이 되었으면 합니다. 누차 말하지만 쉐이더토이의 쉐이더는 실제 사용하기 위해 제작되었다기보다-함수 하나로 모든 요소를 만드는-연습용 챌린지에 가깝습니다. 하지만 그래도 쉐이더토이에 올라온 쉐이더들은 여전히 인상 깊고, 놀랍습니다. 배울 점도 굉장히 많죠.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/shadows.html b/manual/ko/shadows.html deleted file mode 100644 index 2c6cbb7a7ca5c0..00000000000000 --- a/manual/ko/shadows.html +++ /dev/null @@ -1,428 +0,0 @@ - - - 그림자<(Shadows) - - - - - - - - - - - - - - -
-
-

그림자(Shadows)

-
-
-
-

※ 이 글은 Three.js의 튜토리얼 시리즈로서, -먼저 Three.js의 기본 구조에 관한 글을 -읽고 오길 권장합니다.

-

※ 이전 글인 카메라에 관한 글과 -조명에 관한 글에서 이 장을 읽는 꼭 필요한 내용을 -다루었으니 꼭 먼저 읽고 오시기 바랍니다.

-

3D 그래픽에서 그림자란 그리 간단한 주제가 아닙니다. 그림자를 구현하는 방법은 -아주 많지만 모두 단점이 있기에 어떤 것이 가장 효율적이라고 말하기 어렵습니다. -이는 Three.js에서 제공하는 방법도 마찬가지이죠.

-

Three.js는 기본적으로 그림자 맵(shadow maps)을 사용합니다. 그림자 맵이란 -그림자를 만드는 빛의 영향을 받는, 그림자를 드리우는 모든 물체를 빛의 시점에서 -렌더링하는 기법을 말합니다. 중요하니 한 번 더 읽어보세요!

-

다시 말해, 공간 안에 20개의 물체와 5개의 조명이 있고, 20개의 물체 모두 -그림자를 드리우며 5개의 조명 모두 그림자를 지게 한다면, 한 장면을 만들기 -위해 총 6번 화면을 렌더링할 것이라는 이야기입니다. 먼저 조명 1번에 대해 -20개의 물체를 전부 렌더링하고, 다음에는 2번 조명, 그 다음에는 3번... -이렇게 처음 5번 렌더링한 결과물을 합쳐 최종 결과물을 만드는 것이죠.

-

만약 여기에 포인트(point) 조명을 하나 추가하면 조명 하나 때문에 6번을 다시 -렌더링해야 합니다.

-

이 때문에 그림자를 지게 하는 조명을 여러개 만들기보다 다른 방법을 찾는 -경우가 보통입니다. 주로 사용하는 방법은 조명이 여러개 있어도 하나의 조명만 -그림자를 지게끔 설정하는 것이죠.

-

물론 라이트맵(lightmaps)이나 앰비언트 오클루전(ambient occlusion)을 이용해 -빛의 영향을 미리 계산할 수도 있습니다. 이러면 정적 조명이나 정적 빛 반사를 -사용하는 것이기에 수정하기가 어렵지만, 적어도 성능은 빠릅니다. 이 두 가지 -모두 나중에 별도로 다룰 것입니다.

-

가짜 그림자를 사용하는 방법도 있습니다. 평면을 만들고, 흑백 텍스처를 입혀 -땅 위에 그림자가 있을 만한 위치에 가져다 놓는 것이죠.

-

예를 들어 아래 텍스처를 사용해 가짜 그림자를 만들어보겠습니다.

-
- -

이전 글에서 작성했던 코드를 일부 활용하겠습니다.

-

먼저 배경을 흰색으로 칠합니다.

-
const scene = new THREE.Scene();
-+scene.background = new THREE.Color('white');
-
-

같은 체크판 무늬 땅을 사용하되, 땅이 조명의 영향을 받을 필요는 없으니 -MeshBasicMaterial을 사용하겠습니다.

-
+const loader = new THREE.TextureLoader();
-
-{
-  const planeSize = 40;
-
--  const loader = new THREE.TextureLoader();
-  const texture = loader.load('resources/images/checker.png');
-  texture.wrapS = THREE.RepeatWrapping;
-  texture.wrapT = THREE.RepeatWrapping;
-  texture.magFilter = THREE.NearestFilter;
-  const repeats = planeSize / 2;
-  texture.repeat.set(repeats, repeats);
-
-  const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
-  const planeMat = new THREE.MeshBasicMaterial({
-    map: texture,
-    side: THREE.DoubleSide,
-  });
-+  planeMat.color.setRGB(1.5, 1.5, 1.5);
-  const mesh = new THREE.Mesh(planeGeo, planeMat);
-  mesh.rotation.x = Math.PI * -.5;
-  scene.add(mesh);
-}
-
-

평면의 색상을 1.5, 1.5, 1.5로 설정했습니다. 체크판 텍스처의 색상을 1.5, 1.5, 1.5 만큼 -곱해준 것이죠. 체크판 원본 텍스처의 색상이 0x808080(회색), 0xC0C0C0(옅은 회색)이므로, -여기에 1.5를 곱해주면 흰색, 옅은 회색 체크판이 됩니다.

-

이제 그림자 텍스처를 로드해보죠.

-
const shadowTexture = loader.load('resources/images/roundshadow.png');
-
-

구체와 관련된 객체를 분류하기 위해 배열을 만들겠습니다.

-
const sphereShadowBases = [];
-
-

다음으로 구체 geometry를 만듭니다.

-
const sphereRadius = 1;
-const sphereWidthDivisions = 32;
-const sphereHeightDivisions = 16;
-const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
-
-

가짜 그림자를 위한 평면 geometry도 만듭니다.

-
const planeSize = 1;
-const shadowGeo = new THREE.PlaneGeometry(planeSize, planeSize);
-
-

이제 구체를 아주 많이 만들겠습니다. 각각 구체마다 컨테이너 역할을 할 -THREE.Object3D를 만들고, 그림자 평면 mesh, 구체 mesh를 이 -컨테이너의 자식으로 만듭니다. 이러면 구체와 그림자를 동시에 움직일 수 -있죠. z-파이팅 현상을 막기 위해 그림자는 땅보다 약간 위에 둡니다. -또 depthWrite 속성을 false로 설정해 그림자끼리 충돌하는 현상을 -막습니다. 이 충돌 현상은 다른 글에서 -더 자세히 이야기할 거예요. 그림자는 빛을 반사하지 않으니 MeshBasicMaterial을 -사용합니다.

-

구체의 색상을 각각 다르게 지정하고, 컨테이너, 구체 mesh, 그림자 mesh와 -구체의 처음 y축 좌표를 배열에 기록합니다.

-
const numSpheres = 15;
-for (let i = 0; i < numSpheres; ++i) {
-  // 구체와 그림자가 같이 움직이도록 컨테이너(base)를 만듭니다
-  const base = new THREE.Object3D();
-  scene.add(base);
-
-  /**
-   * 그림자를 컨테이너에 추가합니다
-   * 주의: 여기서는 각 구체의 투명도를 따로 설정할 수 있도록
-   * 재질을 각각 따로 만듬
-   */
-  const shadowMat = new THREE.MeshBasicMaterial({
-    map: shadowTexture,
-    transparent: true,    // 땅이 보이도록
-    depthWrite: false,    // 그림자를 따로 정렬하지 않도록
-  });
-  const shadowMesh = new THREE.Mesh(shadowGeo, shadowMat);
-  shadowMesh.position.y = 0.001;  // 그림자를 땅에서 살짝 위에 배치
-  shadowMesh.rotation.x = Math.PI * -.5;
-  const shadowSize = sphereRadius * 4;
-  shadowMesh.scale.set(shadowSize, shadowSize, shadowSize);
-  base.add(shadowMesh);
-
-  // 구체를 컨테이너에 추가
-  const u = i / numSpheres;   // 반복문이 진행됨에 따라 0에서 1사이 값을 지정
-  const sphereMat = new THREE.MeshPhongMaterial();
-  sphereMat.color.setHSL(u, 1, .75);
-  const sphereMesh = new THREE.Mesh(sphereGeo, sphereMat);
-  sphereMesh.position.set(0, sphereRadius + 2, 0);
-  base.add(sphereMesh);
-
-  // y축 좌표를 포함해 나머지 요소를 기록
-  sphereShadowBases.push({ base, sphereMesh, shadowMesh, y: sphereMesh.position.y });
-}
-
-

조명은 2개를 만들겠습니다. 하나는 HemisphereLight, 강도를 2로 설정해 화면을 아주 -밝게 설정할 겁니다.

-
{
-  const skyColor = 0xB1E1FF;  // 하늘색
-  const groundColor = 0xB97A20;  // 오렌지 브라운
-  const intensity = 2;
-  const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
-  scene.add(light);
-}
-
-

다른 하나는 구체의 윤곽을 좀 더 분명하게 해 줄 DirectionalLight입니다.

-
{
-  const color = 0xFFFFFF;
-  const intensity = 1;
-  const light = new THREE.DirectionalLight(color, intensity);
-  light.position.set(0, 10, 5);
-  light.target.position.set(-5, 0, 0);
-  scene.add(light);
-  scene.add(light.target);
-}
-
-

이대로도 렌더링해도 좋지만, 구체들에 애니메이션을 한 번 줘봅시다. -컨테이너를 움직여 구체, 그림자가 xz축 평면을 따라 움직이게 하고, -Math.abs(Math.sin(time))를 사용해 구체에 공처럼 통통 튀는 -애니메이션을 넣어줍니다. 또 그림자 재질의 투명도를 조절해 구체가 -높을수록 그림자가 옅어지도록 합니다.

-
function render(time) {
-  time *= 0.001;  // 초 단위로 변환
-
-  ...
-
-  sphereShadowBases.forEach((sphereShadowBase, ndx) => {
-    const { base, sphereMesh, shadowMesh, y } = sphereShadowBase;
-
-    // u는 구체의 반복문을 실행하면서 인덱스에 따라 0 이상, 1 이하로 지정됩니다
-    const u = ndx / sphereShadowBases.length;
-
-    /**
-     * 컨테이너의 위치를 계산합니다. 구체와 그림자가
-     * 컨테이너에 종속적이므로 위치가 같이 변합니다
-     */
-    const speed = time * .2;
-    const angle = speed + u * Math.PI * 2 * (ndx % 1 ? 1 : -1);
-    const radius = Math.sin(speed - ndx) * 10;
-    base.position.set(Math.cos(angle) * radius, 0, Math.sin(angle) * radius);
-
-    // yOff 값은 0 이상 1 이하입니다
-    const yOff = Math.abs(Math.sin(time * 2 + ndx));
-    // 구체를 위아래로 튕김
-    sphereMesh.position.y = y + THREE.MathUtils.lerp(-2, 2, yOff);
-    // 구체가 위로 올라갈수록 그림자가 옅어짐
-    shadowMesh.material.opacity = THREE.MathUtils.lerp(1, .25, yOff);
-  });
-
-  ...
-
-

15가지 색상의 탱탱볼을 완성했습니다.

-

- -

-

물론 다른 모양의 그림자를 사용해야 하는 경우도 있습니다. 그림자의 경계를 분명하게 -하고 싶을 수도 있죠. 하지만 모든 물체의 그림자를 둥글게 표현하는 것이 좋은 경우도 -분명 있습니다. 모든 그림자를 둥글게 표현한 예 중 하나는 동물의 숲 포켓 캠프입니다. -자연스럽고 성능면에서도 이득이죠. Monument Valley도 -메인 캐릭터에 이런 그림자를 사용한 것으로 보입니다.

-

이제 그림자 맵을 살펴보겠습니다. 그림자를 드리울 수 있는 조명은 3가지, DirectionalLight, -PointLight, SpotLight입니다.

-

조명에 관한 글에서 썼던 예제로 먼저 DirectionalLight부터 -살펴보죠.

-

먼저 renderer의 그림자 맵 옵션을 켜야 합니다.

-
const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+renderer.shadowMap.enabled = true;
-
-

조명도 그림자를 드리우도록 옵션을 활성화합니다.

-
const light = new THREE.DirectionalLight(color, intensity);
-+light.castShadow = true;
-
-

또한 장면(scene) 안 각 mesh에 그림자를 드리울지, 그림자의 영향을 받을지 설정해줘야 합니다.

-

바닥 아래는 굳이 신경 쓸 필요가 없으니 평면(바닥)은 그림자의 영향만 받게 하겠습니다.

-
const mesh = new THREE.Mesh(planeGeo, planeMat);
-mesh.receiveShadow = true;
-
-

정육면체와 구체는 그림자도 드리우고, 영향도 받도록 설정합니다.

-
const mesh = new THREE.Mesh(cubeGeo, cubeMat);
-mesh.castShadow = true;
-mesh.receiveShadow = true;
-
-...
-
-const mesh = new THREE.Mesh(sphereGeo, sphereMat);
-mesh.castShadow = true;
-mesh.receiveShadow = true;
-
-

이제 실행해보죠.

-

- -

-

이런, 그림자 일부가 잘려나간 것이 보이나요?

-

이는 빛의 시점에서 장면을 렌더링해 그림자 맵을 만들기 때문입니다. 위 예제를 예로 들면 -DirectionalLight의 위치에 카메라가 있고, 해당 조명의 목표를 바라보는 것이죠. 조명의 -그림자에는 별도의 카메라가 있고, 이전에 카메라에 관한 글에서 -설명한 것처럼 일정 공간 안의 그림자만 렌더링합니다. 위 예제에서는 그 공간이 너무 좁은 -것이죠.

-

그림자용 카메라를 시각화하기 위해 조명의 그림자 속성에서 카메라를 가져와 CameraHelper를 -생성한 뒤, 장면에 추가하겠습니다.

-
const cameraHelper = new THREE.CameraHelper(light.shadow.camera);
-scene.add(cameraHelper);
-
-

이제 그림자가 렌더링되는 공간을 확인할 수 있을 겁니다.

-

- -

-

target의 x 값을 조정해보면 그림자용 카메라 범위 안에 있는 곳에만 그림자가 보이는 -것을 확인할 수 있을 겁니다.

-

이 공간의 크기는 이 카메라의 속성을 수정해 바꿀 수 있습니다.

-

그림자용 카메라의 속성을 수정하는 GUI를 추가해보죠. DirectionalLight는 빛이 평행으로 -나아가므로, DirectionalLight는 그림자용 카메라로 OrthographicCamera(정사영 카메라)를 -사용합니다. OrthographicCamera가 뭔지 잘 기억나지 않는다면, 카메라에 관한 이전 글을 -참고하세요.

-

OrthographicCamera의 시야는 육면체나 절두체(frustum)로 정의한다고 했었죠. left, -right, top, bottom, near, far, zoom 속성을 지정해서요.

-

lil-gui가 쓸 간단한 헬퍼 클래스를 하나 더 만들겠습니다. 이 DimensionGUIHelper는 -객체와 속성 이름 2개를 인자로 받아, GUI가 하나의 값을 조정할 때 하나의 값은 양수로, -다른 값은 음수로 지정합니다. 이렇게 하면 leftright값을 width로, up과 -down값을 height로 바꾸어 조작할 수 있죠.

-
class DimensionGUIHelper {
-  constructor(obj, minProp, maxProp) {
-    this.obj = obj;
-    this.minProp = minProp;
-    this.maxProp = maxProp;
-  }
-  get value() {
-    return this.obj[this.maxProp] * 2;
-  }
-  set value(v) {
-    this.obj[this.maxProp] = v /  2;
-    this.obj[this.minProp] = v / -2;
-  }
-}
-
-

또한 이전 글에서 썼던 MinMaxGUIHelper를 가져와 near와 -far 속성을 조작하는 데 사용하겠습니다.

-
const gui = new GUI();
-gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
-gui.add(light, 'intensity', 0, 2, 0.01);
-+{
-+  const folder = gui.addFolder('Shadow Camera');
-+  folder.open();
-+  folder.add(new DimensionGUIHelper(light.shadow.camera, 'left', 'right'), 'value', 1, 100)
-+    .name('width')
-+    .onChange(updateCamera);
-+  folder.add(new DimensionGUIHelper(light.shadow.camera, 'bottom', 'top'), 'value', 1, 100)
-+    .name('height')
-+    .onChange(updateCamera);
-+  const minMaxGUIHelper = new MinMaxGUIHelper(light.shadow.camera, 'near', 'far', 0.1);
-+  folder.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
-+  folder.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
-+  folder.add(light.shadow.camera, 'zoom', 0.01, 1.5, 0.01).onChange(updateCamera);
-+}
-
-

그리고 값이 바뀔 때마다 updateCamera 함수를 호출하도록 합니다. 이 함수 안에서는 -조명, 조명 헬퍼, 조명의 그림자용 카메라, 그림자용 카메라의 헬퍼를 업데이트할 거예요.

-
function updateCamera() {
-  // 헬퍼가 가이드라인을 그릴 때 필요한 조명 목표(target)의 matrixWorld를 업데이트 합니다
-  light.target.updateMatrixWorld();
-  helper.update();
-  // 그림자용 카메라의 투영 행렬(projection matrix)를 업데이트합니다
-  light.shadow.camera.updateProjectionMatrix();
-  // 그림자용 카메라를 보기 위해 설치한 카메라의 헬퍼를 업데이트합니다
-  cameraHelper.update();
-}
-updateCamera();
-
-

이제 그림자용 카메라에 GUI가 생겼으니, 값들을 조정하며 놀아봅시다.

-

- -

-

widthheight 속성을 30 정도로 조정하면 그림자가 있어야 할만한 공간은 -대부분 그림자용 카메라 안에 속할 겁니다.

-

하지만 여기서 의문이 하나 생깁니다. 어째서 widthheight를 완전 큰 값으로 -설정해 모든 요소를 다 포함하도록 하지 않는 걸까요? widthheight를 100 정도로 -설정해보세요. 아래와 같은 현상이 나타날 겁니다.

-
- -

왜 그림자의 해상도가 낮아졌을까요?

-

이는 그림자 관련 설정을 할 때 항상 주의해야하는 부분입니다. 사실 그림자 맵은 그림자가 -포함된 하나의 텍스처입니다. 이 텍스처는 크기가 정해져 있죠. 위 예제에서 카메라의 공간을 -늘리면, 이 텍스처 또한 늘어납니다. 다시 말해 공간을 크게 설정할수록 그림자가 더 각져 -보일 거라는 얘기죠.

-

그림자 맵의 해상도는 light.shadow.mapSize 속성의 widthheight 속성으로 설정합니다(기본값은 -512x512). 그림자 맵은 크게 설정할수록 메모리를 많이 차지하고, 연산이 더 복잡해지므로 -가능한 작게 설정하는 것이 좋습니다. 이는 그림자용 카메라의 공간도 마찬가지죠. 작을 수록 -그림자의 퀄리티가 좋아질 테니 가능한 공간을 작게 설정하는 것이 좋습니다. 또한 기기마다 -렌더링할 수 있는 텍스처의 용량이 정해져 있으니 주의해야 합니다. Three.js에서 이 용량은 -renderer.capabilities.maxTextureSize로 확인할 수 있습니다.

-

SpotLight는 그림자용 카메라로 PerspectiveCamera(원근 카메라)를 사용합니다. DirectionalLight의 -그림자용 카메라는 거의 모든 속성을 직접 변경할 수 있었지만, SpotLight의 그림자용 카메라는 -조명 속성의 영향을 받습니다. 카메라의 fov 속성은 SpotLightangle 속성과 직접 연결되어 -있죠. aspect는 그림자 맵의 크기에 따라 자동으로 정해집니다.

-
-const light = new THREE.DirectionalLight(color, intensity);
-+const light = new THREE.SpotLight(color, intensity);
-
-

추가로 이전 글에서 썼던 penumbra(반음영), angle 설정을 가져오겠습니다.

-

- -

-

마지막으로 PointLight를 살펴보죠. PointLight는 모든 방향으로 빛을 발산하기에 -관련 설정은 nearfar 정도입니다. 그리고 사실 PointLight의 그림자는 정육면체의 -각 면에 SpotLight를 하나씩, 총 6개의 그림자를 놓은 것과 같습니다. 한 방향에 한 -번씩, 총 6번을 렌더링해야 하니 렌더링 속도가 훨씬 느리겠죠.

-

이번에는 장면 주위에 상자를 두어 벽과 천장에도 그림자가 생길 수 있도록 해보겠습니다. -먼저 재질의 side 속성을 THREE.BackSide로 설정해 외부 상자의 밖이 아닌 안을 렌더링 -하도록 합니다. 바닥과 마찬가지로 그림자를 드리우지 않도록 설정하고, z-파이팅 현상을 -피하기 위해 외부 상자를 바닥보다 살짝 아래에 둡니다.

-
{
-  const cubeSize = 30;
-  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
-  const cubeMat = new THREE.MeshPhongMaterial({
-    color: '#CCC',
-    side: THREE.BackSide,
-  });
-  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
-  mesh.receiveShadow = true;
-  mesh.position.set(0, cubeSize / 2 - 0.1, 0);
-  scene.add(mesh);
-}
-
-

물론 조명도 PointLight로 바꿔야죠.

-
-const light = new THREE.SpotLight(color, intensity);
-+const light = new THREE.PointLight(color, intensity);
-
-....
-
-// 조명이 위치를 확인하기 쉽도록 헬퍼 추가
-+const helper = new THREE.PointLightHelper(light);
-+scene.add(helper);
-
-

- -

-

GUI의 position 속성을 조정해 조명을 움직이면 벽에 그림자가 지는 걸 -확인할 수 있을 겁니다. 다른 그림자와 마찬가지로 near 값보다 가까운 -곳은 그림자가 지지 않고, far 값보다 먼 곳은 항상 그림자가 지죠.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/textures.html b/manual/ko/textures.html deleted file mode 100644 index cb1b6defec4b28..00000000000000 --- a/manual/ko/textures.html +++ /dev/null @@ -1,561 +0,0 @@ - - - 텍스처(Textures) - - - - - - - - - - - - - - -
-
-

텍스처(Textures)

-
-
-
-

※ 이 글은 Three.js의 튜토리얼 시리즈로서, -먼저 Three.js의 기본 구조에 관한 글과 -개발 환경 설정하는 법을 읽고 오길 권장합니다.

-

※ 텍스처, Texture는 "질감"으로 번역할 수 있으나, 그대로 표기하는 쪽이 -직관적이라 판단하여 텍스처로 번역하였습니다.

-

Three.js에서 텍스처를 이야기하기란 쉽지 않습니다. 텍스처는 워낙 방대한 -주제이고, 각 주제끼리도 서로 연결되어 있어 한 번에 설명하는 것이 거의 -불가능에 가깝기 때문이죠. 어떻게 설명해야 잘 설명했다고 할 수 있을지 -확신은 없지만, 일단 해보기로 합시다. 다음은 이 글의 간략한 목차입니다.

- - -

하이, 텍스처

-

텍스처는 일반적으로 포토샵이나 김프 등의 프로그램으로 만든 이미지입니다. -예를 들어 아래 이미지를 정육면체에 씌워보죠.

-
- -
- -

예제는 처음 만들었던 것을 사용하겠습니다. 추가로 TextureLoader를 새로 생성한 -뒤, 인스턴스의 load 메서드에 이미지의 URL을 넘겨주어 호출하고, -반환 받은 값을 재질(material)의 map 속성에 지정합니다(color 속성은 지정하지 -않습니다).

-
+const loader = new THREE.TextureLoader();
-+const texture = loader.load( 'resources/images/wall.jpg' );
-+texture.colorSpace = THREE.SRGBColorSpace;
-
-const material = new THREE.MeshBasicMaterial({
--  color: 0xFF8844,
-+  map: texture,
-});
-
-

MeshBasicMaterial을 사용했으므로 광원을 사용할 필요가 없습니다.

-

- -

-

육면체 각 면에 다른 텍스처 지정하기

-

이번에는 육면체의 각 면에 다른 텍스처를 넣어볼까요?

-
-
- - - -
-
- - - -
-
- -

단순히 재질을 6개 만들어 Mesh를 생성할 때 배열로 넘겨주기만 하면 됩니다.

-
const loader = new THREE.TextureLoader();
--const texture = loader.load( 'resources/images/wall.jpg' );
--texture.colorSpace = THREE.SRGBColorSpace;
-
--const material = new THREE.MeshBasicMaterial({
--  map: texture,
--});
-+const materials = [
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-1.jpg')}),
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-2.jpg')}),
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-3.jpg')}),
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-4.jpg')}),
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-5.jpg')}),
-+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-6.jpg')}),
-+];
--const cube = new THREE.Mesh(geometry, material);
-+const cube = new THREE.Mesh(geometry, materials);
-
-+function loadColorTexture( path ) {
-+  const texture = loader.load( path );
-+  texture.colorSpace = THREE.SRGBColorSpace;
-+  return texture;
-+}
-
-

껌이네요.

-

- -

-

주의해야할 점은 모든 geometry가 재질을 배열로 받진 않는다는 점입니다. -BoxGeometryBoxGeometry는 최대 6개, ConeGeometry와 -ConeGeometry는 밑면과 뿔 부분에 하나씩 최대 2개, CylinderGeometry와 -CylinderGeometry는 아래, 위, 옆면 하나씩 최대 3개를 지정할 수 있죠. -다른 경우에는 geometry를 따로 만들거나, 텍스처의 좌표를 직접 수정해야 합니다.

-

다른 3D 엔진에서나 Three.js에서나, 하나의 geometry에서 여러 텍스처를 쓰고 싶을 때는 -보통 텍스처 아틀라스를 사용합니다. -텍스처 아틀라스란 여러 이미지로 구성된 하나의 텍스처로, geometry의 정점에 따라 텍스처의 -좌표를 조절해 geometry의 각 삼각형이 텍스처의 일정 부분을 표현하도록 할 수 있습니다.

-

그렇다면 텍스처의 좌표란 무엇일까요? 이는 geometry의 각 정점에 추가되는 데이터로, 특정 -정점에 텍스처의 어느 부분을 써야하는지를 나타냅니다. 자세한 사용법은 나중에 -사용자 지정 geometry 만들기에서 살펴보겠습니다.

-

텍스처 불러오기

-

간단한 방법

-

이 사이트의 예제는 대부분 텍스처를 로딩할 때 간단한 메서드를 사용했습니다. -TextureLoader를 생성하고, 인스턴스의 load 메서드를 -호출하는 거죠. 이 load 메서드는 Texture 객체를 반환합니다.

-
const texture = loader.load('resources/images/flower-1.jpg');
-
-

알아둬야 할 건 이 메서드는 비동기로 작동한다는 점입니다. 이미지를 완전히 -불러온 후 이미지로 텍스처를 업데이트하기 전까지, 텍스처는 투명하게 보일 겁니다.

-

텍스처를 전부 불러오지 않아도 브라우저가 페이지 렌더링을 시작할 것이므로 이는 -속도면에서 꽤 큰 장점입니다. 텍스처를 언제 다 불러왔는지 알아야 하는 경우가 -아니라면, 대부분 큰 문제가 되지 않겠죠.

-

텍스처를 불러온 후 처리하기

-

텍스처를 불러온 후 후처리를 위해 load 메서드는 두 번째 인자로 콜백(callback) -함수를 받습니다. 이 함수는 텍스처를 전부 불러온 후 호출되죠. 글의 첫 번째 예제를 -조금 수정해보겠습니다.

-
const loader = new THREE.TextureLoader();
-loader.load('resources/images/wall.jpg', (texture) => {
-  const material = new THREE.MeshBasicMaterial({
-    map: texture,
-  });
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-  cubes.push(cube);  // 회전 애니메이션을 위해 배열에 추가
-});
-
-

브라우저의 캐시를 비우거나 인터넷 연결 속도가 느리지 않는 한 차이를 느끼기 -어렵긴 하지만, 텍스처를 불러온 뒤 화면을 렌더링합니다.

-

- -

-

다수의 텍스처를 불러온 후 처리하기

-

다수의 텍스처를 한 번에 불러와야 할 경우 LoadingManager를 사용할 수 있습니다. -TextureLoader를 생성할 때 미리 생성한 LoadingManager의 인스턴스를 인자로 -넘겨주고, LoadingManager 인스턴스의 onLoad 속성에 -콜백 함수를 설정해주는 거죠.

-
+const loadManager = new THREE.LoadingManager();
-*const loader = new THREE.TextureLoader(loadManager);
-
-const materials = [
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-1.jpg')}),
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-2.jpg')}),
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-3.jpg')}),
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-4.jpg')}),
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-5.jpg')}),
-  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-6.jpg')}),
-];
-
-+loadManager.onLoad = () => {
-+  const cube = new THREE.Mesh(geometry, materials);
-+  scene.add(cube);
-+  cubes.push(cube);  // 회전 애니메이션을 위해 배열에 추가
-+};
-
-

LoadingManageronProgress에 콜백 함수를 지정하면 -현재 진행 상태를 추적할 수 있습니다.

-

일단 HTML로 프로그래스 바(progress bar)를 만들겠습니다.

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="loading">
-+    <div class="progress"><div class="progressbar"></div></div>
-+  </div>
-</body>
-
-

스타일도 추가하죠.

-
#loading {
-    position: fixed;
-    top: 0;
-    left: 0;
-    width: 100%;
-    height: 100%;
-    display: flex;
-    justify-content: center;
-    align-items: center;
-}
-#loading .progress {
-    margin: 1.5em;
-    border: 1px solid white;
-    width: 50vw;
-}
-#loading .progressbar {
-    margin: 2px;
-    background: white;
-    height: 1em;
-    transform-origin: top left;
-    transform: scaleX(0);
-}
-
-

다음으로 onProgress 콜백에서 .progressbar의 X축 크기를 조정하겠습니다. -콜백 함수는 마지막으로 불러온 자원의 URL, 현재까지 불러온 자원의 수, 총 지원의 -수를 매개변수로 받습니다.

-
+const loadingElem = document.querySelector('#loading');
-+const progressBarElem = loadingElem.querySelector('.progressbar');
-
-loadManager.onLoad = () => {
-+  loadingElem.style.display = 'none';
-  const cube = new THREE.Mesh(geometry, materials);
-  scene.add(cube);
-  cubes.push(cube);  // 회전 애니메이션을 위해 배열에 추가
-};
-
-+loadManager.onProgress = (urlOfLastItemLoaded, itemsLoaded, itemsTotal) => { // 마지막으로 불러온 자원의 URL, 현재까지 불러온 자원의 수, 총 지원의 수
-+  const progress = itemsLoaded / itemsTotal;
-+  progressBarElem.style.transform = `scaleX(${progress})`;
-+};
-
-

캐시를 비우거나 인터넷 속도가 느리지 않다면 프로그래스 바가 보이지 않을 -수도 있습니다.

-

- -

-

다른 도메인(origin)에서 텍스처 불러오기

-

다른 서버에서 이미지를 불러오려면 해당 서버가 CORS 헤더를 -보내줘야 합니다. CORS 헤더가 없다면 Three.js가 이미지를 불러오지 않을 것이고, -에러가 발생할 겁니다. 만약 이미지 호스팅 서버를 운영한다면 해당 서버가 CORS 헤더를 -보내는지 확인해보세요.

-

imgur, flickr, github -등의 사이트는 자신이 호스팅하는 이미지를 사용해도 좋다는 헤더를 보냅니다. -대부분의 웹사이트는 이를 허용하지 않죠.

-

메모리 관리

-

텍스처는 Three.js 앱에서 메모리를 가장 많이 사용하는 요소 중 하나입니다. -대체로 텍스처는 약 너비 * 높이 * 4 * 1.33 바이트의 메모리를 사용합니다.

-

여기서 압축은 그다지 중요한 요소가 아닙니다. 예를 들어 집이 포함된 장면(scene)을 -만든다고 해보죠. 집 안에는 탁자가 있고, 탁자의 윗면에 나무 텍스처를 씌우려고 -합니다.

-
- -

이 이미지는 매우 고 배율로 압축되어 157kb 밖에 되지 않습니다. 상대적으로 -다운 속도는 빠를 것이나, 이 이미지의 실제 크기는 3024 x 3761 픽셀입니다. -위 공식에 따라 이 이미지를 적용해보면,

-
3024 * 3761 * 4 * 1.33 = 60505764.5
-

무려 약 60 메가바이트의 메모리를 사용합니다. 이런 텍스처가 몇 개만 더 -있어도 메모리 부족으로 앱을 사용하지 못할 수 있죠(OUT_OF_MEMORY).

-

극단적인 예제이기는 하나, 이 예제는 텍스처를 사용하는데 숨겨진 비용을 고려해야 -한다는 것을 잘 알려줍니다. Three.js가 텍스처를 사용하려면 GPU에 텍스처를 -넘겨주어야 하는데, GPU는 일반적으로 압축하지 않은 데이터를 사용하죠.

-

이 예시의 교훈은 파일의 용량이 아니라 파일의 해상도를 줄어야 한다는 것입니다. -파일의 용량이 작다면 불러오는 속도가 빠를 것이고, 해상도가 낮다면 메모리를 -그만큼 적게 사용하겠죠. 얼마나 낮게 만들어야 할까요? 필요한 만큼 퀄리티를 -유지한 선에서 가능한 낮게 만드는 게 좋습니다.

-

JPG vs PNG

-

이는 HTML과 마찬가지입니다. JPG는 손실 압축을 사용하고, PNG는 비손실 압축을 -사용하는 대신 보통 PNG가 더 용량이 크죠. 하지만 PNG는 투명도를 지원합니다. -PNG는 비-이미지 데이터인 법선 맵(normal maps), 그리고 나중에 살펴볼 다른 -비-이미지 데이터를 사용하기에 현재로써는 가장 적당한 파일 형식입니다.

-

위에서 말했듯, WebGL에서는 JPG가 용량이 더 작긴 해도 -PNG 형식보다 메모리 점유율이 낮진 않습니다.

-

필터링과 Mips

-

이 16x16 텍스처를

-
- -

아래의 정육면체에 적용해보죠.

-
- -

그리고 정육면체를 아주 작게 렌더링합니다.

-
- -

음, 보기가 어렵네요. 확대해봅시다.

-
- -

GPU는 작은 정육면체를 표현할 때 어떻게 각 픽셀의 색상을 결정할까요? 정육면체가 -작아도 너무 작아서 1, 2 픽셀 정도라면요?

-

이게 바로 필터링(filtering)이 있는 이유입니다.

-

포토샵이라면 근처 픽셀의 평균을 내 해당 1, 2 픽셀의 형태를 결정할 겁니다. -이는 매우 무거운 작업이죠. GPU는 이 문제를 해결하기 위해 밉맵(mipmaps)을 -사용합니다.

-

밉(mips)은 텍스처의 복사본으로, 각 밉은 축소된 이전 밉보다 반만큼 작습니다. -밉은 1x1 픽셀 밉을 생성할 때까지 계속 생성되죠. 위 이미지의 경우 밉은 다음처럼 -생성될 겁니다.

-
- -

이제 1, 2 픽셀 정도로 작은 정육면체를 렌더링할 때 GPU는 가장 작거나, 두 번째로 -작은 밉을 선택해 텍스처를 적용하기만 하면 되죠.

-

Three.js에서는 텍스처의 크기가 원본보다 클 때와 작을 때 각각 어떻게 표현할지를 -설정할 수 있습니다.

-

텍스처의 크기가 원본보다 클 때의 필터는 texture.magFilter -속성을 THREE.NearestFilterTHREE.LinearFilter로 지정해 설정합니다.

-

NearestFilter는 말 그대로 텍스처에서 가장 가까운 픽셀을 고르는 것입니다. -낮은 해상도라면 텍스처가 픽셀화되어 마인크래프트 같은 느낌을 주겠죠.

-

LinearFilter는 가장 가까운 4개의 픽셀을 골라 각 픽셀의 실제 거리에 따라 적절한 -비율로 섞는 것을 말합니다.

-
-
-
-
Nearest
-
-
-
-
Linear
-
-
- -

텍스처가 원본 크기보다 작을 때의 필터는 texture.minFilter -속성을 다음 6가지 값 중 하나로 지정해 사용합니다.

-
    -
  • THREE.NearestFilter

    -

    원본보다 클 때와 마찬가지로 가장 가까운 픽셀을 선택합니다

    -
  • -
  • THREE.LinearFilter

    -

    원본보다 클 때와 마찬가지로 주변의 가까운 픽셀 4개를 골라 섞습니다

    -
  • -
  • THREE.NearestMipmapNearestFilter

    -

    적절한 밉을 고른 뒤 밉에서 픽셀 하나를 선택합니다

    -
  • -
  • THREE.NearestMipmapLinearFilter

    -

    두 개의 밉을 골라 픽셀을 하나씩 선택한 후, 두 픽셀을 섞습니다

    -
  • -
  • THREE.LinearMipmapNearestFilter

    -

    적절한 밉을 고른 뒤 픽셀 4개를 골라 섞습니다

    -
  • -
  • THREE.LinearMipmapLinearFilter

    -

    두 개의 밉을 골라 각각 픽셀을 4개씩 선택하고, 선택한 8개의 픽셀을 하나의 픽셀로 혼합합니다

    -
  • -
-

아래는 6개의 필터를 각각 적용한 예제입니다.

-
-
-
-
클릭해
텍스처를
변경
-
-
nearest
-
linear
-
nearest
mipmap
nearest
-
nearest
mipmap
linear
-
linear
mipmap
nearest
-
linear
mipmap
linear
-
-
- -

여기서 주의깊게 봐야할 건 상단 왼쪽 NearestFilter와 상단 중앙의 LinearFilter는 -밉을 사용하지 않는다는 점입니다. 두 텍스처를 보면 멀리 떨어질수록 픽셀이 깜빡이는 증상이 -보이죠. 이는 GPU가 픽셀을 원본 텍스처에서 선택하기 때문입니다. NearestFilter는 하나의 -픽셀을 선택하고, LinearFilter는 4개의 픽셀을 선택하기는 하나, 픽셀을 제대로 표현하진 -못합니다. 다른 4개 예제는 그나마 낫고, 그 중 LinearMipmapLinearFilter가 제일 깔끔해 -보이네요.

-

위 캔버스를 클릭해보면 텍스처를 바꿀 수 있습니다. 하나는 여태까지 사용하던 텍스처이고, -또 하나는 밉의 각 단계가 다른 색으로 나타나는 텍스처이죠.

-
-
-
- -

이 텍스처는 필터의 동작 원리를 이해하기 쉽도록 해줍니다. 위 예제에서 NearestFilter와 -LinearFilter는 아주 멀리까지도 첫 번째 밉을 사용합니다. 반면 상단 오른쪽과 하단 중앙을 -보면 밉의 경계가 뚜렷이 보이죠.

-

다시 원래 텍스처로 바꿔보면 하단 오른쪽이 가장 매끄러운 것이 보일 겁니다. 왜 항상 이 -필터를 쓰지 않는 걸까요? 뭐, 레트로 감성을 표현한다든가 하는 이유로 물체들이 픽셀화된 -것을 원할 수도 있죠. 하지만 보다 흔한 이유는 성능입니다. 8개의 픽셀 데이터를 처리하는 -것보다는 당연히 1개의 픽셀 데이터를 처리하는 게 훨씬 빠르겠죠. 하나의 텍스처로 이런 -성능 차이를 체감하기는 어렵지만, Three.js를 사용하다보면 하나의 물체에 4, 5개의 텍스처가 -들어가는 경우도 빈번합니다. 4개의 텍스처에서 각각 8개의 픽셀을 처리해야 하니, 이는 한 -프레임당 32개의 픽셀을 처리해야 함을 의미하죠. 이는 저사양 기기를 고려할 때 특히 중요히 -여겨야 하는 요소입니다.

-

텍스처의 반복(repeating), 위치 조절(offseting), 회전(rotating), 래핑(wrapping)

-

텍스처에는 반복, 위치, 회전 설정이 있습니다.

-

Three.js는 기본적으로 텍스처를 반복하지 않습니다. 반복 여부를 설정하는 -2가지 속성이 있는데, 하나는 수평 래핑을 설정하는 wrapS이고, -또 하나는 수직 래핑을 설정하는 wrapT입니다.

-

두 속성은 다음 중 하나로 지정할 수 있습니다.

-
    -
  • THREE.ClampToEdgeWrapping

    -

    텍스처의 가장자리 픽셀을 계속해서 반복합니다

    -
  • -
  • THREE.RepeatWrapping

    -

    텍스처 자체를 반복합니다

    -
  • -
  • THREE.MirroredRepeatWrapping

    -

    텍스처 자체를 반복하되, 매번 뒤집습니다.

    -
  • -
-

양 방향의 래핑을 키려면 다음과 같이 설정할 수 있습니다.

-
someTexture.wrapS = THREE.RepeatWrapping;
-someTexture.wrapT = THREE.RepeatWrapping;
-
-

반복은 repeat 속성으로 설정할 수 있죠.

-
const timesToRepeatHorizontally = 4;
-const timesToRepeatVertically = 2;
-someTexture.repeat.set(timesToRepeatHorizontally, timesToRepeatVertically);
-
-

텍스처의 위치는 offset 속성을 설정해 조절할 수 있습니다. 텍스처 위치의 단위는 -텍스처의 크기와 1:1, 즉 0은 위치가 그대로인 것이고 1은 각 축에서 텍스처 크기만큼 -이동한 것을 의미하죠.

-
const xOffset = .5;   // 텍스처 너비의 반만큼 이동
-const yOffset = .25;  // 텍스처 높이의 1/4만큼 이동
-someTexture.offset.set(xOffset, yOffset);
-
-

텍스처의 회전은 rotation 속성을 라디안(radians) 단위로 지정해 조절할 수 있습니다. -center 속성은 회전의 중심을 정하는 데 사용하죠. center 속성의 기본값은 0, 0으로 -왼쪽 상단을 기준으로 회전하고, offset과 마찬가지로 텍스처의 크기를 기준으로 -단위가 정해지기에 .5, .5로 설정하면 텍스처의 중앙을 기준으로 회전합니다.

-
someTexture.center.set(.5, .5);
-someTexture.rotation = THREE.MathUtils.degToRad(45);
-
-

아까 작성한 예제를 수정해 위 설정을 테스트할 예제를 만들겠습니다.

-

먼저 텍스처를 별도 변수에 담아 나중에 수정할 수 있도록 합니다.

-
+const texture = loader.load('resources/images/wall.jpg');
-const material = new THREE.MeshBasicMaterial({
--  map: loader.load('resources/images/wall.jpg');
-+  map: texture,
-});
-
-

간단한 인터페이스를 만들어보죠. -다시 한 번 lil-gui가 등장할 때입니다.

-
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
-
-

이전 예제처럼 간단한 헬퍼 클래스를 만들어 각도(degrees)로 값을 조절하면 -알아서 호도(radians)로 변환해 지정하게끔 해줍니다.

-
class DegRadHelper {
-  constructor(obj, prop) {
-    this.obj = obj;
-    this.prop = prop;
-  }
-  get value() {
-    return THREE.MathUtils.radToDeg(this.obj[this.prop]);
-  }
-  set value(v) {
-    this.obj[this.prop] = THREE.MathUtils.degToRad(v);
-  }
-}
-
-

또 문자열을 숫자형으로 변환시켜줄 클래스도 만듭니다. lil-gui는 값을 문자열로 -넘겨주는데, Three.js는 wrapSwrapT 등 enum 값을 지정할 때 숫자형만 -받기 때문이죠.

-
class StringToNumberHelper {
-  constructor(obj, prop) {
-    this.obj = obj;
-    this.prop = prop;
-  }
-  get value() {
-    return this.obj[this.prop];
-  }
-  set value(v) {
-    this.obj[this.prop] = parseFloat(v);
-  }
-}
-
-

위에서 만든 클래스를 이용해 설정값을 조절할 GUI를 만듭니다.

-
const wrapModes = {
-  'ClampToEdgeWrapping': THREE.ClampToEdgeWrapping,
-  'RepeatWrapping': THREE.RepeatWrapping,
-  'MirroredRepeatWrapping': THREE.MirroredRepeatWrapping,
-};
-
-function updateTexture() {
-  texture.needsUpdate = true;
-}
-
-const gui = new GUI();
-gui.add(new StringToNumberHelper(texture, 'wrapS'), 'value', wrapModes)
-  .name('texture.wrapS')
-  .onChange(updateTexture);
-gui.add(new StringToNumberHelper(texture, 'wrapT'), 'value', wrapModes)
-  .name('texture.wrapT')
-  .onChange(updateTexture);
-gui.add(texture.repeat, 'x', 0, 5, .01).name('texture.repeat.x');
-gui.add(texture.repeat, 'y', 0, 5, .01).name('texture.repeat.y');
-gui.add(texture.offset, 'x', -2, 2, .01).name('texture.offset.x');
-gui.add(texture.offset, 'y', -2, 2, .01).name('texture.offset.y');
-gui.add(texture.center, 'x', -.5, 1.5, .01).name('texture.center.x');
-gui.add(texture.center, 'y', -.5, 1.5, .01).name('texture.center.y');
-gui.add(new DegRadHelper(texture, 'rotation'), 'value', -360, 360)
-  .name('texture.rotation');
-
-

텍스처의 wrapSwrapT 속성을 변경할 경우 texture.needsUpdate를 -true로 설정해줘야 합니다. 나머지 설정만 변경한다면 굳이 이 값을 설정할 필요는 없죠.

-

- -

-

뭔가 많은 것을 배운 것 같지만, 이는 맛보기에 불과합니다. 글을 진행하다보면 -텍스처의 정렬과 재질에 적용할 수 있는 다른 9가지의 텍스처에 대해 다룰 기회가 -있을 거예요.

-

일단 다음 장에서는 조명(lights)에 대해 알아보기로 하죠.

- -

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/tips.html b/manual/ko/tips.html deleted file mode 100644 index d2f094e099a5c2..00000000000000 --- a/manual/ko/tips.html +++ /dev/null @@ -1,332 +0,0 @@ - - - - - - - - - - - - - - - - - -
-
-

-
-
-
-

이 글은 Three.js의 팁에 관한 글들 중 너무 짧아 별도의 글로 분리하기 애매한 글들을 묶은 것입니다.

-
-

-

캔버스의 스크린샷 찍기

-

브라우저에서 스크린샷을 찍을 수 있는 방법은 2가지 정도가 있습니다. 예전부터 사용하던 canvas.toDataURL과, 새로 등장한 canvas.toBlob이 있죠.

-

그냥 메서드만 호출하면 되는 거라니, 얼핏 쉬워 보입니다. 아래 정도의 코드면 손쉽게 스크린샷을 찍을 수 있을 것 같네요.

-
<canvas id="c"></canvas>
-+<button id="screenshot" type="button">Save...</button>
-
-
const elem = document.querySelector('#screenshot');
-elem.addEventListener('click', () => {
-  canvas.toBlob((blob) => {
-    saveBlob(blob, `screencapture-${ canvas.width }x${ canvas.height }.png`);
-  });
-});
-
-const saveBlob = (function() {
-  const a = document.createElement('a');
-  document.body.appendChild(a);
-  a.style.display = 'none';
-  return function saveData(blob, fileName) {
-     const url = window.URL.createObjectURL(blob);
-     a.href = url;
-     a.download = fileName;
-     a.click();
-  };
-}());
-
-

아래는 반응형 디자인의 예제에 버튼과 버튼을 꾸밀 CSS를 추가한 예제입니다.

-

- -

-

하지만 막상 스크린샷을 찍어보니 아래와 같은 결과가 나옵니다.

-
- -

그냥 텅 빈 이미지네요.

-

물론 브라우저나/OS에 따라 잘 나오는 경우도 있을 수 있지만 대게의 경우 텅 빈 이미지가 나올 겁니다.

-

이건 성능 관련 문제입니다. 기본적으로 브라우저는 화면을 렌더링한 후 WebGL 캔버스의 드로잉 버퍼를 바로 비웁니다.

-

이 문제를 해결하려면 화면을 캡쳐하기 직전에 화면을 렌더링하는 함수를 호출해야 합니다.

-

예제에서 몇 가지를 수정해야 합니다. 먼저 렌더링 함수를 분리합시다.

-
+const state = {
-+  time: 0,
-+};
-
--function render(time) {
--  time *= 0.001;
-+function render() {
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-  cubes.forEach((cube, ndx) => {
-    const speed = 1 + ndx * .1;
--    const rot = time * speed;
-+    const rot = state.time * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
-  renderer.render(scene, camera);
-
--  requestAnimationFrame(render);
-}
-
-+function animate(time) {
-+  state.time = time * 0.001;
-+
-+  render();
-+
-+  requestAnimationFrame(animate);
-+}
-+requestAnimationFrame(animate);
-
-

이제 render 함수는 오직 화면을 렌더링하는 역할만 하기에, 화면을 캡쳐하기 직전에 render 함수를 호출하면 됩니다.

-
const elem = document.querySelector('#screenshot');
-elem.addEventListener('click', () => {
-+  render();
-  canvas.toBlob((blob) => {
-    saveBlob(blob, `screencapture-${ canvas.width }x${ canvas.height }.png`);
-  });
-});
-
-

이제 문제 없이 잘 작동할 겁니다.

-

- -

-

다른 방법에 대해서는 다음 글을 보기 바랍니다.

-
-

-

캔버스 초기화 방지하기

-

움직이는 물체로 사용자가 그림을 그리게 한다고 해봅시다. 이걸 구현하려면 WebGLRenderer를 생성할 때 preserveDrawingBuffer: true를 설정해야 합니다. 또한 Three.js가 캔버스를 초기화하지 않도록 해주어야 하죠.

-
const canvas = document.querySelector('#c');
--const renderer = new THREE.WebGLRenderer({ canvas });
-+const renderer = new THREE.WebGLRenderer({
-+  canvas,
-+  preserveDrawingBuffer: true,
-+  alpha: true,
-+});
-+renderer.autoClearColor = false;
-
-

- -

-

만약 진짜 드로잉 프로그램을 만들 계획이라면 이 방법은 쓰지 않는 게 좋습니다. 해상도가 변경될 때마다 브라우저가 캔버스를 초기화할 테니까요. 현재 예제에서는 해상도를 캔버스 크기에 맞춰 변경합니다. 그리고 캔버스 크기는 화면 크기에 맞춰 조정되죠. 파일을 다운받거나, 탭을 바꾸거나, 상태표시줄이 추가되는 등 화면 크기가 바뀌는 경우는 다양합니다. 모바일 환경이라면 화면을 회전시키는 경우도 포함되겠죠.

-

드로잉 프로그램을 만들려면 렌더 타겟을 이용해 텍스처로 화면을 렌더링하는 게 좋을 겁니다.

-
-

-

키 입력 받기

-

이 시리즈에서는 대부분의 이벤트 리스너를 canvas에 추가했습니다. 다른 이벤트는 문제 없이 작동했지만, 딱 하나, 키보드 이벤트는 기본적으로 그냥 동작하지 않았습니다.

-

키보드 이벤트를 받으려면 해당 요소의 tabindex를 0 이상의 값으로 설정해야 합니다.

-
<canvas tabindex="0"></canvas>
-
-

하지만 이 속성을 적용하면 새로운 문제가 생깁니다. tabindex가 있는 요소는 focus 상태일 때 강조 표시가 적용되거든요. 이 문제를 해결하려면 CSS의 outline 속성을 none으로 설정해야 합니다.

-
canvas:focus {
-  outline: none;
-}
-
-

간단한 테스트를 위해 캔버스 3개를 만들겠습니다.

-
<canvas id="c1"></canvas>
-<canvas id="c2" tabindex="0"></canvas>
-<canvas id="c3" tabindex="1"></canvas>
-
-

마지막 캔버스에만 CSS를 추가합니다.

-
#c3:focus {
-    outline: none;
-}
-
-

그리고 모든 캔버스에 이벤트 리스너를 추가합니다.

-
document.querySelectorAll('canvas').forEach((canvas) => {
-  const ctx = canvas.getContext('2d');
-
-  function draw(str) {
-    ctx.clearRect(0, 0, canvas.width, canvas.height);
-    ctx.textAlign = 'center';
-    ctx.textBaseline = 'middle';
-    ctx.fillText(str, canvas.width / 2, canvas.height / 2);
-  }
-  draw(canvas.id);
-
-  canvas.addEventListener('focus', () => {
-    draw('has focus press a key');
-  });
-
-  canvas.addEventListener('blur', () => {
-    draw('lost focus');
-  });
-
-  canvas.addEventListener('keydown', (e) => {
-    draw(`keyCode: ${e.keyCode}`);
-  });
-});
-
-

첫 번째 캔버스에는 아무리 해도 키보드 이벤트가 발생하지 않을 겁니다. 두 번째 캔버스는 키보드 이벤트를 받긴 하지만 강조 표시가 생기죠. 대신 세 번째 캔버스에서는 두 문제가 발생하지 않습니다.

-

- -

-
-

-

캔버스를 투명하게 만들기

-

아무런 설정을 하지 않는다면 Three.js는 기본적으로 캔버스를 불투명하게 렌더링합니다. 캔버스를 투명하게 만들려면 WebGLRenderer를 생성할 때 alpha: true를 넘겨줘야 하죠.

-
const canvas = document.querySelector('#c');
--const renderer = new THREE.WebGLRenderer({ canvas });
-+const renderer = new THREE.WebGLRenderer({
-+  canvas,
-+  alpha: true,
-+});
-
-

또한 캔버스가 premultiplied 알파(미리 계산된 alpha 값, straight alpha 또는 associated alpha라고도 불림)를 사용하지 않도록 하게끔 하려면 아래처럼 값을 설정해줘야 합니다.

-
const canvas = document.querySelector('#c');
-const renderer = new THREE.WebGLRenderer({
-  canvas,
-  alpha: true,
-+  premultipliedAlpha: false,
-});
-
-

Three.js는 기본적으로 캔버스에는 premultipliedAlpha: true를 사용하지만 재질(material)에는 premultipliedAlpha: false를 사용합니다.

-

premultiplied alpha를 어떻게 사용해야 하는지 알고 싶다면 여기 이 글*을 참고하기 바랍니다.

-

※ 영어이니 읽기가 어렵다면 그냥 구글에 premultiplied alpha를 검색하는 것을 추천합니다. 역주.

-

어쨌든 이제 한 번 투명 캔버스 예제를 만들어보죠.

-

반응형 디자인에서 가져온 예제에 저 설정을 적용했습니다. 추가로 재질도 똑같이 투명하게 만들어보죠.

-
function makeInstance(geometry, color, x) {
--  const material = new THREE.MeshPhongMaterial({ color });
-+  const material = new THREE.MeshPhongMaterial({
-+    color,
-+    opacity: 0.5,
-+  });
-
-...
-
-

여기에 HTML로 텍스트를 추가합니다.

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="content">
-+    <div>
-+      <h1>Cubes-R-Us!</h1>
-+      <p>We make the best cubes!</p>
-+    </div>
-+  </div>
-</body>
-
-

캔버스를 앞에 둬야 하니 CSS도 추가합니다.

-
body {
-    margin: 0;
-}
-#c {
-    width: 100%;
-    height: 100%;
-    display: block;
-+    position: fixed;
-+    left: 0;
-+    top: 0;
-+    z-index: 2;
-+    pointer-events: none;
-}
-+#content {
-+  font-size: 7vw;
-+  font-family: sans-serif;
-+  text-align: center;
-+  width: 100%;
-+  height: 100%;
-+  display: flex;
-+  justify-content: center;
-+  align-items: center;
-+}
-
-

pointer-events: none은 캔버스가 마우스나 터치 이벤트의 영향을 받지 않도록 해줍니다. 아래에 있는 텍스트를 바로 선택할 수 있도록 설정한 것이죠.

-

- -

-
-

-

배경에 Three.js 애니메이션 넣기

-

많이 받은 질문 중에 하나가 Three.js 애니메이션을 웹 페이지의 배경으로 사용하는 방법이었습니다.

-

가능한 방법은 2가지 정도겠네요.

-
    -
  • 캔버스 요소의 CSS positionfixed로 설정한다.
  • -
-
#c {
- position: fixed;
- left: 0;
- top: 0;
- ...
-}
-
-

이전 예제에서 썼던 방법과 똑같은 방법을 적용할 수 있습니다. z-index를 -1 로 설정하면 정육면체들이 텍스트 뒤로 사라질 겁니다.

-

이 방법의 단점은 자바스크립트 코드가 반드시 페이지와 통합되야 한다는 겁니다. 특히 복잡한 페이지라면 Three.js를 렌더링하는 코드가 다른 코드와 충돌하지 않도록 특별히 신경을 써야 하겠죠.

-
    -
  • iframe을 쓴다.
  • -
-

이 방법은 이 사이트의 메인 페이지에서 사용한 방법입니다.

-

해당 웹 페이지에 iframe만 추가하면 되죠.

-
<iframe id="background" src="responsive.html">
-<div>
-  내용 내용 내용 내용
-</div>
-
-

그런 다음 캔버스 요소를 활용했을 때와 마찬가지로 iframe이 창 전체를 채우도록 한 뒤, z-index를 이용해 배경으로 지정합니다. iframe에는 기본적으로 윤곽선이 있으니 추가로 bordernone으로 설정해주면 됩니다.

-
#background {
-    position: fixed;
-    width: 100%;
-    height: 100%;
-    left: 0;
-    top: 0;
-    z-index: -1;
-    border: none;
-    pointer-events: none;
-}
-
-

- -

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/transparency.html b/manual/ko/transparency.html deleted file mode 100644 index 9ac41864bbf64b..00000000000000 --- a/manual/ko/transparency.html +++ /dev/null @@ -1,389 +0,0 @@ - - - 투명도 - - - - - - - - - - - - - - -
-
-

투명도

-
-
-
-

Three.js에서 투명도는 간단하지만 동시에 까다로운 주제입니다.

-

먼저 쉬운 것부터 알아보죠. 예제로 정육면체 8개를 2x2x2 그리드에 맞춘 장면을 -만들어보겠습니다.

-

불필요한 렌더링 제거하기에서 썼던 -예제를 가져와 정육면체 3개를 8개로 수정합니다. 먼저 makeInstance 함수가 -x, y, z 값을 받도록 수정하겠습니다.

-
-function makeInstance(geometry, color) {
-+function makeInstance(geometry, color, x, y, z) {
-  const material = new THREE.MeshPhongMaterial({color});
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
--  cube.position.x = x;
-+  cube.position.set(x, y, z);
-
-  return cube;
-}
-
-

그리고 정육면체 8개를 만듭니다.

-
+function hsl(h, s, l) {
-+  return (new THREE.Color()).setHSL(h, s, l);
-+}
-
--makeInstance(geometry, 0x44aa88,  0);
--makeInstance(geometry, 0x8844aa, -2);
--makeInstance(geometry, 0xaa8844,  2);
-
-+{
-+  const d = 0.8;
-+  makeInstance(geometry, hsl(0 / 8, 1, .5), -d, -d, -d);
-+  makeInstance(geometry, hsl(1 / 8, 1, .5),  d, -d, -d);
-+  makeInstance(geometry, hsl(2 / 8, 1, .5), -d,  d, -d);
-+  makeInstance(geometry, hsl(3 / 8, 1, .5),  d,  d, -d);
-+  makeInstance(geometry, hsl(4 / 8, 1, .5), -d, -d,  d);
-+  makeInstance(geometry, hsl(5 / 8, 1, .5),  d, -d,  d);
-+  makeInstance(geometry, hsl(6 / 8, 1, .5), -d,  d,  d);
-+  makeInstance(geometry, hsl(7 / 8, 1, .5),  d,  d,  d);
-+}
-
-

카메라도 조정합니다.

-
const fov = 75;
-const aspect = 2;  // canvas 기본값
-const near = 0.1;
--const far = 5;
-+const far = 25;
-const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
--camera.position.z = 4;
-+camera.position.z = 2;
-
-

배경색은 하얀색으로 바꿔주고

-
const scene = new THREE.Scene();
-+scene.background = new THREE.Color('white');
-
-

정육면체의 옆면도 빛을 받도록 조명을 하나 더 추가합니다.

-
-{
-+function addLight(...pos) {
-  const color = 0xFFFFFF;
-  const intensity = 1;
-  const light = new THREE.DirectionalLight(color, intensity);
--  light.position.set(-1, 2, 4);
-+  light.position.set(...pos);
-  scene.add(light);
-}
-+addLight(-1, 2, 4);
-+addLight( 1, -1, -2);
-
-

정육면체를 투명하게 만들려면 transparent 속성을 -켜고 opacity 속성을 설정해줘야 합니다(CSS와 마찬가지로 -0은 완전히 투명함, 1은 완전히 불투명함을 의미).

-
function makeInstance(geometry, color, x, y, z) {
--  const material = new THREE.MeshPhongMaterial({color});
-+  const material = new THREE.MeshPhongMaterial({
-+    color,
-+    opacity: 0.5,
-+    transparent: true,
-+  });
-
-  const cube = new THREE.Mesh(geometry, material);
-  scene.add(cube);
-
-  cube.position.set(x, y, z);
-
-  return cube;
-}
-
-

이제 8개의 반투명한 정육면체가 생겼습니다.

-

- -

-

예제를 드래그하면 화면을 회전시킬 수 있습니다.

-

완벽한데요, 라고 생각했다면 좀 더 자세히 보세요. 정육면체의 뒷면이 하나도 -보이지 않습니다.

-
-
뒷면이 보이지 않는다
- -

이전에 재질(material)에 관해 배울 때 side -속성에 대해 배웠었죠. 이 속성을 THREE.DoubleSide로 설정해 정육면체의 -양면이 모두 보이도록 해봅시다.

-
const material = new THREE.MeshPhongMaterial({
-  color,
-  map: loader.load(url),
-  opacity: 0.5,
-  transparent: true,
-+  side: THREE.DoubleSide,
-});
-
-

- -

-

예제를 돌려보세요. 뭔가 해결된 듯 하지만 자세히 보면 가끔 뒷면 또는 뒷면의 -일부가 보이지 않습니다.

-
-
정육면체의 왼쪽 뒷면이 보이지 않는다
- -

이는 3D 요소를 렌더링하는 방식 때문입니다. WebGL은 각 geometry의 삼각형을 -한 번에 하나씩 렌더링합니다. 그리고 삼각형의 픽셀 하나를 렌더링할 때마다 -2개의 정보를 기록하는데, 하나는 해당 픽셀의 색이고 다른 하나는 픽셀의 -깊이(depth)입니다. 다음 삼각형을 그릴 때 해당 픽셀이 이미 그려진 픽셀보다 -깊이가 깊다면 해당 픽셀을 렌더링하지 않죠.

-

이는 불투명한 물체에서는 문제가 되지 않았지만 투명한 물체에서는 문제가 됩니다.

-

이 문제를 해결하려면 투명한 물체를 분류해 뒤에 있는 물체를 앞에 있는 물체보다 -먼저 렌더링해야 합니다. Mesh 같은 경우는 Three.js가 자동으로 이를 처리해주죠. -만약 그러지 않았다면 제일 첫 번째 예제에서 뒤에 있는 정육면체를 아예 볼 수 -없었을 겁니다.

-

정육면체에는 한 면에 2개, 총 12개의 삼각형이 있습니다. 각 삼각형의 렌더링 순서는 -geometry에 관한 글에서 봤던 것과 같죠. -시선에 따라 카메라에서 가까운 삼각형을 먼저 렌더링할 겁니다. 앞면을 뒷면보다 먼저 -렌더링하니, 때때로 뒷면이 보이지 않을 수밖에 없죠.

-

구체나 정육면체 등 볼록 물체(convex object)의 경우, 모든 물체를 한 번씩 더 렌더링해 -문제를 해결할 수 있습니다. 하나는 안쪽면 삼각형만 렌더링하고, 다른 하나는 바깥쪽 -삼각형만 렌더링하도록 만드는 것이죠.

-
function makeInstance(geometry, color, x, y, z) {
-+  [THREE.BackSide, THREE.FrontSide].forEach((side) => {
-    const material = new THREE.MeshPhongMaterial({
-      color,
-      opacity: 0.5,
-      transparent: true,
-+      side,
-    });
-
-    const cube = new THREE.Mesh(geometry, material);
-    scene.add(cube);
-
-    cube.position.set(x, y, z);
-+  });
-}
-
-

어찌어찌 해결된 것처럼 보입니다.

-

- -

-

Three.js의 분류 기준은 고정적인 듯합니다. side: THREE.BackSide mesh를 먼저 -넣고, 그 다음 정확히 같은 위치에 side: THREE.FrontSide mesh를 넣었으니까요.

-

이번에는 평면 2개를 교차로 배치해봅시다(정육면체 관련 코드를 전부 지운 뒤). -각 평면에는 다른 텍스처를 넣을 겁니다.

-
const planeWidth = 1;
-const planeHeight = 1;
-const geometry = new THREE.PlaneGeometry(planeWidth, planeHeight);
-
-const loader = new THREE.TextureLoader();
-
-function makeInstance(geometry, color, rotY, url) {
-  const texture = loader.load(url, render);
-  const material = new THREE.MeshPhongMaterial({
-    color,
-    map: texture,
-    opacity: 0.5,
-    transparent: true,
-    side: THREE.DoubleSide,
-  });
-
-  const mesh = new THREE.Mesh(geometry, material);
-  scene.add(mesh);
-
-  mesh.rotation.y = rotY;
-}
-
-makeInstance(geometry, 'pink',       0,             'resources/images/happyface.png');
-makeInstance(geometry, 'lightblue',  Math.PI * 0.5, 'resources/images/hmmmface.png');
-
-

평면은 한 번에 한 면밖에 보지 못하니, side: THREE.DoubleSide로 설정했습니다. 또한 -텍스처를 전부 불러왔을 때 장면을 다시 렌더링하도록 render 함수를 loader.load -메서드에 넘겨줬습니다. 이는 필요에 따른 렌더링을 -구현하기 위한 것이죠.

-

- -

-

아까와 비슷한 문제가 보입니다.

-
-
면의 반쪽이 사라졌다
- -

평면을 둘로 쪼개 실제로는 교차하지 않게끔 만들면 문제를 해결할 수 있습니다.

-
function makeInstance(geometry, color, rotY, url) {
-+  const base = new THREE.Object3D();
-+  scene.add(base);
-+  base.rotation.y = rotY;
-
-+  [-1, 1].forEach((x) => {
-    const texture = loader.load(url, render);
-+    texture.offset.x = x < 0 ? 0 : 0.5;
-+    texture.repeat.x = .5;
-    const material = new THREE.MeshPhongMaterial({
-      color,
-      map: texture,
-      opacity: 0.5,
-      transparent: true,
-      side: THREE.DoubleSide,
-    });
-
-    const mesh = new THREE.Mesh(geometry, material);
--    scene.add(mesh);
-+    base.add(mesh);
-
--    mesh.rotation.y = rotY;
-+    mesh.position.x = x * .25;
-  });
-}
-
-

저걸 어떻게 구현할지는 여러분의 선택입니다. 블렌더(Blender) -같은 3D 에디터를 사용했다면 텍스처 좌표를 직접 수정했겠죠. 예제의 경우에는 -PlaneGeometry를 썼습니다. 이전에 다뤘듯 -이 geometry는 기본적으로 텍스처를 크기에 맞춰 늘립니다. texture.repeat -속성과 texture.offset 속성을 조정해 각 면에 적절한 -텍스처를 입혀줄 수 있죠.

-

위 코드에서는 Object3D를 만들어 두 평면의 부모로 지정했습니다. 이렇게 -하면 복잡한 계산 없이 간단하게 Object3D만 돌려서 두 평면 다 회전시킬 -수 있죠.

-

- -

-

이 방법은 교차점이 변하지 않는 정말 간단한 경우에만 가능합니다.

-

텍스처가 들어간 요소는 알파 테스트(alpha test)를 활성화해 이를 해결할 수 -있죠.

-

알파 테스트란 Three.js가 픽셀을 렌더링하지 않는 특정 알파 단계를 의미합니다. -만약 아무것도 그리지 않게 설정한다면 위와 같은 문제는 사라지겠죠. 상대적으로 -경계가 분명한 텍스처, 나뭇잎, 잔디 등의 경우 이는 꽤 잘 작동합니다.

-

이번에도 2개의 면을 만들어 테스트해보도록 합시다. 아까는 텍스처가 전부 불투명했죠. -이번에는 각 면에는 각각 다른, 부분적으로 투명한 텍스처를 사용할 겁니다.

-
-
-
-
- -

아까 평면 2개를 교차해놓았던(반으로 가르기 전) 예제를 가져와 이 텍스처에 -alphaTest 속성을 지정하겠습니다.

-
function makeInstance(geometry, color, rotY, url) {
-  const texture = loader.load(url, render);
-  const material = new THREE.MeshPhongMaterial({
-    color,
-    map: texture,
--    opacity: 0.5,
-    transparent: true,
-+    alphaTest: 0.5,
-    side: THREE.DoubleSide,
-  });
-
-  const mesh = new THREE.Mesh(geometry, material);
-  scene.add(mesh);
-
-  mesh.rotation.y = rotY;
-}
-
--makeInstance(geometry, 'pink',       0,             'resources/images/happyface.png');
--makeInstance(geometry, 'lightblue',  Math.PI * 0.5, 'resources/images/hmmmface.png');
-+makeInstance(geometry, 'white', 0,             'resources/images/tree-01.png');
-+makeInstance(geometry, 'white', Math.PI * 0.5, 'resources/images/tree-02.png');
-
-

이대로 실행해도 되지만, 간단한 UI를 만들어 alphaTesttransparent 속성을 -갖고 놀 수 있게 해보겠습니다. 씬 그래프에 관한 글에서 -소개했던 lil-gui를 써서요.

-

먼저 lil-gui에 지정할 헬퍼 클래스를 만들겠습니다. 이 헬퍼 클래스는 장면 안 모든 -재질을 해당 값으로 변경할 겁니다.

-
class AllMaterialPropertyGUIHelper {
-  constructor(prop, scene) {
-    this.prop = prop;
-    this.scene = scene;
-  }
-  get value() {
-    const { scene, prop } = this;
-    let v;
-    scene.traverse((obj) => {
-      if (obj.material && obj.material[prop] !== undefined) {
-        v = obj.material[prop];
-      }
-    });
-    return v;
-  }
-  set value(v) {
-    const { scene, prop } = this;
-    scene.traverse((obj) => {
-      if (obj.material && obj.material[prop] !== undefined) {
-        obj.material[prop] = v;
-        obj.material.needsUpdate = true;
-      }
-    });
-  }
-}
-
-

다음으로 GUI를 추가합니다.

-
const gui = new GUI();
-gui.add(new AllMaterialPropertyGUIHelper('alphaTest', scene), 'value', 0, 1)
-    .name('alphaTest')
-    .onChange(requestRenderIfNotRequested);
-gui.add(new AllMaterialPropertyGUIHelper('transparent', scene), 'value')
-    .name('transparent')
-    .onChange(requestRenderIfNotRequested);
-
-

물론 lil-gui 모듈도 불러와야죠.

-
import * as THREE from 'three';
-import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-+import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
-
-

- -

-

예제를 확대해보면 평면에 하얀 테두리가 보일 겁니다.

-
- -

이는 앞서 봤던 예제와 같은 문제입니다. 하얀 테두리를 가진 요소가 먼저 그려져 -뒤에 있는 요소가 일부 가려진 것이죠. 완벽한 해결책은 없습니다. 그때그때 상황에 -따라 alphaTesttransparent 옵션을 조정해서 상황에 맞는 해결책을 찾아야 -하죠.

-

결국 이 글의 주제는 "완벽한 투명도는 구현하기 힘들다"가 되겠네요. 웬만한 방법에는 -모두 문제와, 타협점, 편법 등이 존재합니다.

-

자동차의 경우를 예로 들어보죠. 자동차는 보통 4면에 유리창이 있습니다. 렌더링 순서를 -제대로 적용하려면 각각의 창문을 별도의 요소로 만들어야 합니다. 만약 하나의 요소라면 -Three.js가 렌더링 순서를 제대로 결정할 수 없겠죠.

-

식물이나 잔디 등을 구현할 때는 일반적으로 알파 테스트를 사용합니다.

-

어떤 방법을 사용할지는 전적으로 상황과 여러분의 판단에 달려 있죠.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/voxel-geometry.html b/manual/ko/voxel-geometry.html deleted file mode 100644 index 76d41b4898ac97..00000000000000 --- a/manual/ko/voxel-geometry.html +++ /dev/null @@ -1,1016 +0,0 @@ - - - 복셀 Geometry - - - - - - - - - - - - - - -
-
-

복셀 Geometry

-
-
-
-

복셀: Voxel, 볼륨(volume, 부피)과 픽셀(pixel)의 합성어로, 마인크래프트의 블록처럼 부피가 있는 픽셀을 말합니다. 역주.

-

이 주제는 꽤 많은 커뮤니티에 공통적으로 올라오는 주제입니다. "마인크래프트 블록 같은 복셀을 어떻게 만들 수 있나요?"라는 것이죠.

-

대부분의 초심자가 이를 정육면체 geometry를 만들고 각 복셀의 위치에 mesh를 따로 만들어 구현하려고 합니다. 재미삼아 이 방식으로 한 번 구현해보죠. 먼저 256x256x256짜리 복셀 큐브를 만들기 위해 16,777,216개의 요소를 가진 Uint8Array를 만듭니다.

-
const cellSize = 256;
-const cell = new Uint8Array(cellSize * cellSize * cellSize);
-
-

그리고 사인(sine) 함수 곡선으로 언덕을 한 겹 만듭니다.

-
for (let y = 0; y < cellSize; ++y) {
-  for (let z = 0; z < cellSize; ++z) {
-    for (let x = 0; x < cellSize; ++x) {
-      const height = (Math.sin(x / cellSize * Math.PI * 4) + Math.sin(z / cellSize * Math.PI * 6)) * 20 + cellSize / 2;
-      if (height > y && height < y + 1) {
-        const offset = y * cellSize * cellSize +
-                       z * cellSize +
-                       x;
-        cell[offset] = 1;
-      }
-    }
-  }
-}
-
-

다음으로 모든 블럭을 돌면서 0이 아닐 경우 정육면체를 새로 만듭니다.

-
const geometry = new THREE.BoxGeometry(1, 1, 1);
-const material = new THREE.MeshPhongMaterial({ color: 'green' });
-
-for (let y = 0; y < cellSize; ++y) {
-  for (let z = 0; z < cellSize; ++z) {
-    for (let x = 0; x < cellSize; ++x) {
-      const offset = y * cellSize * cellSize +
-                     z * cellSize +
-                     x;
-      const block = cell[offset];
-      const mesh = new THREE.Mesh(geometry, material);
-      mesh.position.set(x, y, z);
-      scene.add(mesh);
-    }
-  }
-}
-
-

나머지 코드는 불필요한 렌더링 제거하기에서 가져왔습니다.

-

- -

-

처음 초기화하는 데도 시간이 오래 걸리고 카메라를 움직이면 굉장히 버벅일 겁니다. 다중 요소 최적화하기의 경우와 마찬가지로 너무 많은 물체가 있는 탓이죠. 256x256, 육면체가 총 65,536개나 있으니 그럴 만합니다.

-

geometry를 합치면 이 문제를 해결할 수 있습니다. 내친김에 언덕 한 겹이 아니라 땅까지 복셀을 채워보도록 하죠. 반복문을 다음처럼 수정해 빈 공간을 전부 채우도록 합니다.

-
for (let y = 0; y < cellSize; ++y) {
-  for (let z = 0; z < cellSize; ++z) {
-    for (let x = 0; x < cellSize; ++x) {
-      const height = (Math.sin(x / cellSize * Math.PI * 4) + Math.sin(z / cellSize * Math.PI * 6)) * 20 + cellSize / 2;
--      if (height > y && height < y + 1) {
-+      if (height < y + 1) {
-        const offset = y * cellSize * cellSize +
-                       z * cellSize +
-                       x;
-        cell[offset] = 1;
-      }
-    }
-  }
-}
-
-

테스트를 돌려봤더니 잠시 멈췄다가 out of memery 오류가 뜹니다 😅.

-

몇 가지 문제가 있을 테지만 현재 가장 큰 문제는 전혀 볼 일이 없는 정육면체 안쪽도 렌더링한다는 겁니다.

-

쉽게 설명해 복셀로 이루어진 3x2x2짜리 육면체가 있다고 해보죠. 각 복셀을 합치면 아래와 같은 모습이 될 겁니다.

-
-
-
- -

문제를 해결하려면 아래와 같은 형태로 구현해야 하죠.

-
-
-
- -

위쪽 예제에는 복셀 사이에 면들이 있습니다. 밖에서는 전혀 볼 일이 없기에 불필요한 것들이죠. 거기다 각 복셀 사이에는 면이 하나도 아니고 마주 보는 면당 하나씩, 총 두 개가 있습니다. 이 역시 낭비이죠. 복셀에 이런 면들이 많아질수록 성능은 처참해질 겁니다.

-

이쯤에서 그냥 말해야겠네요. 단순히 geometry를 합쳐버려서는 이 문제를 해결할 수 없습니다. 복셀이 서로 마주 본다면 해당 면을 만들지 않도록 직접 복셀을 만들어야 하죠.

-

다른 문제는 크기가 너무 크다는 겁니다. 256x256x256이면 16MB 정도로 메모리 점유율이 꽤 큰 편에 속하죠. 특히 빈 공간은 아무것도 있을 필요가 없습니다. 복셀의 숫자도 약 천육백만 개가 넘으니 연산이 버거울 만합니다.

-

한 가지 해결 방법은 영역을 작은 영역으로 쪼개는 겁니다. 아무것도 없는 영역에는 메모리를 할당할 필요가 없으니, 32x32x32 크기(32KB)의 영역을 만들어 안에 요소가 있는 영역만 렌더링하도록 하겠습니다. 이 32x32x32 영역은 편의상 "cell"이라고 부르도록 하죠.

-

먼저 복셀 데이터를 관리할 클래스를 만듭니다.

-
class VoxelWorld {
-  constructor(cellSize) {
-    this.cellSize = cellSize;
-  }
-}
-
-

다음으로 각 cell의 geometry를 생성하는 메서드를 작성합니다. 이 메서드는 cell의 위치값을 인자로 받는데, 쉽게 말해 (x축 0-31, y축 0-31, z축 0-31)을 포함하는 복셀들을 생성하려면 (0,0,0)을 넘겨주면 됩니다. (x축 32-63, y축 0-31, z축 0-31)을 포함하는 복셀을 생성하려면 (1,0,0)을 넘겨주면 되죠.

-

그리고 이웃하는 복셀을 검사해야 합니다. 일단 해당 위치의 복셀값을 반환하는 getVoxel 메서드가 있다고 가정합시다. 예를 들어 cell의 크기가 32일 경우, 이 메서드에 (35,0,0)을 넘겨주면 (1,0,0) 쪽 cell을 찾아 해당 cell의 (3,0,0)에 위치한 복셀값을 반환할 겁니다. 다른 cell의 복셀이라고 해도 이웃 복셀을 얼마든지 찾아낼 수 있다는 이야기죠.

-
class VoxelWorld {
-  constructor(cellSize) {
-    this.cellSize = cellSize;
-  }
-+  generateGeometryDataForCell(cellX, cellY, cellZ) {
-+    const { cellSize } = this;
-+    const startX = cellX * cellSize;
-+    const startY = cellY * cellSize;
-+    const startZ = cellZ * cellSize;
-+
-+    for (let y = 0; y < cellSize; ++y) {
-+      const voxelY = startY + y;
-+      for (let z = 0; z < cellSize; ++z) {
-+        const voxelZ = startZ + z;
-+        for (let x = 0; x < cellSize; ++x) {
-+          const voxelX = startX + x;
-+          const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
-+          if (voxel) {
-+            for (const { dir } of VoxelWorld.faces) {
-+              const neighbor = this.getVoxel(
-+                  voxelX + dir[0],
-+                  voxelY + dir[1],
-+                  voxelZ + dir[2]);
-+              if (!neighbor) {
-+                // 이 복셀은 이 방향에 이웃하는 복셀이 없으므로
-+                // 이쪽에 면을 만듭니다.
-+              }
-+            }
-+          }
-+        }
-+      }
-+    }
-+  }
-}
-
-+VoxelWorld.faces = [
-+  { // 왼쪽
-+    dir: [ -1,  0,  0, ],
-+  },
-+  { // 오른쪽
-+    dir: [  1,  0,  0, ],
-+  },
-+  { // 아래
-+    dir: [  0, -1,  0, ],
-+  },
-+  { // 위
-+    dir: [  0,  1,  0, ],
-+  },
-+  { // 뒤
-+    dir: [  0,  0, -1, ],
-+  },
-+  { // 앞
-+    dir: [  0,  0,  1, ],
-+  },
-+];
-
-

이제 언제 면을 만들 기준이 생겼으니 한 번 면들을 만들어봅시다.

-
class VoxelWorld {
-  constructor(cellSize) {
-    this.cellSize = cellSize;
-  }
-  generateGeometryDataForCell(cellX, cellY, cellZ) {
-    const { cellSize } = this;
-+    const positions = [];
-+    const normals = [];
-+    const indices = [];
-    const startX = cellX * cellSize;
-    const startY = cellY * cellSize;
-    const startZ = cellZ * cellSize;
-
-    for (let y = 0; y < cellSize; ++y) {
-      const voxelY = startY + y;
-      for (let z = 0; z < cellSize; ++z) {
-        const voxelZ = startZ + z;
-        for (let x = 0; x < cellSize; ++x) {
-          const voxelX = startX + x;
-          const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
-          if (voxel) {
--            for (const { dir } of VoxelWorld.faces) {
-+            for (const { dir, corners } of VoxelWorld.faces) {
-              const neighbor = this.getVoxel(
-                  voxelX + dir[0],
-                  voxelY + dir[1],
-                  voxelZ + dir[2]);
-              if (!neighbor) {
-+                // 이 복셀은 이 방향에 이웃하는 복셀이 없으므로
-+                // 이쪽에 면을 만듭니다.
-+                const ndx = positions.length / 3;
-+                for (const pos of corners) {
-+                  positions.push(pos[0] + x, pos[1] + y, pos[2] + z);
-+                  normals.push(...dir);
-+                }
-+                indices.push(
-+                  ndx, ndx + 1, ndx + 2,
-+                  ndx + 2, ndx + 1, ndx + 3,
-+                );
-              }
-            }
-          }
-        }
-      }
-    }
-+    return {
-+      positions,
-+      normals,
-+      indices,
-    };
-  }
-}
-
-VoxelWorld.faces = [
-  { // 왼쪽
-    dir: [ -1,  0,  0, ],
-+    corners: [
-+      [ 0, 1, 0 ],
-+      [ 0, 0, 0 ],
-+      [ 0, 1, 1 ],
-+      [ 0, 0, 1 ],
-+    ],
-  },
-  { // 오른쪽
-    dir: [  1,  0,  0, ],
-+    corners: [
-+      [ 1, 1, 1 ],
-+      [ 1, 0, 1 ],
-+      [ 1, 1, 0 ],
-+      [ 1, 0, 0 ],
-+    ],
-  },
-  { // 아래
-    dir: [  0, -1,  0, ],
-+    corners: [
-+      [ 1, 0, 1 ],
-+      [ 0, 0, 1 ],
-+      [ 1, 0, 0 ],
-+      [ 0, 0, 0 ],
-+    ],
-  },
-  { // 위
-    dir: [  0,  1,  0, ],
-+    corners: [
-+      [ 0, 1, 1 ],
-+      [ 1, 1, 1 ],
-+      [ 0, 1, 0 ],
-+      [ 1, 1, 0 ],
-+    ],
-  },
-  { // 뒤
-    dir: [  0,  0, -1, ],
-+    corners: [
-+      [ 1, 0, 0 ],
-+      [ 0, 0, 0 ],
-+      [ 1, 1, 0 ],
-+      [ 0, 1, 0 ],
-+    ],
-  },
-  { // 앞
-    dir: [  0,  0,  1, ],
-+    corners: [
-+      [ 0, 0, 1 ],
-+      [ 1, 0, 1 ],
-+      [ 0, 1, 1 ],
-+      [ 1, 1, 1 ],
-+    ],
-  },
-];
-
-

위 코드는 기본 geometry 데이터를 만들어줍니다. 이제 getVoxel 메서드만 만들면 되겠네요. 일단 약간의 하드코딩을 더해 cell을 만듭니다.

-
class VoxelWorld {
-  constructor(cellSize) {
-    this.cellSize = cellSize;
-+    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
-  }
-+  getCellForVoxel(x, y, z) {
-+    const { cellSize } = this;
-+    const cellX = Math.floor(x / cellSize);
-+    const cellY = Math.floor(y / cellSize);
-+    const cellZ = Math.floor(z / cellSize);
-+    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
-+      return null
-+    }
-+    return this.cell;
-+  }
-+  getVoxel(x, y, z) {
-+    const cell = this.getCellForVoxel(x, y, z);
-+    if (!cell) {
-+      return 0;
-+    }
-+    const { cellSize } = this;
-+    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
-+    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
-+    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
-+    const voxelOffset = voxelY * cellSize * cellSize +
-+                        voxelZ * cellSize +
-+                        voxelX;
-+    return cell[voxelOffset];
-+  }
-  generateGeometryDataForCell(cellX, cellY, cellZ) {
-
-  ...
-}
-
-

딱히 문제는 없어보입니다. 데이터를 지정할 수 있는 setVoxel 메서드도 만들도록 하죠.

-
class VoxelWorld {
-  constructor(cellSize) {
-    this.cellSize = cellSize;
-    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
-  }
-  getCellForVoxel(x, y, z) {
-    const { cellSize } = this;
-    const cellX = Math.floor(x / cellSize);
-    const cellY = Math.floor(y / cellSize);
-    const cellZ = Math.floor(z / cellSize);
-    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
-      return null
-    }
-    return this.cell;
-  }
-+  setVoxel(x, y, z, v) {
-+    let cell = this.getCellForVoxel(x, y, z);
-+    if (!cell) {
-+      return;  // 할 일: 새로운 cell 추가 기능?
-+    }
-+    const { cellSize } = this;
-+    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
-+    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
-+    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
-+    const voxelOffset = voxelY * cellSize * cellSize +
-+                        voxelZ * cellSize +
-+                        voxelX;
-+    cell[voxelOffset] = v;
-+  }
-  getVoxel(x, y, z) {
-    const cell = this.getCellForVoxel(x, y, z);
-    if (!cell) {
-      return 0;
-    }
-    const {cellSize} = this;
-    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
-    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
-    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
-    const voxelOffset = voxelY * cellSize * cellSize +
-                        voxelZ * cellSize +
-                        voxelX;
-    return cell[voxelOffset];
-  }
-  generateGeometryDataForCell(cellX, cellY, cellZ) {
-
-  ...
-}
-
-

흠, 반복되는 코드가 많네요. 코드를 좀 정리해봅시다.

-
class VoxelWorld {
-  constructor(cellSize) {
-    this.cellSize = cellSize;
-+    this.cellSliceSize = cellSize * cellSize;
-    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
-  }
-  getCellForVoxel(x, y, z) {
-    const { cellSize } = this;
-    const cellX = Math.floor(x / cellSize);
-    const cellY = Math.floor(y / cellSize);
-    const cellZ = Math.floor(z / cellSize);
-    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
-      return null;
-    }
-    return this.cell;
-  }
-+  computeVoxelOffset(x, y, z) {
-+    const { cellSize, cellSliceSize } = this;
-+    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
-+    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
-+    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
-+    return voxelY * cellSliceSize +
-+           voxelZ * cellSize +
-+           voxelX;
-+  }
-  setVoxel(x, y, z, v) {
-    const cell = this.getCellForVoxel(x, y, z);
-    if (!cell) {
-      return;  // 할 일: 새로운 cell 추가 기능?
-    }
--    const { cellSize } = this;
--    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
--    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
--    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
--    const voxelOffset = voxelY * cellSize * cellSize +
--                        voxelZ * cellSize +
--                        voxelX;
-+    const voxelOffset = this.computeVoxelOffset(x, y, z);
-    cell[voxelOffset] = v;
-  }
-  getVoxel(x, y, z) {
-    const cell = this.getCellForVoxel(x, y, z);
-    if (!cell) {
-      return 0;
-    }
--    const { cellSize } = this;
--    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
--    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
--    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
--    const voxelOffset = voxelY * cellSize * cellSize +
--                        voxelZ * cellSize +
--                        voxelX;
-+    const voxelOffset = this.computeVoxelOffset(x, y, z);
-    return cell[voxelOffset];
-  }
-  generateGeometryDataForCell(cellX, cellY, cellZ) {
-
-  ...
-}
-
-

다음으로 첫 번째 cell을 복셀로 채우는 코드를 작성합니다.

-
const cellSize = 32;
-
-const world = new VoxelWorld(cellSize);
-
-for (let y = 0; y < cellSize; ++y) {
-  for (let z = 0; z < cellSize; ++z) {
-    for (let x = 0; x < cellSize; ++x) {
-      const height = (Math.sin(x / cellSize * Math.PI * 2) + Math.sin(z / cellSize * Math.PI * 3)) * (cellSize / 6) + (cellSize / 2);
-      if (y < height) {
-        world.setVoxel(x, y, z, 1);
-      }
-    }
-  }
-}
-
-

BufferGeometry에 관한 글에서 다뤘던 대로 실제 geometry를 생성하는 코드도 작성합니다.

-
const { positions, normals, indices } = world.generateGeometryDataForCell(0, 0, 0);
-const geometry = new THREE.BufferGeometry();
-const material = new THREE.MeshLambertMaterial({ color: 'green' });
-
-const positionNumComponents = 3;
-const normalNumComponents = 3;
-geometry.setAttribute(
-    'position',
-    new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
-geometry.setAttribute(
-    'normal',
-    new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
-geometry.setIndex(indices);
-const mesh = new THREE.Mesh(geometry, material);
-scene.add(mesh);
-
-

한 번 테스트해보죠.

-

- -

-

잘 완성한 것 같네요! 여기에 실제 마인크래프트처럼 텍스처를 넣어봅시다.

-

인터넷을 뒤져 이 텍스처들을 찾았습니다(라이선스: CC-BY-NC-SA, 작가: Joshtimus). 그리고 여기서 몇 가지를 임의로 골라 텍스처 아틀라스(texture atlas)를 만들었습니다.

-
- -

작업을 간단히 하기 위해 텍스처를 열별로 정렬했습니다. 첫 번째 줄은 복셀의 옆면, 두 번째 줄은 복셀의 윗면, 세 번째 줄은 복셀의 아랫면이죠.

-

이 데이터를 바탕으로 VoxelWorld.faces에 각 복셀에 사용할 텍스처의 줄 번호와 복셀의 각 면에 사용할 UV 좌표 데이터를 지정합니다.

-
VoxelWorld.faces = [
-  { // 왼쪽
-+    uvRow: 0,
-    dir: [ -1,  0,  0, ],
-    corners: [
--      [ 0, 1, 0 ],
--      [ 0, 0, 0 ],
--      [ 0, 1, 1 ],
--      [ 0, 0, 1 ],
-+      { pos: [ 0, 1, 0 ], uv: [ 0, 1 ], },
-+      { pos: [ 0, 0, 0 ], uv: [ 0, 0 ], },
-+      { pos: [ 0, 1, 1 ], uv: [ 1, 1 ], },
-+      { pos: [ 0, 0, 1 ], uv: [ 1, 0 ], },
-    ],
-  },
-  { // 오른쪽
-+    uvRow: 0,
-    dir: [  1,  0,  0, ],
-    corners: [
--      [ 1, 1, 1 ],
--      [ 1, 0, 1 ],
--      [ 1, 1, 0 ],
--      [ 1, 0, 0 ],
-+      { pos: [ 1, 1, 1 ], uv: [ 0, 1 ], },
-+      { pos: [ 1, 0, 1 ], uv: [ 0, 0 ], },
-+      { pos: [ 1, 1, 0 ], uv: [ 1, 1 ], },
-+      { pos: [ 1, 0, 0 ], uv: [ 1, 0 ], },
-    ],
-  },
-  { // 아래
-+    uvRow: 1,
-    dir: [  0, -1,  0, ],
-    corners: [
--      [ 1, 0, 1 ],
--      [ 0, 0, 1 ],
--      [ 1, 0, 0 ],
--      [ 0, 0, 0 ],
-+      { pos: [ 1, 0, 1 ], uv: [ 1, 0 ], },
-+      { pos: [ 0, 0, 1 ], uv: [ 0, 0 ], },
-+      { pos: [ 1, 0, 0 ], uv: [ 1, 1 ], },
-+      { pos: [ 0, 0, 0 ], uv: [ 0, 1 ], },
-    ],
-  },
-  { // 위
-+    uvRow: 2,
-    dir: [  0,  1,  0, ],
-    corners: [
--      [ 0, 1, 1 ],
--      [ 1, 1, 1 ],
--      [ 0, 1, 0 ],
--      [ 1, 1, 0 ],
-+      { pos: [ 0, 1, 1 ], uv: [ 1, 1 ], },
-+      { pos: [ 1, 1, 1 ], uv: [ 0, 1 ], },
-+      { pos: [ 0, 1, 0 ], uv: [ 1, 0 ], },
-+      { pos: [ 1, 1, 0 ], uv: [ 0, 0 ], },
-    ],
-  },
-  { // 뒤
-+    uvRow: 0,
-    dir: [  0,  0, -1, ],
-    corners: [
--      [ 1, 0, 0 ],
--      [ 0, 0, 0 ],
--      [ 1, 1, 0 ],
--      [ 0, 1, 0 ],
-+      { pos: [ 1, 0, 0 ], uv: [ 0, 0 ], },
-+      { pos: [ 0, 0, 0 ], uv: [ 1, 0 ], },
-+      { pos: [ 1, 1, 0 ], uv: [ 0, 1 ], },
-+      { pos: [ 0, 1, 0 ], uv: [ 1, 1 ], },
-    ],
-  },
-  { // 앞
-+    uvRow: 0,
-    dir: [  0,  0,  1, ],
-    corners: [
--      [ 0, 0, 1 ],
--      [ 1, 0, 1 ],
--      [ 0, 1, 1 ],
--      [ 1, 1, 1 ],
-+      { pos: [ 0, 0, 1 ], uv: [ 0, 0 ], },
-+      { pos: [ 1, 0, 1 ], uv: [ 1, 0 ], },
-+      { pos: [ 0, 1, 1 ], uv: [ 0, 1 ], },
-+      { pos: [ 1, 1, 1 ], uv: [ 1, 1 ], },
-    ],
-  },
-];
-
-

방금 지정한 데이터를 사용하도록 코드를 수정합니다. 텍스처 아틀라스 타일 하나의 크기와 텍스처의 크기를 알아야 하니 생성 시에 넘겨 받도록 합니다.

-
class VoxelWorld {
--  constructor(cellSize) {
--    this.cellSize = cellSize;
-+  constructor(options) {
-+    this.cellSize = options.cellSize;
-+    this.tileSize = options.tileSize;
-+    this.tileTextureWidth = options.tileTextureWidth;
-+    this.tileTextureHeight = options.tileTextureHeight;
-+    const { cellSize } = this;
-+    this.cellSliceSize = cellSize * cellSize;
-+    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
-  }
-
-  ...
-
-  generateGeometryDataForCell(cellX, cellY, cellZ) {
--    const { cellSize } = this;
-+    const { cellSize, tileSize, tileTextureWidth, tileTextureHeight } = this;
-    const positions = [];
-    const normals = [];
-+    const uvs = [];
-    const indices = [];
-    const startX = cellX * cellSize;
-    const startY = cellY * cellSize;
-    const startZ = cellZ * cellSize;
-
-    for (let y = 0; y < cellSize; ++y) {
-      const voxelY = startY + y;
-      for (let z = 0; z < cellSize; ++z) {
-        const voxelZ = startZ + z;
-        for (let x = 0; x < cellSize; ++x) {
-          const voxelX = startX + x;
-          const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
-          if (voxel) {
-            const uvVoxel = voxel - 1;  // 0 위치의 복셀은 하늘이므로 UV의 경우는 0에서 시작하도록 합니다.
-            // 현재 위치에 복셀이 있을 때 해당 위치에 면이 필요한지 검사합니다.
--            for (const { dir, corners } of VoxelWorld.faces) {
-+            for (const { dir, corners, uvRow } of VoxelWorld.faces) {
-              const neighbor = this.getVoxel(
-                  voxelX + dir[0],
-                  voxelY + dir[1],
-                  voxelZ + dir[2]);
-              if (!neighbor) {
-                // 이 복셀은 이 방향에 이웃하는 복셀이 없으므로
-                // 이쪽에 면을 만듭니다.
-                const ndx = positions.length / 3;
--                for (const pos of corners) {
-+                for (const {pos, uv} of corners) {
-                  positions.push(pos[0] + x, pos[1] + y, pos[2] + z);
-                  normals.push(...dir);
-+                  uvs.push(
-+                        (uvVoxel +   uv[0]) * tileSize / tileTextureWidth,
-+                    1 - (uvRow + 1 - uv[1]) * tileSize / tileTextureHeight);
-                }
-                indices.push(
-                  ndx, ndx + 1, ndx + 2,
-                  ndx + 2, ndx + 1, ndx + 3,
-                );
-              }
-            }
-          }
-        }
-      }
-    }
-
-    return {
-      positions,
-      normals,
-      uvs,
-      indices,
-    };
-  }
-}
-
-

다음으로 텍스처를 불러옵니다.

-
const loader = new THREE.TextureLoader();
-const texture = loader.load('resources/images/minecraft/flourish-cc-by-nc-sa.png', render);
-texture.magFilter = THREE.NearestFilter;
-texture.minFilter = THREE.NearestFilter;
-texture.colorSpace = THREE.SRGBColorSpace;
-
-

그리고 VoxelWorld에 설정값을 넘겨줍니다.

-
+const tileSize = 16;
-+const tileTextureWidth = 256;
-+const tileTextureHeight = 64;
--const world = new VoxelWorld(cellSize);
-+const world = new VoxelWorld({
-+  cellSize,
-+  tileSize,
-+  tileTextureWidth,
-+  tileTextureHeight,
-+});
-
-

geometry를 만들 때 UV 좌표를, 재질을 만들 때 텍스처를 사용하도록 변경합니다.

-
-const { positions, normals, indices } = world.generateGeometryDataForCell(0, 0, 0);
-+const { positions, normals, uvs, indices } = world.generateGeometryDataForCell(0, 0, 0);
-const geometry = new THREE.BufferGeometry();
--const material = new THREE.MeshLambertMaterial({ color: 'green' });
-+const material = new THREE.MeshLambertMaterial({
-+  map: texture,
-+  side: THREE.DoubleSide,
-+  alphaTest: 0.1,
-+  transparent: true,
-+});
-
-const positionNumComponents = 3;
-const normalNumComponents = 3;
-+const uvNumComponents = 2;
-geometry.setAttribute(
-    'position',
-    new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
-geometry.setAttribute(
-    'normal',
-    new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
-+geometry.setAttribute(
-+    'uv',
-+    new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
-geometry.setIndex(indices);
-const mesh = new THREE.Mesh(geometry, material);
-scene.add(mesh);
-
-

마지막으로 복셀이 서로 다른 텍스처를 쓰도록 설정합니다.

-
for (let y = 0; y < cellSize; ++y) {
-  for (let z = 0; z < cellSize; ++z) {
-    for (let x = 0; x < cellSize; ++x) {
-      const height = (Math.sin(x / cellSize * Math.PI * 2) + Math.sin(z / cellSize * Math.PI * 3)) * (cellSize / 6) + (cellSize / 2);
-      if (y < height) {
--        world.setVoxel(x, y, z, 1);
-+        world.setVoxel(x, y, z, randInt(1, 17));
-      }
-    }
-  }
-}
-
-+function randInt(min, max) {
-+  return Math.floor(Math.random() * (max - min) + min);
-+}
-
-

한 번 실행해보죠!

-

- -

-

코드를 좀 더 발전시켜 하나 이상의 cell을 추가할 수 있도록 해봅시다.

-

먼저 각 cell에 id를 부여해 객체 형태로 저장하도록 합니다. 이 id는 각 cell의 위치값을 쉼표로 분할한 문자열로 지정할 겁니다. 예를 들어 (35,0,0) 복셀은 cell (1,0,0)에 있을 테니 해당 cell의 id는 "1,0,0"이 되겠죠.

-
class VoxelWorld {
-  constructor(options) {
-    this.cellSize = options.cellSize;
-    this.tileSize = options.tileSize;
-    this.tileTextureWidth = options.tileTextureWidth;
-    this.tileTextureHeight = options.tileTextureHeight;
-    const { cellSize } = this;
-    this.cellSliceSize = cellSize * cellSize;
--    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
-+    this.cells = {};
-  }
-+  computeCellId(x, y, z) {
-+    const { cellSize } = this;
-+    const cellX = Math.floor(x / cellSize);
-+    const cellY = Math.floor(y / cellSize);
-+    const cellZ = Math.floor(z / cellSize);
-+    return `${cellX},${cellY},${cellZ}`;
-+  }
-+  getCellForVoxel(x, y, z) {
--    const cellX = Math.floor(x / cellSize);
--    const cellY = Math.floor(y / cellSize);
--    const cellZ = Math.floor(z / cellSize);
--    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
--      return null;
--    }
--    return this.cell;
-+    return this.cells[this.computeCellId(x, y, z)];
-  }
-
-   ...
-}
-
-

그리고 setVoxel 메서드를 수정해 존재하지 않는 cell의 복셀을 추가할 때 새로운 cell을 추가하도록 합니다.

-
  setVoxel(x, y, z, v) {
--    const cell = this.getCellForVoxel(x, y, z);
-+    let cell = this.getCellForVoxel(x, y, z);
-    if (!cell) {
--      return 0;
-+      cell = this.addCellForVoxel(x, y, z);
-    }
-    const voxelOffset = this.computeVoxelOffset(x, y, z);
-    cell[voxelOffset] = v;
-  }
-+  addCellForVoxel(x, y, z) {
-+    const cellId = this.computeCellId(x, y, z);
-+    let cell = this.cells[cellId];
-+    if (!cell) {
-+      const { cellSize } = this;
-+      cell = new Uint8Array(cellSize * cellSize * cellSize);
-+      this.cells[cellId] = cell;
-+    }
-+    return cell;
-+  }
-
-

준비를 마쳤으니 복셀을 마음대로 수정할 수 있도록 해봅시다.

-

먼저 라디오 버튼을 이용해 타일을 8x2짜리 UI로 만듭니다.

-
<body>
-  <canvas id="c"></canvas>
-+  <div id="ui">
-+    <div class="tiles">
-+      <input type="radio" name="voxel" id="voxel1" value="1"><label for="voxel1" style="background-position:   -0% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel2" value="2"><label for="voxel2" style="background-position: -100% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel3" value="3"><label for="voxel3" style="background-position: -200% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel4" value="4"><label for="voxel4" style="background-position: -300% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel5" value="5"><label for="voxel5" style="background-position: -400% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel6" value="6"><label for="voxel6" style="background-position: -500% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel7" value="7"><label for="voxel7" style="background-position: -600% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel8" value="8"><label for="voxel8" style="background-position: -700% -0%"></label>
-+    </div>
-+    <div class="tiles">
-+      <input type="radio" name="voxel" id="voxel9"  value="9" ><label for="voxel9"  style="background-position:  -800% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel10" value="10"><label for="voxel10" style="background-position:  -900% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel11" value="11"><label for="voxel11" style="background-position: -1000% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel12" value="12"><label for="voxel12" style="background-position: -1100% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel13" value="13"><label for="voxel13" style="background-position: -1200% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel14" value="14"><label for="voxel14" style="background-position: -1300% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel15" value="15"><label for="voxel15" style="background-position: -1400% -0%"></label>
-+      <input type="radio" name="voxel" id="voxel16" value="16"><label for="voxel16" style="background-position: -1500% -0%"></label>
-+    </div>
-+  </div>
-</body>
-
-

UI가 현재 선택한 타일을 보여주도록 CSS도 추가합니다.

-
body {
-    margin: 0;
-}
-#c {
-    width: 100%;
-    height: 100%;
-    display: block;
-}
-+#ui {
-+    position: absolute;
-+    left: 10px;
-+    top: 10px;
-+    background: rgba(0, 0, 0, 0.8);
-+    padding: 5px;
-+}
-+#ui input[type=radio] {
-+  width: 0;
-+  height: 0;
-+  display: none;
-+}
-+#ui input[type=radio] + label {
-+  background-image: url('resources/images/minecraft/flourish-cc-by-nc-sa.png');
-+  background-size: 1600% 400%;
-+  image-rendering: pixelated;
-+  width: 64px;
-+  height: 64px;
-+  display: inline-block;
-+}
-+#ui input[type=radio]:checked + label {
-+  outline: 3px solid red;
-+}
-+@media (max-width: 600px), (max-height: 600px) {
-+  #ui input[type=radio] + label {
-+    width: 32px;
-+    height: 32px;
-+  }
-+}
-
-

기능은 다음처럼 구현할 겁니다. 선택한 타일이 없거나 shift 키를 누르고 있는 경우, 복셀을 클릭하면 해당 복셀이 지워집니다. 반대로 선택한 타일이 있는 경우 선택한 타일이 추가되죠. 선택한 타일을 다시 클릭하면 선택을 해제할 수 있습니다.

-

아래는 사용자가 선택한 라디오 버튼을 해제할 수 있게끔 해주는 코드입니다.

-
let currentVoxel = 0;
-let currentId;
-
-document.querySelectorAll('#ui .tiles input[type=radio][name=voxel]').forEach((elem) => {
-  elem.addEventListener('click', allowUncheck);
-});
-
-function allowUncheck() {
-  if (this.id === currentId) {
-    this.checked = false;
-    currentId = undefined;
-    currentVoxel = 0;
-  } else {
-    currentId = this.id;
-    currentVoxel = parseInt(this.value);
-  }
-}
-
-

아래 코드는 사용자가 클릭한 지점에 복셀을 추가하는 역할입니다. 피킹에 관한 글에서 썼던 것과 비슷한 방법을 사용하는데, Three.js의 내장 RayCaster가 아닌 교차하는 지점의 좌표와 교차한 점의 법선(normal)을 반환하는 VoxelWorld.intersectRay를 사용합니다.

-
function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
-  return {
-    x: (event.clientX - rect.left) * canvas.width  / rect.width,
-    y: (event.clientY - rect.top ) * canvas.height / rect.height,
-  };
-}
-
-function placeVoxel(event) {
-  const pos = getCanvasRelativePosition(event);
-  const x = (pos.x / canvas.width ) *  2 - 1;
-  const y = (pos.y / canvas.height) * -2 + 1;  // Y축을 뒤집었음
-
-  const start = new THREE.Vector3();
-  const end = new THREE.Vector3();
-  start.setFromMatrixPosition(camera.matrixWorld);
-  end.set(x, y, 1).unproject(camera);
-
-  const intersection = world.intersectRay(start, end);
-  if (intersection) {
-    const voxelId = event.shiftKey ? 0 : currentVoxel;
-    /**
-     * 교차점은 면 위에 있습니다. 이는 수학적 오차로 인해 교차점이 면의 양면
-     * 어디로 떨어질지 모른다는 이야기죠.
-     * 그래서 복셀을 제거하는 경우(currentVoxel = 0)는 normal의 값을 반으로
-     * 줄이고, 추가하는 경우(currentVoxel > 0)에는 방향을 바꾼 뒤 반만큼 줄입니다.
-     **/
-    const pos = intersection.position.map((v, ndx) => {
-      return v + intersection.normal[ndx] * (voxelId > 0 ? 0.5 : -0.5);
-    });
-    world.setVoxel(...pos, voxelId);
-    updateVoxelGeometry(...pos);
-    requestRenderIfNotRequested();
-  }
-}
-
-const mouse = {
-  x: 0,
-  y: 0,
-};
-
-function recordStartPosition(event) {
-  mouse.x = event.clientX;
-  mouse.y = event.clientY;
-  mouse.moveX = 0;
-  mouse.moveY = 0;
-}
-function recordMovement(event) {
-  mouse.moveX += Math.abs(mouse.x - event.clientX);
-  mouse.moveY += Math.abs(mouse.y - event.clientY);
-}
-function placeVoxelIfNoMovement(event) {
-  if (mouse.moveX < 5 && mouse.moveY < 5) {
-    placeVoxel(event);
-  }
-  window.removeEventListener('pointermove', recordMovement);
-  window.removeEventListener('pointerup', placeVoxelIfNoMovement);
-}
-canvas.addEventListener('pointerdown', (event) => {
-  event.preventDefault();
-  recordStartPosition(event);
-  window.addEventListener('pointermove', recordMovement);
-  window.addEventListener('pointerup', placeVoxelIfNoMovement);
-}, { passive: false });
-canvas.addEventListener('touchstart', (event) => {
-  // prevent scrolling
-  event.preventDefault();
-}, { passive: false });
-
-

마우스는 두 가지 용도로 사용합니다. 하나는 카메라를 움직이는 용도이고, 다른 하나는 복셀을 수정하는 용도이죠. 복셀의 추가/제거 액션은 마우스를 누르고 전혀 움직이지 않았을 때만 발생합니다. 마우스를 누른 뒤 움직였다면 카메라를 돌리려는 의도로 간주한 것이죠. moveXmoveY는 절대값으로, 왼쪽으로 10픽셀, 오른쪽으로 다시 10픽셀을 움직였다면 moveX는 20픽셀이 됩니다. 이러면 화면을 돌렸다가 다시 제자리에 놓는 경우에도 복셀의 추가/제거 액션이 발생하지 않을 겁니다. 5픽셀 이상 움직이지 않았을 경우 클릭으로 간주했는데, 별도 테스트는 진행하지 않은 임의의 값이니 참고 바랍니다.

-

위 코드에서는 world.setVoxel로 복셀을 추가한 뒤 updateVoxelGeometry를 호출해 Three.js가 변경된 geometry를 반영하도록 했습니다.

-

이제 이 updateVoxelGeometry를 만들어야 합니다. 사용자가 cell 가장자리의 복셀을 클릭했다면 새로운 cell geometry를 만들어야 할 수도 있죠. 때문에 방금 추가한 복셀 뿐만 아니라 해당 복셀의 cell 주변 cell들도 전부 확인해야 합니다.

-
const neighborOffsets = [
-  [ 0,  0,  0], // 자신
-  [-1,  0,  0], // 왼쪽
-  [ 1,  0,  0], // 오른쪽
-  [ 0, -1,  0], // 아래
-  [ 0,  1,  0], // 위
-  [ 0,  0, -1], // 뒤
-  [ 0,  0,  1], // 앞
-];
-function updateVoxelGeometry(x, y, z) {
-  const updatedCellIds = {};
-  for (const offset of neighborOffsets) {
-    const ox = x + offset[0];
-    const oy = y + offset[1];
-    const oz = z + offset[2];
-    const cellId = world.computeCellId(ox, oy, oz);
-    if (!updatedCellIds[cellId]) {
-      updatedCellIds[cellId] = true;
-      updateCellGeometry(ox, oy, oz);
-    }
-  }
-}
-
-

처음에는 아래처럼 인접한 cell을 검사하려 했습니다.

-
const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
-if (voxelX === 0) {
-  // cell을 왼쪽에 추가합니다.
-} else if (voxelX === cellSize - 1) {
-  // cell을 오른쪽에 추가합니다.
-}
-
-

여기에 다른 4방향을 검사하는 코드를 추가하려 했지만, 이때 그냥 좌표값 배열을 만들어 이미 만든 cell의 id로 사용하는 게 더 낫다는 생각이 들었습니다. 추가한 복셀이 cell의 안에 있는 게 아니라면 해당 복셀을 추가하길 거부하는 게 더 빠를 테니까요.

-

updateCellGeometry는 간단히 이전에 cell을 만들었던 코드를 가져와 여러 cell을 만들 수 있도록 수정했습니다.

-
const cellIdToMesh = {};
-function updateCellGeometry(x, y, z) {
-  const cellX = Math.floor(x / cellSize);
-  const cellY = Math.floor(y / cellSize);
-  const cellZ = Math.floor(z / cellSize);
-  const cellId = world.computeCellId(x, y, z);
-  let mesh = cellIdToMesh[cellId];
-  const geometry = mesh ? mesh.geometry : new THREE.BufferGeometry();
-
-  const { positions, normals, uvs, indices } = world.generateGeometryDataForCell(cellX, cellY, cellZ);
-  const positionNumComponents = 3;
-  geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
-  const normalNumComponents = 3;
-  geometry.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
-  const uvNumComponents = 2;
-  geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
-  geometry.setIndex(indices);
-  geometry.computeBoundingSphere();
-
-  if (!mesh) {
-    mesh = new THREE.Mesh(geometry, material);
-    mesh.name = cellId;
-    cellIdToMesh[cellId] = mesh;
-    scene.add(mesh);
-    mesh.position.set(cellX * cellSize, cellY * cellSize, cellZ * cellSize);
-  }
-}
-
-

위 함수는 인덱스 맵과 cell의 id로 미리 만든 mesh가 있는지 확인합니다. 만약 해당 id(좌표)에 해당하는 cell이 없다면 새로운 cell mesh를 만들어 장면에 추가한 뒤 mesh의 속성과 인덱스 맵을 업데이트합니다.

-

- -

-

참고:

-

예제의 방법 대신 RayCaster를 써도 괜찮은 결과가 나올 수 있습니다. 따로 테스트를 해보진 않았지만, 대신 복셀에 최적화된 raycaster를 찾아 이걸 적용했습니다.

-

intersectRay를 VoxelWorld의 메서드로 만든 건 성능 때문입니다. 복셀 단위로 체크하는 게 너무 느릴 경우 cell 단위로 먼저 체크해 성능을 좀 더 높혀보려는 계획이었죠.

-

현재 raycaster의 길이는 z-far까지인데, 이 값을 바꿔도 됩니다. 이건 제가 예제를 만들 때 1-2픽셀 정도로 보이는 먼 곳에는 복셀을 만들 일이 없다고 생각했기 때문이니까요.

-

geometry.computeBoundingSphere 메서드의 성능은 다소 느릴 수 있습니다. 이 경우 cell을 전부 포함하는 경계 구체를 직접 만들 수 있죠.

-

실제 프로젝트였다면 아마 복셀이 아예 없는 cell도 제거하는 게 좋았을 겁니다.

-

이 방법이 가장 별로일 경우는 당연히 체크판 형태로 복셀을 배치하는 경우(예를 들어 체크판의 흰색 칸에만 배치)일 겁니다. 당장은 이런 경우에 어떻게 성능을 향상시킬지 생각나는 방법이 없네요. 아마 사용자가 성능 때문에 거대한 체크판 만들기를 포기하는 게 더 빠를 겁니다.

-

예제에서는 간단한 형태만 구현하기 위해 텍스처 아틀라스를 텍스처 한 종류당 한 열씩만 만들었습니다. 각 복셀의 면에 다른 텍스처를 지정할 수 있도록 별도의 테이블을 만들면 좀 더 범용성을 추구할 수 있겠죠. 예제에서는 불필요한 낭비라고 생각해 해당 부분을 제외했습니다.

-

실제 마인크래프트에는 복셀도, 정육면체도 아닌 타일(tile)이라는 것이 있습니다. 울타리나 꽃 같은 것이 여기에 해당하죠. 이걸 구현하려면 각 복셀이 정육면체인지, 다른 geometry인지 판별하는 테이블을 만들어 복셀이 정육면체가 아닐 경우, 맞닿는 면을 제거하지 않도록 해야 합니다. 꽃 복셀 아래에 있는 땅 복셀이 지워져서는 안 되니까요.

-

이 글이 Three.js로 마인크래프트 같은 그래픽을 구현할 때 좋은 시작점을 마련하고, geometry를 최적화하는 데 도움이 되었으면 합니다.

-

- - -
-
-
- - - - - - - - diff --git a/manual/ko/webxr-basics.html b/manual/ko/webxr-basics.html deleted file mode 100644 index 3a8ad214c1c310..00000000000000 --- a/manual/ko/webxr-basics.html +++ /dev/null @@ -1,346 +0,0 @@ - - - VR - - - - - - - - - - - - - - -
-
-

VR

-
-
-
-

가상현실 앱을 three.js로 만드는 것은 매우 기본적으로 three.js에게 WedXR을 사용할 것이라 알리기만 하면됩니다. WedXR에 몇가지 사항을 명확하게 해야 하는데 이를 생각하여 보면 먼저 카메라가 가리키는 방향은 향하고 있는지 VR시스템에서 제공해야합니다. 사용자가 머리를 돌려 보는 방향을 선택하기 때문입니다. 비슷하게 각 시스템 이후 VR 시스템에서 시야와 화면비가 제공됩니다. (각 시스템은 시야와 디스플레이 측면이 다릅니다)

-

반응형 웹페이지 만들기 예시를 통하여 VR을 지원하도록 만들어 보겠습니다.

-

시작하기 전에 안드로이드 스마트폰, 구글 데이드림, 오큘러스 고, 오큘러스 리프트, 바이브, -삼성 기어 VR, WebXR browser가 설치된 아이폰과 같은 VR 지원 장치가 필요합니다.

-

다음으로, 로컬에서 실행 중인 경우 다음과 같은 간단한 웹 서버를 실행해야 합니다. -the article on setting up 참조.

-

VR을 보는 데 사용하는 장치가 실행 중인 컴퓨터와 다른 경우 -https를 통해 웹 페이지를 서비스해야 합니다. 그렇지 않으면 브라우저에서 사용을 허용하지 않습니다. -WebXR API. the article on setting up에 언급된 서버 -Servez에는 https를 사용할 수 있는 옵션이 있습니다. -확인 후 서버를 시작합니다.

-
- -

URL을 기록해 두십시오. 컴퓨터의 로컬 IP 주소가 필요합니다. 일반적으로 192, 172 또는 10으로 시작합니다. https:// 부분을 포함한 전체 주소를 입력하시면 됩니다.

-

VR 기기의 브라우저로 이동합니다. NOTE: 작업 컴퓨터와 VR 장치는 동일한 로컬 네트워크에 있어야 합니다. -또는 WiFi이고 아마도 홈 네트워크에 있어야 할것입니다. NOTE: 많은 카페에서 이러한 방법으로 기계 대 기계 연결을 합니다.

-

아래와 같은 오류 메시지가 표시됩니다. "고급(advanced)"을 클릭한 다음 진행(proceed) 을 클릭합니다.

-
- -

이제 예제를 실행할 수 있습니다.

-

실제로 WebVR 개발을 하려는 경우 배워야 할 또 다른 사항은 -원격 디버깅(remote debugging) 입니다. -이를 통해 콘솔 경보, 오류, 실제로 코드 디버그(debug your code)가 가능합니다.

-

아래 코드가 작동하는 것을 보고 싶다면 이 사이트에서 코드를 실행할 수 있습니다.

-

가장 먼저 해야 할 일은 three.js를 포함시킨 후 VR 지원을 포함하는 것입니다.

-
import * as THREE from 'three';
-+import {VRButton} from 'three/addons/webxr/VRButton.js';
-
-

이후 three.js's WebXR 지원을 활성화하며, 이를 위한 VR button을 페이지에 추가하여 주어야합니다.

-
function main() {
-  const canvas = document.querySelector('#c');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
-+  renderer.xr.enabled = true;
-+  document.body.appendChild(VRButton.createButton(renderer));
-
-

three.js가 렌더 루프를 실행하도록 해야 합니다. 지금까지 우리는 -requestAnimationFrameloop 를 사용하였습니다. 하지만 VR을 지원하기 위해서 우리는 three.js가 우리의 render loop를 관리할 수 있도록 해야합니다. 이 과정을 -WebGLRenderer.setAnimationLoop를 호출 및 루프를 호출하는 함수를 전달하여 진행 가능하게 합니다.

-
function render(time) {
-  time *= 0.001;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.updateProjectionMatrix();
-  }
-
-  cubes.forEach((cube, ndx) => {
-    const speed = 1 + ndx * .1;
-    const rot = time * speed;
-    cube.rotation.x = rot;
-    cube.rotation.y = rot;
-  });
-
-  renderer.render(scene, camera);
-
--  requestAnimationFrame(render);
-}
-
--requestAnimationFrame(render);
-+renderer.setAnimationLoop(render);
-
-

세부 사항이 하나 더 있습니다. 우리는 아마 카메라 높이를 설정해야 합니다. -예로 서있는 사용자의 평균 키를 들 수 있습니다.

-
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-+camera.position.set(0, 1.6, 0);
-
-

큐브를 카메라 앞으로 이동합니다.

-
const cube = new THREE.Mesh(geometry, material);
-scene.add(cube);
-
-cube.position.x = x;
-+cube.position.y = 1.6;
-+cube.position.z = -2;
-
-

카메라가 z = 0 에 있고 카메라는 -z축을 바라보기 때문에 우리는 z = -2에 배치 시켰습니다(cude).

-

이것은 매우 중요한 점을 제시합니다. Units in VR are in meters. -다른 말로 One Unit = One Meter. 이것은 카메라가 0보다 1.6미터 위에 있음을 의미합니다. -큐브의 중심은 카메라 앞에서 2미터입니다. -각 큐브 1x1x1미터 크기입니다. -이것은 VR이 실제 세계 에 사용자에 반응하여 사물을 조정해야 하기 때문에 중요합니다. 즉 사용자의 움직임과 VR에서의 움직임을 매치 시켜줄 단위가 필요합니다.

-

그리고 그것으로(단위) 우리는 앞으로 3개의 회전하는 큐브와 VR에 들어갈 버튼 을 카메라 앞에 가져와야 합니다.

-

- -

-

카메라 주변에 방(참조할 수 있는 공간)과 같은 감싸는 것이 있으면 VR이 더 잘 작동합니다. 따라서 과거 the article on backgrounds에서 진행한 것과 유사하게 간단한 그리드 큐브맵을 추가해 보겠습니다.

-

우리는 같은 그리드를 사용할 것입니다. -그리드 룸으로 제공할 큐브의 각 면에 대한 텍스처입니다.

-
const scene = new THREE.Scene();
-+{
-+  const loader = new THREE.CubeTextureLoader();
-+  const texture = loader.load([
-+    'resources/images/grid-1024.png',
-+    'resources/images/grid-1024.png',
-+    'resources/images/grid-1024.png',
-+    'resources/images/grid-1024.png',
-+    'resources/images/grid-1024.png',
-+    'resources/images/grid-1024.png',
-+  ]);
-+  scene.background = texture;
-+}
-
-

더욱 나아진것이 확인 가능합니다.

-

- -

-

Note: VR을 실제로 보려면 WebXR 호환 장치가 필요합니다. -대부분의 Android 휴대폰은 Chrome 또는 Firefox를 사용하여 WebXR을 지원 합니다. -iOS의 경우 WebXR App를 참조 하시면 됩니다. -iOS에서 일반적으로 WebXR 지원은 2019년 5월 현재 지원되지 않습니다.

-

Android 또는 iPhone에서 WebXR을 사용하려면 VR 헤드셋이 필요합니다. -전화용. 골판지로 만든 1개에 5달러부터 어디에서나 구입할 수 있습니다. -불행히도 어떤 제품을 추천해야 할지 모르겠습니다. -저는 여러가지 제품을 구입하였습니다. 그 중 6개는 수년에 걸쳐 생산되었으며 모두 품질이 다릅니다. -$25 이상을 지불한 적은 없습니다.

-

VR 헤드셋의 몇 가지 문제만 언급하겠습니다.

-
    -
  1. 휴대전화에 맞습니까?

    -

    전화기는 다양한 크기로 제공되므로 VR 헤드셋이 일치해야 합니다. -많은 헤드셋이 다양한 크기와 일치한다고 주장합니다. 내 경험 -더 많은 스마트폰 크기와 일치할수록 기능은 나빠집니다. -특정 크기에 맞게 설계된 잘 타협한 제품을 구매해야 합니다.

    -
  2. -
  3. 당신의 얼굴에 집중할 수 있습니까?

    -

    일부 장치에는 다른 장치보다 더 많은 조절 요소가 있습니다. 일반적으로는 -렌즈가 눈에서 얼마나 멀리 떨어져 있는지, 그리고 렌즈가 얼마나 멀리 떨어져 있는지 -최대 2개의 조절 요소가 존재합니다.

    -
  4. -
  5. 너무 반사적인가요?

    -

    당신의 눈에서 전화까지 플라스틱 원뿔의 많은 헤드셋. -플라스틱이 반짝이거나 반사되면 다음과 같이 작동합니다. -화면을 반사하는 거울처럼 매우 산만합니다.

    -

    리뷰 중 이 문제를 다루는 것으로 보이는 경우는 거의 없습니다.

    -
  6. -
  7. 당신의 얼굴에 편안한가요?

    -

    대부분의 장치는 안경처럼 코에 닿습니다. -몇 분 후에 아플 수 있습니다. 일부는 주변에 스트랩이 있습니다. -너의 머리 위로 가는 3번째 끈이 있습니다. 이것들은 -장치를 올바른 위치에 유지하는 데 도움이 될 수도 있고 도움이 되지 않을 수도 있습니다.

    -

    대부분의 (모든?) 장치에서 눈이 중앙에 있어야 합니다. -렌즈와 함께. 렌즈가 자신보다 약간 높거나 낮은 경우 -눈 이미지의 초점이 흐려집니다. 이것은 매우 실망 스러울 수 있습니다. -작업이 초점에 맞게 시작될 수 있지만 45-60초 후에 장치를 -1mm 위 또는 아래로 이동하면 갑자기 -흐릿한 이미지에 초점을 맞추려고 애쓰는 경우가 있습니다.

    -
  8. -
  9. 안경을 착용하고 사용할수 있습니까?

    -

    안경을 쓰신 분들은 리뷰를 읽어보시고 -특정 헤드셋이 안경과 잘 매치되는 경우는 -정말 아쉽게도 추천을 해드릴 수가 없습니다.

    -

    구글은 일부 판지로 만든 저렴한 추천 -그들 중 일부는 $ 5만큼 낮으므로 거기에서 시작하고 즐길 수 있습니다. -그런 다음 업그레이드를 고려하시면 됩니다. ($5는 커피 1잔 가격과 같으니 꼭 이용해 보세요!)

    -
  10. -
-

또한 3가지 기본 유형의 장치가 있습니다.

-
    -
  1. 3자유도(3dof), 입력 장치 없음

    -

    이것은 일반적으로 전화 스타일이지만 때로는 할 수 있습니다. -타사 입력 장치를 구입하십시오. 3 자유도 -위/아래(1), 왼쪽/오른쪽(2)을 보고 기울일 수 있음을 의미합니다. -머리를 좌우로(3).

    -
  2. -
  3. 1개의 입력 장치(3dof)로 3자유도(3dof)

    -

    이것은 기본적으로 Google Daydream과 Oculus GO입니다.

    -

    이것들은 또한 3개의 자유도를 허용하고 작은 -VR 내부에서 레이저 포인터처럼 작동하는 컨트롤러입니다. -레이저 포인터의 자유도는 3개뿐입니다. -NS 시스템은 입력 장치가 가리키는 방향을 알 수 있지만 -장치가 어디에 있는지 알 수 없습니다.

    -
  4. -
  5. 입력 장치(6dof)가 있는 6자유도(6dof)

    -

    이것들은 좋은 물건입니다. 6 자유도 -이 장치는 사용자가 보고 있는 방향을 알 뿐만 아니라 -그러나 그들은 또한 당신의 머리가 실제로 어디에 있는지 알고 있습니다. 그 의미는 -왼쪽에서 오른쪽으로 또는 앞뒤로 움직이거나 일어서거나 앉는 경우 -장치는 이것을 등록할 수 있고 VR의 모든 것은 그에 따라 움직입니다. -놀랍도록 실제적인 느낌입니다. 당신도 이와 비슷한 경험을 하게될것입니다. -아니면 적어도 저는 그랬고 지금도 그렇습니다.

    -

    또한 이러한 장치에는 일반적으로 2개의 컨트롤러가 포함됩니다. -각 손에 대하여 시스템은 사용자의 위치를 정확히 알 수 있습니다. -(손이 어떤 방향으로 향하고 있는지 손을 뻗고, 만지고, 밀기, 비틀기 등...)

    -
  6. -
-

이 모든 것을 다루었지만 어떤 장치가 WebXR과 함께 작동할지 확신할 수 없습니다. -Chrome을 실행할 때 대부분의 Android 휴대전화가 작동할 것이라고 99% 확신합니다. -당신은 about:flags에서 WebXR support를 켜야 할수도 있습니다.

-

Google Draydream 또한 비슷하게 작동할 것입니다.
about:flags에서 WebXR support 를 활성화 하세요.

-

Oculus Rift, Vive 및 Vive Pro는 Cheome 이나 Firefox를 통해 작동합니다.

-

Oculus Go와 Oculus Quest는 둘 다 사용자 정의 OS를 사용하지만 인터넷에 따르면 둘 다 작동하는 것으로 보입니다.

-

이제, VR 장치와 WebXR에 대한 긴 설명에 마지막을 설명하겠습니다.

-
    -
  • VR 및 Non-VR 모두 지원
  • -
-

내가 아는한 적어도 r112에서는 three.js 에 VR과 Non-VR 모드 모두를 지원하는 쉬운 방법이 없습니다.

-

이상적으로 -VR 모드가 아니면 OrbitControls와 같은 것들을 사용하여 카메라를 제어할 수 있습니다. -그리고 이 전환 과정에서 일조의 여러 이벤트가 발생합니다. -VR 모드에서 벗어나 컨트롤을 켜고 끌 수 있습니다.

-

만약 three.js가 앞서 말한 두가지를 지원해준다면 해당 artical을 업데이트 하도록 노력하겠습니다. -그때까지는 2가지 버전의 사이트를 만들거나 다음과 같이 URL에 flag 를 pass in 해줍니다.

-
https://mysite.com/mycooldemo?allowvr=true
-

그럼 우리는 스위치 모드에 몇몇 링크를 추가할 수 있습니다.

-
<body>
-  <canvas id="c"></canvas>
-+  <div class="mode">
-+    <a href="?allowvr=true" id="vr">Allow VR</a>
-+    <a href="?" id="nonvr">Use Non-VR Mode</a>
-+  </div>
-</body>
-
-

그리고 추가한 것들을 배치할 css 를 추가합니다.

-
body {
-    margin: 0;
-}
-#c {
-    width: 100%;
-    height: 100%;
-    display: block;
-}
-+.mode {
-+  position: absolute;
-+  right: 1em;
-+  top: 1em;
-+}
-
-

코드안에서 다음과 같은 파라미터를 사용 가능합니다.

-
function main() {
-  const canvas = document.querySelector('#c');
-  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
--  renderer.xr.enabled = true;
--  document.body.appendChild(VRButton.createButton(renderer));
-
-  const fov = 75;
-  const aspect = 2;  // the canvas default
-  const near = 0.1;
-  const far = 5;
-  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
-  camera.position.set(0, 1.6, 0);
-
-+  const params = (new URL(document.location)).searchParams;
-+  const allowvr = params.get('allowvr') === 'true';
-+  if (allowvr) {
-+    renderer.xr.enabled = true;
-+    document.body.appendChild(VRButton.createButton(renderer));
-+    document.querySelector('#vr').style.display = 'none';
-+  } else {
-+    // no VR, add some controls
-+    const controls = new OrbitControls(camera, canvas);
-+    controls.target.set(0, 1.6, -2);
-+    controls.update();
-+    document.querySelector('#nonvr').style.display = 'none';
-+  }
-
-

어떤 방식이 적합하지는 모릅니다. -VR에 필요한 것과 비 VR에 필요한 것 사이에는 종종 차이점을 느끼고 있습니다. -둘은 매우 다르므로 가장 간단한 것을 제외하고는 2개의 별도 페이지일 수 있습니다. -무엇이 더 좋은지는 사용자가 결정해야 합니다.

-

여러 가지 이유로 라이브 편집기에서 작동하지 않습니다. -이 사이트에 있으므로 확인하고 싶다면 -여기를 클릭하세요. -비 VR 모드에서 시작해야 하며 마우스나 손가락을 사용하여 이동할 수 있습니다. -카메라. "허용 VR"을 클릭하면 VR 모드를 허용하도록 전환되어야 하며 -VR 기기를 사용 중인 경우 "VR 입력"을 클릭할 수 있습니다.

-
    -
  • VR 지원 수준 결정

    -

    위에서 우리는 3가지 유형의 VR 기기를 다루었습니다.

    -
      -
    • 3DOF input 없음
    • -
    • 3DOF + 3DOF input
    • -
    • 6DOF + 6DOF input
    • -
    -

    목적에 따라 위 중 어떤 장치를 이용할지 결정해야합니다.

    -

    예를 들어 가장 단순한 장치에는 입력이 없습니다. 당신이 할 수있는 최선 -일반적으로 사용자의 보기에 일부 버튼이나 개체가 있도록 만드는 것입니다. -사용자가 디스플레이 중앙에 일부 마커를 정렬하는 경우 -1/2초 동안 해당 개체에서 해당 버튼을 클릭합니다. -일반적인 UX는 다음을 나타내는 개체 위에 나타날 작은 타이머를 표시하는 것입니다. -잠시 동안 마커를 유지하면 개체/버튼이 선택됩니다.

    -

    당신이 할 수 있는 최선에 대한 다른 입력이 없기 때문에 -다음 레벨에는 하나의 3DOF 입력 장치가 있습니다. -Daydream의 경우 일반적으로 사물을 가리킬 수 있고 사용자는 적어도 2개의 버튼을 가지고 있습니다. -또한 일반 터치 입력을 제공하는 터치 패드가 있습니다.

    -

    어쨌든 사용자가 이러한 유형의 장치를 가지고 있다면 사용자가 머리를 이용하여 가르키는 것보다 -훨씬 더 간단히 물건을 가리킬 수 있어 편안합니다.

    -

    그것과 유사한 수준은 게임 콘솔 컨트롤러를 포함한 3DOF 또는 6DOF 장치일 수 있습니다.

    -

    여기서 무엇을 할지 결정해야 합니다.

    -

    가장 흔한 것은 사용자가 여전히 -바라 봐야하며 컨트롤러는 버튼용으로만 사용된다는 점입니다.

    -

    마지막 레벨은 6DOF 헤드셋과 2개의 6DOF 컨트롤러를 사용하는 사용자입니다. -이러한 사용자는 종종 3DOF와 다를게 없는 답답한 경험을 할 수도 있을것입니다. -마찬가지로 그들은 일반적으로 다음을 수행할 수 있을 것으로 기대합니다. -VR에서 손으로 물건을 가상으로 조작하여 지원 여부를 결정해야 합니다.

    -
  • -
-

보시다시피 VR을 시작하는 것은 매우 쉽지만 -실제로 VR을 고르고 세팅하는데는 많은 디자인을 고민 및 결정 해야합니다.

-

이것은 three.js를 사용한 VR에 대한 아주 간단한 소개였습니다. 이제 -future articles에서 일부 입력 방법을 다루겠습니다.

- -
-
-
- - - - - - - - diff --git a/manual/ko/webxr-look-to-select.html b/manual/ko/webxr-look-to-select.html deleted file mode 100644 index b2fa54b65fb006..00000000000000 --- a/manual/ko/webxr-look-to-select.html +++ /dev/null @@ -1,411 +0,0 @@ - - - VR - Look to Select - - - - - - - - - - - - - - -
-
-

VR - Look to Select

-
-
-
-

NOTE: 이 페이지의 예시에는 VR 지원 기기가 필요합니다. -VR 기기 없이는 동작하지 않으며 그 이유를 이전 글 -에서 확인할 수 있습니다.

-

이전 글에서 우리는 three.js를 사용한 매우 간단한 VR 예제를 살펴보고 다양한 종류의 VR 시스템에 대해 이야기했습니다.

-

가장 간단하고 흔한 것은 기본적으로 5달러에서 50달러의 얼굴 마스크에 넣는 전화기인 VR 구글 카드 보드 스타일입니다. -이런 종류의 VR에는 컨트롤러가 없기 때문에 사람들은 사용자 입력을 허용하기 위한 창의적인 해결책을 생각해 내야 합니다.

-

이때 가장 일반적인 해결책은 사용자가 무언가를 잠시 동안 가리킬 경우 그것이 선택되는 "Look to Select"입니다.

-

"Look to Select"를 구현해봅시다! 먼저 이전 글의 예시에서 시작해 Three.js 피킹에서 만든 PickHelper를 추가할 것입니다.

-
class PickHelper {
-  constructor() {
-    this.raycaster = new THREE.Raycaster();
-    this.pickedObject = null;
-    this.pickedObjectSavedColor = 0;
-  }
-  pick(normalizedPosition, scene, camera, time) {
-    // restore the color if there is a picked object
-    if (this.pickedObject) {
-      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
-      this.pickedObject = undefined;
-    }
-
-    // cast a ray through the frustum
-    this.raycaster.setFromCamera(normalizedPosition, camera);
-    // get the list of objects the ray intersected
-    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
-    if (intersectedObjects.length) {
-      // pick the first object. It's the closest one
-      this.pickedObject = intersectedObjects[0].object;
-      // save its color
-      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
-      // set its emissive color to flashing red/yellow
-      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
-    }
-  }
-}
-
-

해당 코드에 대한 설명은 피킹에 대한 글을 참조하십시오.

-

이 기능을 사용하려면 인스턴스를 만들고 render loop에서 호출하기만 하면 됩니다.

-
+const pickHelper = new PickHelper();
-
-...
-function render(time) {
-  time *= 0.001;
-
-  ...
-
-+  // 0, 0 is the center of the view in normalized coordinates.
-+  pickHelper.pick({x: 0, y: 0}, scene, camera, time);
-
-

원래의 피킹 예시에서 우리는 마우스 좌표를 CSS 픽셀에서 캔버스를 가로질러 -1에서 +1로 가는 정규화된 좌표로 변환했습니다.

-

이 경우 우리는 항상 카메라가 마주 보고 있는 화면의 중심을 선택하기 때문에 정규화된 좌표의 중심인 x와 y 모두에 대해 0을 통과합니다.

-

그리고 우리가 그 물체들을 볼 때 그 물체들은 번쩍거릴 것입니다.

-

- -

-

일반적으로 우리는 즉각적인 선택을 원하지 않습니다.

-

대신 우리는 실수로 어떤 것을 선택하지 않도록 하기 위해 몇 분 동안 카메라를 사용자가 선택하고자 하는 것에 고정시키도록 합니다.

-

그러기 위해서 사용자가 계속 보고 있었는지, 그리고 얼마나 오래 있었는지를 전달하기 위한 일종의 미터나 게이지나 방법이 필요합니다.

-

이를 위한 한 가지 쉬운 방법은 2가지 색상의 텍스처를 만들고 텍스처 오프셋을 사용하여 모델을 가로질러 텍스처를 이동시키는 것입니다.

-

VR 예제에 추가하기 전에 스스로 작동하는지 보도록 합시다.

-

먼저 OrthographicCamera를 만들어 보겠습니다.

-
const left = -2;    // Use values for left
-const right = 2;    // right, top and bottom
-const top = 1;      // that match the default
-const bottom = -1;  // canvas size.
-const near = -1;
-const far = 1;
-const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
-
-

그리고 캔버스의 크기가 변경되면 업데이트하는 것을 잊지 마십시오.

-
function render(time) {
-  time *= 0.001;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    const aspect = canvas.clientWidth / canvas.clientHeight;
-+    camera.left = -aspect;
-+    camera.right = aspect;
-    camera.updateProjectionMatrix();
-  }
-  ...
-
-

우리는 현재 중앙 위아래 두 유닛과 좌우 측면 유닛을 보여주는 카메라를 가지고 있습니다.

-

다음으로 2가지 색 텍스처를 만들어 봅시다. 몇 군데 다른 곳에서 사용했던 DataTexture를 사용할 것입니다.

-
function makeDataTexture(data, width, height) {
-  const texture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat);
-  texture.minFilter = THREE.NearestFilter;
-  texture.magFilter = THREE.NearestFilter;
-  texture.needsUpdate = true;
-  return texture;
-}
-
-const cursorColors = new Uint8Array([
-  64, 64, 64, 64,       // dark gray
-  255, 255, 255, 255,   // white
-]);
-const cursorTexture = makeDataTexture(cursorColors, 2, 1);
-
-

그 다음 TorusGeometry에 있는 텍스처를 사용할 것입니다.

-
const ringRadius = 0.4;
-const tubeRadius = 0.1;
-const tubeSegments = 4;
-const ringSegments = 64;
-const cursorGeometry = new THREE.TorusGeometry(
-    ringRadius, tubeRadius, tubeSegments, ringSegments);
-
-const cursorMaterial = new THREE.MeshBasicMaterial({
-  color: 'white',
-  map: cursorTexture,
-  transparent: true,
-  blending: THREE.CustomBlending,
-  blendSrc: THREE.OneMinusDstColorFactor,
-  blendDst: THREE.OneMinusSrcColorFactor,
-});
-const cursor = new THREE.Mesh(cursorGeometry, cursorMaterial);
-scene.add(cursor);
-
-

그 다음 render에서 텍스처의 오프셋을 조정하도록 합니다.

-
function render(time) {
-  time *= 0.001;
-
-  if (resizeRendererToDisplaySize(renderer)) {
-    const canvas = renderer.domElement;
-    const aspect = canvas.clientWidth / canvas.clientHeight;
-    camera.left = -aspect;
-    camera.right = aspect;
-    camera.updateProjectionMatrix();
-  }
-
-+  const fromStart = 0;
-+  const fromEnd = 2;
-+  const toStart = -0.5;
-+  const toEnd = 0.5;
-+  cursorTexture.offset.x = THREE.MathUtils.mapLinear(
-+      time % 2,
-+      fromStart, fromEnd,
-+      toStart, toEnd);
-
-  renderer.render(scene, camera);
-}
-
-

THREE.MathUtils.mapLinearfromStartfromEnd 사이의 값을 취하여 시작과 끝 사이의 값으로 매핑합니다.

-

위의 경우, 0에서 2까지의 값을 의미하는 time % 2를 취하여 -0.5에서 0.5까지의 값에 매핑합니다.

-

텍스처는 0에서 1까지 정규화된 텍스처 좌표를 사용하여 geometry에 매핑됩니다. -즉, 기본 래핑 모드인 THREE.ClampToEdge로 설정된 2x1 픽셀 이미지를 의미하며, -텍스처 좌표를 -0.5만큼 조정하면 전체 메시가 첫 번째 색상이 되고 텍스처 좌표를 +0.5만큼 조정하면 전체 메시가 두 번째 색상이 됩니다. -필터링을 THREE.NearestFilter로 설정하면 geometry를 통해 두 색상 간의 전환이 가능해집니다.

-

배경과 관련된 글에서 다루었던 것처럼 배경의 질감을 더해봅시다. -2x2 색상 셋을 사용하지만 텍스처의 반복 설정을 8x8 그리드로 설정할 수 있습니다. -이렇게 하면 커서가 렌더링 되어 다른 색상과 대조하여 확인할 수 있습니다.

-
+const backgroundColors = new Uint8Array([
-+    0,   0,   0, 255,  // black
-+   90,  38,  38, 255,  // dark red
-+  100, 175, 103, 255,  // medium green
-+  255, 239, 151, 255,  // light yellow
-+]);
-+const backgroundTexture = makeDataTexture(backgroundColors, 2, 2);
-+backgroundTexture.wrapS = THREE.RepeatWrapping;
-+backgroundTexture.wrapT = THREE.RepeatWrapping;
-+backgroundTexture.repeat.set(4, 4);
-
-const scene = new THREE.Scene();
-+scene.background = backgroundTexture;
-
-

이제 이것을 실행하면 게이지와 같은 원을 얻을 수 있고 게이지 위치를 설정할 수 있습니다.

-

- -

-

몇 가지 주목하고 시도해야 할 것들이 있습니다.

-
    -
  • 다음과 같이 cursorMaterialblending, blendSrc, blendDst -속성을 설정합니다.

    -
      blending: THREE.CustomBlending,
    -  blendSrc: THREE.OneMinusDstColorFactor,
    -  blendDst: THREE.OneMinusSrcColorFactor,
    -

    이것은 효과의 타입으로 주어집니다. -그 세 줄에 주석을 달면 차이를 알 수 있을 것입니다. -저는 역효과가 가장 좋다고 생각하는데, 이렇게 하면 커서의 색깔에 상관없이 커서가 보일 수 있기 때문입니다.

    -
  • -
  • RingGeometry가 아닌 TorusGeometry를 사용해 봅시다.

    -

    어떤 이유로든 RingGeometry는 평평한 UV 매핑 방식을 사용합니다. -이 때문에 RingGeometry를 사용하면 위에서처럼 링 주위가 아닌 수평으로 링을 가로질러 텍스처가 미끄러집니다.

    -

    이걸 시도해 보고 TorusGeometryRingGeometry(위 예시에서 설명한 대로)로 바꾸면 무슨 뜻인지 알 수 있을 것입니다.

    -

    적절한 정의를 위한 적절한 할 것은 RingGeometry를 사용하되 링 주위를 돌도록 텍스처 좌표를 고정하는 것입니다. -아니면, 자신만의 링 지오메트리를 생성하세요. 그래도 torus는 잘 작동합니다. -MeshBasicMaterial과 함께 카메라 바로 앞에 배치하면 링과 똑같이 보이고 텍스처 좌표가 링 주위를 돌기 때문에 우리가 원하는 대로 작동합니다.

    -
  • -
-

이제 이것을 위의 VR 코드와 통합해 봅시다.

-
class PickHelper {
--  constructor() {
-+  constructor(camera) {
-    this.raycaster = new THREE.Raycaster();
-    this.pickedObject = null;
--    this.pickedObjectSavedColor = 0;
-
-+    const cursorColors = new Uint8Array([
-+      64, 64, 64, 64,       // dark gray
-+      255, 255, 255, 255,   // white
-+    ]);
-+    this.cursorTexture = makeDataTexture(cursorColors, 2, 1);
-+
-+    const ringRadius = 0.4;
-+    const tubeRadius = 0.1;
-+    const tubeSegments = 4;
-+    const ringSegments = 64;
-+    const cursorGeometry = new THREE.TorusGeometry(
-+        ringRadius, tubeRadius, tubeSegments, ringSegments);
-+
-+    const cursorMaterial = new THREE.MeshBasicMaterial({
-+      color: 'white',
-+      map: this.cursorTexture,
-+      transparent: true,
-+      blending: THREE.CustomBlending,
-+      blendSrc: THREE.OneMinusDstColorFactor,
-+      blendDst: THREE.OneMinusSrcColorFactor,
-+    });
-+    const cursor = new THREE.Mesh(cursorGeometry, cursorMaterial);
-+    // add the cursor as a child of the camera
-+    camera.add(cursor);
-+    // and move it in front of the camera
-+    cursor.position.z = -1;
-+    const scale = 0.05;
-+    cursor.scale.set(scale, scale, scale);
-+    this.cursor = cursor;
-+
-+    this.selectTimer = 0;
-+    this.selectDuration = 2;
-+    this.lastTime = 0;
-  }
-  pick(normalizedPosition, scene, camera, time) {
-+    const elapsedTime = time - this.lastTime;
-+    this.lastTime = time;
-
--    // restore the color if there is a picked object
--    if (this.pickedObject) {
--      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
--      this.pickedObject = undefined;
--    }
-
-+    const lastPickedObject = this.pickedObject;
-+    this.pickedObject = undefined;
-
-    // cast a ray through the frustum
-    this.raycaster.setFromCamera(normalizedPosition, camera);
-    // get the list of objects the ray intersected
-    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
-    if (intersectedObjects.length) {
-      // pick the first object. It's the closest one
-      this.pickedObject = intersectedObjects[0].object;
--      // save its color
--      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
--      // set its emissive color to flashing red/yellow
--      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
-    }
-
-+    // show the cursor only if it's hitting something
-+    this.cursor.visible = this.pickedObject ? true : false;
-+
-+    let selected = false;
-+
-+    // if we're looking at the same object as before
-+    // increment time select timer
-+    if (this.pickedObject && lastPickedObject === this.pickedObject) {
-+      this.selectTimer += elapsedTime;
-+      if (this.selectTimer >= this.selectDuration) {
-+        this.selectTimer = 0;
-+        selected = true;
-+      }
-+    } else {
-+      this.selectTimer = 0;
-+    }
-+
-+    // set cursor material to show the timer state
-+    const fromStart = 0;
-+    const fromEnd = this.selectDuration;
-+    const toStart = -0.5;
-+    const toEnd = 0.5;
-+    this.cursorTexture.offset.x = THREE.MathUtils.mapLinear(
-+        this.selectTimer,
-+        fromStart, fromEnd,
-+        toStart, toEnd);
-+
-+    return selected ? this.pickedObject : undefined;
-  }
-}
-
-

위의 코드를 보시면 커서 형상, 텍스처, 매테리얼을 만들기 위해 모든 코드를 추가한 것을 볼 수 있습니다. -그리고 카메라의 자식으로 추가해서 항상 카메라 앞에 놓이게 합니다. -커서가 렌더링 되지 않을 경우 카메라를 scene에 추가해야 합니다.

-
+scene.add(camera);
-
-

이 다음 이번에 피킹 할 것이 지난번과 같은지 확인합니다. -타이머에 경과 시간을 추가하고 타이머가 한계치에 도달하면 선택한 항목을 반환합니다.

-

이제 큐브들을 고르는데 그것을 사용해 봅시다. -간단한 예로 3개의 구도 추가하겠습니다. -큐브를 선택하여 큐브를 숨기고 해당 구의 숨기기를 취소합니다.

-

먼저 구면 geometry를 만들어보겠습니다.

-
const boxWidth = 1;
-const boxHeight = 1;
-const boxDepth = 1;
--const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-+const boxGeometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-+
-+const sphereRadius = 0.5;
-+const sphereGeometry = new THREE.SphereGeometry(sphereRadius);
-
-

그리고 세 쌍의 박스와 구 Mesh를 만들어 봅시다. 각 Mesh를 파트너와 연결할 수 있도록 을 사용할 것입니다.

-
-const cubes = [
--  makeInstance(geometry, 0x44aa88,  0),
--  makeInstance(geometry, 0x8844aa, -2),
--  makeInstance(geometry, 0xaa8844,  2),
--];
-+const meshToMeshMap = new Map();
-+[
-+  { x:  0, boxColor: 0x44aa88, sphereColor: 0xFF4444, },
-+  { x:  2, boxColor: 0x8844aa, sphereColor: 0x44FF44, },
-+  { x: -2, boxColor: 0xaa8844, sphereColor: 0x4444FF, },
-+].forEach((info) => {
-+  const {x, boxColor, sphereColor} = info;
-+  const sphere = makeInstance(sphereGeometry, sphereColor, x);
-+  const box = makeInstance(boxGeometry, boxColor, x);
-+  // hide the sphere
-+  sphere.visible = false;
-+  // map the sphere to the box
-+  meshToMeshMap.set(box, sphere);
-+  // map the box to the sphere
-+  meshToMeshMap.set(sphere, box);
-+});
-
-

큐브를 회전하는 render에서 cubes 대신 meshToMeshMap를 반복해야 합니다.

-
-cubes.forEach((cube, ndx) => {
-+let ndx = 0;
-+for (const mesh of meshToMeshMap.keys()) {
-  const speed = 1 + ndx * .1;
-  const rot = time * speed;
--  cube.rotation.x = rot;
--  cube.rotation.y = rot;
--});
-+  mesh.rotation.x = rot;
-+  mesh.rotation.y = rot;
-+  ++ndx;
-+}
-
-

이제 새로운 PickHelper 구현을 사용하여 개체 중 하나를 선택할 수 있습니다. 이 옵션을 선택하면 개체를 숨기고 그 파트너를 드러냅니다.

-
// 0, 0 is the center of the view in normalized coordinates.
--pickHelper.pick({x: 0, y: 0}, scene, camera, time);
-+const selectedObject = pickHelper.pick({x: 0, y: 0}, scene, camera, time);
-+if (selectedObject) {
-+  selectedObject.visible = false;
-+  const partnerObject = meshToMeshMap.get(selectedObject);
-+  partnerObject.visible = true;
-+}
-
-

그리고 이를 통해 우리는 꽤 괜찮은 look to select를 구현해야 합니다.

-

- -

-

이 예제가 구글 카드 보드 레벨 UX의 "look to select"를 구현하는 방법에 대한 아이디어를 주었기를 바랍니다. -텍스쳐 좌표 오프셋을 사용한 슬라이딩 텍스쳐도 일반적으로 유용한 기법입니다.

-

다음으로는 VR 컨트롤러가 있는 사용자가 사물을 가리키고 이동할 수 있는 방법을 알아보겠습니다..

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/ko/webxr-point-to-select.html b/manual/ko/webxr-point-to-select.html deleted file mode 100644 index b9ffe070cc67fe..00000000000000 --- a/manual/ko/webxr-point-to-select.html +++ /dev/null @@ -1,353 +0,0 @@ - - - VR - 3DOF Point to Select - - - - - - - - - - - - - - -
-
-

VR - 3DOF Point to Select

-
-
-
-

NOTE: 이 페이지의 예시에는 포인팅 장치가 있는 VR 지원 장치가 필요합니다. -포인팅 장치가 있는 VR 지원 장치가 없으면 작업을 수행할 수 없으며 -그 이유를 이 글에서 확인할 수 있습니다. -

-

이전 글에서는 사용자가 보는 것을 통해 항목을 가리키며 선택할 수 있도록 하는 매우 간단한 VR 예제를 살펴보았습니다. -이 글에서는 한 단계 더 나아가 사용자가 포인팅 장치를 사용하여 항목을 선택할 수 있도록 해보겠습니다.

-

Three.js는 2개의 컨트롤러 개체를 VR로 제공하여 비교적 쉽게 만들며 단일 3DOF 컨트롤러와 2개의 6DOF 컨트롤러의 경우를 모두 처리하려고 합니다. -각 컨트롤러는 컨트롤러의 방향과 위치를 제공하는 Object3D 개체입니다. -또한 사용자가 컨트롤러의 "메인" 버튼을 누르기 시작하고, 누르고, 누르기를 중지할 때(끝낼 때) selectstart, selectselectend 이벤트를 제공합니다.

-

이전 글의 마지막 예시에서 PickHelperControllerPickHelper로 변경해 보겠습니다.

-

이번의 새로운 구현에서는 선택된 개체를 제공하는 select 이벤트를 내보낼 것입니다. -따라서 개체를 사용하기 위해 이 작업을 수행해야 합니다.

-
const pickHelper = new ControllerPickHelper(scene);
-pickHelper.addEventListener('select', (event) => {
-  event.selectedObject.visible = false;
-  const partnerObject = meshToMeshMap.get(event.selectedObject);
-  partnerObject.visible = true;
-});
-
-

이전의 코드를 떠올려 보면 상자와 구를 서로 매핑하면 meshToMeshMap를 통해 박스와 구를 찾을 수 있으므로 -여기서는 선택된 개체를 숨기고 파트너의 숨김을 해제합니다.

-

ControllerPickHelper의 실제 구현에 대해서는 먼저 VR 컨트롤러 개체를 scene에 추가하고 - 이러한 개체에 사용자가 가리키는 위치를 표시하는 데 사용할 수 있는 3D 라인을 추가하고, 컨트롤러와 라인을 모두 저장해야 합니다.

-
class ControllerPickHelper {
-  constructor(scene) {
-    const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
-      new THREE.Vector3(0, 0, 0),
-      new THREE.Vector3(0, 0, -1),
-    ]);
-
-    this.controllers = [];
-    for (let i = 0; i < 2; ++i) {
-      const controller = renderer.xr.getController(i);
-      scene.add(controller);
-
-      const line = new THREE.Line(pointerGeometry);
-      line.scale.z = 5;
-      controller.add(line);
-      this.controllers.push({controller, line});
-    }
-  }
-}
-
-

다른 어떠한 작업을 수행하지 않고 이 작업만으로도 -사용자의 포인팅 장치가 어디에 있고 어느 쪽을 가리키고 있는지를 보여주는 scene에서 한 두개의 라인이 제공됩니다.

-

다음에는 컨트롤러로 선택하는 코드를 추가해봅시다. 카메라가 아닌 것으로 선택하는 것은 이번이 처음입니다. -피킹에 관한 글에서는 마우스나 손가락을 사용하여 선택하는 것이 카메라에서 화면으로 전달된다는 것을 의미했습니다. -이전 글에서는 카메라에 나오는 사용자가 어떤 식으로 다시 보이는지를 기준으로 선택했습니다. -이번에는 카메라를 사용하지 않기 때문에 컨트롤러의 위치를 통해 선택합니다.

-
class ControllerPickHelper {
-  constructor(scene) {
-+    this.raycaster = new THREE.Raycaster();
-+    this.objectToColorMap = new Map();
-+    this.controllerToObjectMap = new Map();
-+    this.tempMatrix = new THREE.Matrix4();
-
-    const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
-      new THREE.Vector3(0, 0, 0),
-      new THREE.Vector3(0, 0, -1),
-    ]);
-
-    this.controllers = [];
-    for (let i = 0; i < 2; ++i) {
-      const controller = renderer.xr.getController(i);
-      scene.add(controller);
-
-      const line = new THREE.Line(pointerGeometry);
-      line.scale.z = 5;
-      controller.add(line);
-      this.controllers.push({controller, line});
-    }
-  }
-+  update(scene, time) {
-+    this.reset();
-+    for (const {controller, line} of this.controllers) {
-+      // cast a ray through the from the controller
-+      this.tempMatrix.identity().extractRotation(controller.matrixWorld);
-+      this.raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
-+      this.raycaster.ray.direction.set(0, 0, -1).applyMatrix4(this.tempMatrix);
-+      // get the list of objects the ray intersected
-+      const intersections = this.raycaster.intersectObjects(scene.children);
-+      if (intersections.length) {
-+        const intersection = intersections[0];
-+        // make the line touch the object
-+        line.scale.z = intersection.distance;
-+        // pick the first object. It's the closest one
-+        const pickedObject = intersection.object;
-+        // save which object this controller picked
-+        this.controllerToObjectMap.set(controller, pickedObject);
-+        // highlight the object if we haven't already
-+        if (this.objectToColorMap.get(pickedObject) === undefined) {
-+          // save its color
-+          this.objectToColorMap.set(pickedObject, pickedObject.material.emissive.getHex());
-+          // set its emissive color to flashing red/yellow
-+          pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFF2000 : 0xFF0000);
-+        }
-+      } else {
-+        line.scale.z = 5;
-+      }
-+    }
-+  }
-}
-
-

Raycaster를 사용하기 전에는 그랬지만 이번에는 컨트롤러에서 ray를 가져옵니다. -이전 PickHelper에서는 한 가지만 선택할 수 있었지만, 여기서는 한 손에 하나씩 최대 2개의 컨트롤러가 있습니다. -우리는 각 컨트롤러가 보고 있는 개체를 controllerToObjectMap에 저장해야 합니다. -또한 원래 방사체의 색을 objectToColorMap에 저장하여 선이 가리키는 모든 부분에 닿을 수 있을 만큼 길게 만듭니다.

-

모든 프레임에서 이러한 설정들을 재설정하려면 몇 가지 코드를 추가해야 합니다.

-
class ControllerPickHelper {
-
-  ...
-
-+  _reset() {
-+    // restore the colors
-+    this.objectToColorMap.forEach((color, object) => {
-+      object.material.emissive.setHex(color);
-+    });
-+    this.objectToColorMap.clear();
-+    this.controllerToObjectMap.clear();
-+  }
-  update(scene, time) {
-+    this._reset();
-
-    ...
-
-}
-
-

다음으로 우리는 사용자가 컨트롤러를 클릭했을 때 select 이벤트를 내보내야 합니다. -이를위해 three.js의 EventDispatcher를 확장한 후 컨트롤러에서 select 이벤트를 확인하고 -해당 컨트롤러가 가리키는 것이 있으면 해당 컨트롤러가 가리키는 select 이벤트를 내보냅니다.

-
-class ControllerPickHelper {
-+class ControllerPickHelper extends THREE.EventDispatcher {
-  constructor(scene) {
-+    super();
-    this.raycaster = new THREE.Raycaster();
-    this.objectToColorMap = new Map();  // object to save color and picked object
-    this.controllerToObjectMap = new Map();
-    this.tempMatrix = new THREE.Matrix4();
-
-    const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
-      new THREE.Vector3(0, 0, 0),
-      new THREE.Vector3(0, 0, -1),
-    ]);
-
-    this.controllers = [];
-    for (let i = 0; i < 2; ++i) {
-      const controller = renderer.xr.getController(i);
-+      controller.addEventListener('select', (event) => {
-+        const controller = event.target;
-+        const selectedObject = this.controllerToObjectMap.get(controller);
-+        if (selectedObject) {
-+          this.dispatchEvent({type: 'select', controller, selectedObject});
-+        }
-+      });
-      scene.add(controller);
-
-      const line = new THREE.Line(pointerGeometry);
-      line.scale.z = 5;
-      controller.add(line);
-      this.controllers.push({controller, line});
-    }
-  }
-}
-
-

이제 render loop에서 update만 호출하면 됩니다.

-
function render(time) {
-
-  ...
-
-+  pickHelper.update(scene, time);
-
-  renderer.render(scene, camera);
-}
-
-

그리고 컨트롤러가 있는 VR 장치가 있다고 가정하면 컨트롤러를 사용하여 선택할 수 있습니다.

-

- -

-

만약 우리가 물체를 움직일 수 있기를 원한다면 어떨까요?

-

그것은 비교적 쉽습니다. -컨트롤러의 select 수신기 코드를 함수 안으로 이동하여 두가지 이상의 용도로 그것을 사용할 수 있도록 합니다.

-
class ControllerPickHelper extends THREE.EventDispatcher {
-  constructor(scene) {
-    super();
-
-    ...
-
-    this.controllers = [];
-
-+    const selectListener = (event) => {
-+      const controller = event.target;
-+      const selectedObject = this.controllerToObjectMap.get(event.target);
-+      if (selectedObject) {
-+        this.dispatchEvent({type: 'select', controller, selectedObject});
-+      }
-+    };
-
-    for (let i = 0; i < 2; ++i) {
-      const controller = renderer.xr.getController(i);
--      controller.addEventListener('select', (event) => {
--        const controller = event.target;
--        const selectedObject = this.controllerToObjectMap.get(event.target);
--        if (selectedObject) {
--          this.dispatchEvent({type: 'select', controller, selectedObject});
--        }
--      });
-+      controller.addEventListener('select', selectListener);
-
-       ...
-
-

이제 이것을 selectstartselect 모두에 사용해 봅시다.

-
class ControllerPickHelper extends THREE.EventDispatcher {
-  constructor(scene) {
-    super();
-
-    ...
-
-    this.controllers = [];
-
-    const selectListener = (event) => {
-      const controller = event.target;
-      const selectedObject = this.controllerToObjectMap.get(event.target);
-      if (selectedObject) {
--        this.dispatchEvent({type: 'select', controller, selectedObject});
-+        this.dispatchEvent({type: event.type, controller, selectedObject});
-      }
-    };
-
-    for (let i = 0; i < 2; ++i) {
-      const controller = renderer.xr.getController(i);
-      controller.addEventListener('select', selectListener);
-      controller.addEventListener('selectstart', selectListener);
-
-       ...
-
-

사용자가 컨트롤러의 버튼을 놓을 때 three.js가 전송하는 selectend 이벤트도 전달해 봅시다.

-
class ControllerPickHelper extends THREE.EventDispatcher {
-  constructor(scene) {
-    super();
-
-    ...
-
-    this.controllers = [];
-
-    const selectListener = (event) => {
-      const controller = event.target;
-      const selectedObject = this.controllerToObjectMap.get(event.target);
-      if (selectedObject) {
-        this.dispatchEvent({type: event.type, controller, selectedObject});
-      }
-    };
-
-+    const endListener = (event) => {
-+      const controller = event.target;
-+      this.dispatchEvent({type: event.type, controller});
-+    };
-
-    for (let i = 0; i < 2; ++i) {
-      const controller = renderer.xr.getController(i);
-      controller.addEventListener('select', selectListener);
-      controller.addEventListener('selectstart', selectListener);
-+      controller.addEventListener('selectend', endListener);
-
-       ...
-
-

이제 코드를 변경하여 selectstart 이벤트가 발생하면 선택한 개체를 scene에서 제거하고 컨트롤러의 하위 개체로 만듭니다. -즉, 컨트롤러와 함께 이동합니다. selectend이벤트를 받게 되면 다시 scene에 넣을 것입니다.

-
const pickHelper = new ControllerPickHelper(scene);
--pickHelper.addEventListener('select', (event) => {
--  event.selectedObject.visible = false;
--  const partnerObject = meshToMeshMap.get(event.selectedObject);
--  partnerObject.visible = true;
--});
-
-+const controllerToSelection = new Map();
-+pickHelper.addEventListener('selectstart', (event) => {
-+  const {controller, selectedObject} = event;
-+  const existingSelection = controllerToSelection.get(controller);
-+  if (!existingSelection) {
-+    controllerToSelection.set(controller, {
-+      object: selectedObject,
-+      parent: selectedObject.parent,
-+    });
-+    controller.attach(selectedObject);
-+  }
-+});
-+
-+pickHelper.addEventListener('selectend', (event) => {
-+  const {controller} = event;
-+  const selection = controllerToSelection.get(controller);
-+  if (selection) {
-+    controllerToSelection.delete(controller);
-+    selection.parent.attach(selection.object);
-+  }
-+});
-
-

한 개체가 선택되면 해당 개체와 원래의 부모 개체가 저장됩니다. -사용자가 작업을 마치면 개체를 다시 돌려놓을 수 있습니다.

-

Object3D.attach를 사용하여 선택한 개체를 재부모화 합니다. -이러한 기능을 통해 scene에서 객체의 방향과 위치를 변경하지 않고도 객체의 부모를 변경할 수 있습니다.

-

그리고 우리는 이를 통해 6DOF컨트롤러로 물체를 이동하거나 3DOF 컨트롤러로 방향을 전환할 수도 있을 것입니다.

-

- -

-

솔직하게 말해서 나는 이 ControllerPickHelper가 코드를 구성하는 가장 좋은 방법이라고 확신할 수 없습니다. -하지만 이것은 three.js의 VR에서 작동하는 간단한 작업들의 다양한 부분을 보여주는데 유용합니다.

- -
-
-
- - - - - - - - \ No newline at end of file diff --git a/manual/list.json b/manual/list.json index 5a24759f8584bd..a8014fe1457e4a 100644 --- a/manual/list.json +++ b/manual/list.json @@ -1,416 +1,87 @@ { - "en": { - "Getting Started": { - "Installation": "en/installation", - "Creating a Scene": "en/creating-a-scene", - "Creating Text": "en/creating-text", - "Drawing Lines": "en/drawing-lines", - "FAQ": "en/faq", - "Libraries and Plugins": "en/libraries-and-plugins", - "Loading 3D Models": "en/loading-3d-models", - "Uniform Types": "en/uniform-types", - "Useful Links": "en/useful-links", - "WebGL Compatibility Check": "en/webgl-compatibility-check" - }, - "Next Steps": { - "Animation System": "en/animation-system", - "Color Management": "en/color-management", - "How to create VR content": "en/how-to-create-vr-content", - "How to dispose of Objects": "en/how-to-dispose-of-objects", - "How to update Things": "en/how-to-update-things", - "How to use Post Processing": "en/how-to-use-post-processing", - "Matrix Transformations": "en/matrix-transformations" - }, - "---": {}, - "Basics": { - "Fundamentals": "en/fundamentals", - "Responsive Design": "en/responsive", - "Prerequisites": "en/prerequisites", - "Setup": "en/setup" - }, - "Fundamentals": { - "Primitives": "en/primitives", - "Scenegraph": "en/scenegraph", - "Materials": "en/materials", - "Textures": "en/textures", - "Lights": "en/lights", - "Cameras": "en/cameras", - "Shadows": "en/shadows", - "Fog": "en/fog", - "Render Targets": "en/rendertargets", - "Custom BufferGeometry": "en/custom-buffergeometry", - "Physics": "en/physics" - }, - "Tips": { - "Rendering On Demand": "en/rendering-on-demand", - "Debugging JavaScript": "en/debugging-javascript", - "Debugging GLSL": "en/debugging-glsl", - "Taking a screenshot": "en/tips#screenshot", - "Prevent the Canvas Being Cleared": "en/tips#preservedrawingbuffer", - "Get Keyboard Input From a Canvas": "en/tips#tabindex", - "Make the Canvas Transparent": "en/tips#transparent-canvas", - "Use three.js as Background in HTML": "en/tips#html-background" - }, - "Optimization": { - "Optimizing Lots of Objects": "en/optimize-lots-of-objects", - "Optimizing Lots of Objects Animated": "en/optimize-lots-of-objects-animated", - "Using OffscreenCanvas in a Web Worker": "en/offscreencanvas" - }, - "Solutions": { - "Load an .OBJ file": "en/load-obj", - "Load a .GLTF file": "en/load-gltf", - "Add a Background or Skybox": "en/backgrounds", - "How to Draw Transparent Objects": "en/transparency", - "Multiple Canvases, Multiple Scenes": "en/multiple-scenes", - "Picking Objects with the mouse": "en/picking", - "Post Processing": "en/post-processing", - "Using Shadertoy shaders": "en/shadertoy", - "Aligning HTML Elements to 3D": "en/align-html-elements-to-3d", - "Using Indexed Textures for Picking and Color": "en/indexed-textures", - "Using A Canvas for Dynamic Textures": "en/canvas-textures", - "Billboards and Facades": "en/billboards", - "Freeing Resources": "en/cleanup", - "Making Voxel Geometry (Minecraft)": "en/voxel-geometry", - "Start making a Game": "en/game" - }, - "WebGPU": { - "WebGPURenderer": "en/webgpurenderer", - "Post-Processing": "en/webgpu-postprocessing" - }, - "WebXR": { - "VR - Basics": "en/webxr-basics", - "VR - Look To Select": "en/webxr-look-to-select", - "VR - Point To Select": "en/webxr-point-to-select" - }, - "Reference": { - "Material Table": "en/material-table" - } + "Getting Started": { + "Installation": "installation", + "Creating a Scene": "creating-a-scene", + "Creating Text": "creating-text", + "Drawing Lines": "drawing-lines", + "FAQ": "faq", + "Libraries and Plugins": "libraries-and-plugins", + "Loading 3D Models": "loading-3d-models", + "Uniform Types": "uniform-types", + "Useful Links": "useful-links", + "WebGL Compatibility Check": "webgl-compatibility-check" }, - "fr": { - "Bases": { - "Principes de base": "fr/fundamentals", - "Design réactif": "fr/responsive", - "Pré-requis": "fr/prerequisites", - "Configuration": "fr/setup" - }, - "Principes de base": { - "Primitives": "fr/primitives", - "Graphe de scène": "fr/scenegraph", - "Matériaux": "fr/materials", - "Textures": "fr/textures", - "Lumières": "fr/lights", - "Caméras": "fr/cameras", - "Ombres": "fr/shadows", - "Brouillard": "fr/fog", - "Render Targets": "fr/rendertargets", - "Custom BufferGeometry": "fr/custom-buffergeometry" - }, - "Сonseils": { - "Rendering On Demand": "fr/rendering-on-demand", - "Debugging JavaScript": "fr/debugging-javascript", - "Debugging GLSL": "fr/debugging-glsl", - "Taking a screenshot": "fr/tips#screenshot", - "Prevent the Canvas Being Cleared": "fr/tips#preservedrawingbuffer", - "Get Keyboard Input From a Canvas": "fr/tips#tabindex", - "Make the Canvas Transparent": "fr/tips#transparent-canvas", - "Use three.js as Background in HTML": "fr/tips#html-background" - }, - "Optimisation": { - "Optimizing Lots of Objects": "fr/optimize-lots-of-objects", - "Optimizing Lots of Objects Animated": "fr/optimize-lots-of-objects-animated", - "Using OffscreenCanvas in a Web Worker": "fr/offscreencanvas" - }, - "Solutions": { - "Load an .OBJ file": "fr/load-obj", - "Load a .GLTF file": "fr/load-gltf", - "Add a Background or Skybox": "fr/backgrounds", - "How to Draw Transparent Objects": "fr/transparency", - "Multiple Canvases, Multiple Scenes": "fr/multiple-scenes", - "Picking Objects with the mouse": "fr/picking", - "Post Processing": "fr/post-processing", - "Using Shadertoy shaders": "fr/shadertoy", - "Aligning HTML Elements to 3D": "fr/align-html-elements-to-3d", - "Using Indexed Textures for Picking and Color": "fr/indexed-textures", - "Using A Canvas for Dynamic Textures": "fr/canvas-textures", - "Billboards and Facades": "fr/billboards", - "Freeing Resources": "fr/cleanup", - "Making Voxel Geometry (Minecraft)": "fr/voxel-geometry", - "Start making a Game": "fr/game" - }, - "WebXR": { - "VR - Basics": "fr/webxr-basics", - "VR - Look To Select": "fr/webxr-look-to-select", - "VR - Point To Select": "fr/webxr-point-to-select" - }, - "Référence": { - "Material Table": "fr/material-table" - } + "Next Steps": { + "Animation System": "animation-system", + "Color Management": "color-management", + "How to create VR content": "how-to-create-vr-content", + "How to dispose of Objects": "how-to-dispose-of-objects", + "How to update Things": "how-to-update-things", + "How to use Post Processing": "how-to-use-post-processing", + "Matrix Transformations": "matrix-transformations" }, - "ja": { - "基本": { - "基礎知識": "ja/fundamentals", - "レスポンシブデザイン": "ja/responsive", - "前提条件": "ja/prerequisites", - "セットアップ": "ja/setup" - }, - "基礎": { - "プリミティブ": "ja/primitives", - "シーングラフ": "ja/scenegraph", - "マテリアル": "ja/materials", - "テクスチャ": "ja/textures", - "ライト": "ja/lights", - "カメラ": "ja/cameras", - "シャドウ": "ja/shadows", - "フォグ": "ja/fog", - "レンダーターゲット": "ja/rendertargets", - "カスタムバッファジオメトリ": "ja/custom-buffergeometry" - }, - "Tips": { - "要求されたレンダリング": "ja/rendering-on-demand", - "JavaScriptのデバッグ": "ja/debugging-javascript", - "GLSLのデバッグ": "ja/debugging-glsl", - "スクリーンショットを撮る": "ja/tips#screenshot", - "キャンバスがクリアされるのを防ぐ": "ja/tips#preservedrawingbuffer", - "キャンバスからキーボード入力を取得する": "ja/tips#tabindex", - "キャンバスを透明にする": "ja/tips#transparent-canvas", - "HTMLの背景にthree.jsを使う": "ja/tips#html-background" - }, - "最適化": { - "多くのオブジェクトを最適化": "ja/optimize-lots-of-objects", - "アニメーションする多くのオブジェクトを最適化": "ja/optimize-lots-of-objects-animated", - "Web WorkerでOffscreenCanvasを使用する": "ja/offscreencanvas" - }, - "解決策": { - "OBJファイルの読み込み": "ja/load-obj", - "GLTFファイルの読み込み": "ja/load-gltf", - "背景やスカイボックスを追加する": "ja/backgrounds", - "透明なオブジェクトの描画方法": "ja/transparency", - "複数キャンバスと複数シーン": "ja/multiple-scenes", - "マウスでオブジェクトをピッキング": "ja/picking", - "ポストプロセス": "ja/post-processing", - "Shadertoyのシェーダーを使う": "ja/shadertoy", - "HTML要素を3Dに揃える": "ja/align-html-elements-to-3d", - "圧縮テクスチャのピッキングとカラー": "ja/indexed-textures", - "動的なテクスチャのキャンバスを使用する": "ja/canvas-textures", - "Billboards and Facades": "ja/billboards", - "Freeing Resources": "ja/cleanup", - "Making Voxel Geometry (Minecraft)": "ja/voxel-geometry", - "Start making a Game": "ja/game" - }, - "WebXR": { - "VR - Basics": "ja/webxr-basics", - "VR - Look To Select": "ja/webxr-look-to-select", - "VR - Point To Select": "ja/webxr-point-to-select" - }, - "参照": { - "Material Table": "ja/material-table" - } + "---": {}, + "Basics": { + "Fundamentals": "fundamentals", + "Responsive Design": "responsive", + "Prerequisites": "prerequisites", + "Setup": "setup" }, - "ko": { - "Three.js란?": { - "Three.js란?": "ko/fundamentals", - "반응형 디자인": "ko/responsive", - "먼저 알아야 할 것들": "ko/prerequisites", - "개발 환경": "ko/setup" - }, - "기본 구조": { - "원시 모델(Primitives)": "ko/primitives", - "씬 그래프(Scenegraph)": "ko/scenegraph", - "재질(Materials)": "ko/materials", - "텍스처(Textures)": "ko/textures", - "조명(Lights)": "ko/lights", - "카메라(Cameras)": "ko/cameras", - "그림자(Shadows)": "ko/shadows", - "안개(Fog)": "ko/fog", - "렌더 타겟(Render Targets)": "ko/rendertargets", - "사용자 지정 BufferGeometry": "ko/custom-buffergeometry" - }, - "팁": { - "불필요한 렌더링 없애기": "ko/rendering-on-demand", - "자바스크립트 디버깅": "ko/debugging-javascript", - "GLSL 디버깅": "ko/debugging-glsl", - "스크린샷 찍기": "ko/tips#screenshot", - "캔버스 초기화 방지하기": "ko/tips#preservedrawingbuffer", - "캔버스에서 키 입력 받기": "ko/tips#tabindex", - "캔버스를 투명하게 만들기": "ko/tips#transparent-canvas", - "Three.js를 HTML 요소의 배경으로 사용하기": "ko/tips#html-background" - }, - "최적화하기": { - "요소가 많을 때 최적화하는 방법": "ko/optimize-lots-of-objects", - "애니메이션 요소가 많을 때 최적화하는 방법": "ko/optimize-lots-of-objects-animated", - "웹 워커에서 OffscreenCanvas 사용하기": "ko/offscreencanvas" - }, - "활용하기": { - ".OBJ 파일 불러오기": "ko/load-obj", - ".GLTF 파일 불러오기": "ko/load-gltf", - "배경, 하늘 상자 추가하기": "ko/backgrounds", - "물체의 투명도 설정하기": "ko/transparency", - "다중 캔버스, 다중 장면 만들기": "ko/multiple-scenes", - "물체를 마우스로 피킹하기": "ko/picking", - "후처리": "ko/post-processing", - "쉐이더토이 쉐이더 활용하기": "ko/shadertoy", - "HTML 요소를 3D로 정렬하기": "ko/align-html-elements-to-3d", - "피킹과 색상에 인덱스 텍스처 사용하기": "ko/indexed-textures", - "캔버스로 동적 텍스처 만들기": "ko/canvas-textures", - "빌보드와 파사드": "ko/billboards", - "메모리 해제하기": "ko/cleanup", - "복셀 Geometry(마인크래프트) 만들기": "ko/voxel-geometry", - "게임 만들기": "ko/game" - }, - "웹VR": { - "VR - 기본 사항": "ko/webxr-basics", - "VR - Look To Select": "ko/webxr-look-to-select", - "VR - Point To Select": "ko/webxr-point-to-select" - }, - "레퍼런스": { - "재질(Material) 속성표": "ko/material-table" - } + "Fundamentals": { + "Primitives": "primitives", + "Scenegraph": "scenegraph", + "Materials": "materials", + "Textures": "textures", + "Lights": "lights", + "Cameras": "cameras", + "Shadows": "shadows", + "Fog": "fog", + "Render Targets": "rendertargets", + "Custom BufferGeometry": "custom-buffergeometry", + "Physics": "physics" }, - "ru": { - "Введение": { - "Базовые принципы": "ru/fundamentals", - "Адаптивный дизайн": "ru/responsive", - "Необходимые условия": "ru/prerequisites", - "Настройка": "ru/setup" - }, - "Фунаментальные понятия": { - "Примитивы": "ru/primitives", - "Граф сцены": "ru/scenegraph", - "Материалы": "ru/materials", - "Текстуры": "ru/textures", - "Освещение": "ru/lights", - "Камера": "ru/cameras", - "Тени": "ru/shadows", - "Туман": "ru/fog", - "Цели рендеринга": "ru/rendertargets", - "Пользовательская BufferGeometry": "ru/custom-buffergeometry" - }, - "Советы": { - "Рендеринг по требованию": "ru/rendering-on-demand", - "Отладка JavaScript": "ru/debugging-javascript", - "Отладка GLSL": "ru/debugging-glsl", - "Делаем скриншот холста": "ru/tips#screenshot", - "Предотвращение очистки холста ": "ru/tips#preservedrawingbuffer", - "Ввод с клавиатуры": "ru/tips#tabindex", - "Делаем холст прозрачным ": "ru/tips#transparent-canvas", - "Создание анимированного фона в three.js ": "ru/tips#html-background" - }, - "Оптимизация": { - "Оптимизация большого количества объектов": "ru/optimize-lots-of-objects", - "Оптимизация множества анимированных объектов": "ru/optimize-lots-of-objects-animated", - "Использование OffscreenCanvas в воркере": "ru/offscreencanvas" - }, - "Решения": { - "Load an .OBJ file": "ru/load-obj", - "Load a .GLTF file": "ru/load-gltf", - "Add a Background or Skybox": "ru/backgrounds", - "How to Draw Transparent Objects": "ru/transparency", - "Несколько холстов, несколько сцен": "ru/multiple-scenes", - "Picking Objects with the mouse": "ru/picking", - "Post Processing": "ru/post-processing", - "Using Shadertoy shaders": "ru/shadertoy", - "Aligning HTML Elements to 3D": "ru/align-html-elements-to-3d", - "Using Indexed Textures for Picking and Color": "ru/indexed-textures", - "Using A Canvas for Dynamic Textures": "ru/canvas-textures", - "Billboards and Facades": "ru/billboards", - "Freeing Resources": "ru/cleanup", - "Making Voxel Geometry (Minecraft)": "ru/voxel-geometry", - "Start making a Game": "ru/game" - }, - "WebXR": { - "VR - Basics": "ru/webxr-basics", - "VR - Look To Select": "ru/webxr-look-to-select", - "VR - Point To Select": "ru/webxr-point-to-select" - }, - "Ссылки": { - "Таблица материалов": "ru/material-table" - } + "Tips": { + "Rendering On Demand": "rendering-on-demand", + "Debugging JavaScript": "debugging-javascript", + "Debugging GLSL": "debugging-glsl", + "Taking a screenshot": "tips#screenshot", + "Prevent the Canvas Being Cleared": "tips#preservedrawingbuffer", + "Get Keyboard Input From a Canvas": "tips#tabindex", + "Make the Canvas Transparent": "tips#transparent-canvas", + "Use three.js as Background in HTML": "tips#html-background" }, - "zh": { - "入门": { - "安装": "zh/installation", - "创建场景": "zh/creating-a-scene", - "创建文本": "zh/creating-text", - "绘制线条": "zh/drawing-lines", - "常见问题": "zh/faq", - "库和插件": "zh/libraries-and-plugins", - "加载3D模型": "zh/loading-3d-models", - "Uniform类型": "zh/uniform-types", - "相关资源": "zh/useful-links", - "WebGL兼容性检查": "zh/webgl-compatibility-check" - }, - "进阶": { - "动画系统": "zh/animation-system", - "颜色管理": "zh/color-management", - "如何创建VR内容": "zh/how-to-create-vr-content", - "如何释放对象": "zh/how-to-dispose-of-objects", - "如何更新对象": "zh/how-to-update-things", - "如何使用后处理": "zh/how-to-use-post-processing", - "矩阵变换": "zh/matrix-transformations" - }, - "---": {}, - "基本": { - "基础": "zh/fundamentals", - "响应式设计": "zh/responsive", - "先决条件": "zh/prerequisites", - "设置": "zh/setup" - }, - "基础": { - "图元": "zh/primitives", - "场景图": "zh/scenegraph", - "材质": "zh/materials", - "纹理": "zh/textures", - "光照": "zh/lights", - "摄像机": "zh/cameras", - "阴影": "zh/shadows", - "雾": "zh/fog", - "渲染目标": "zh/rendertargets", - "自定义缓冲几何体": "zh/custom-buffergeometry", - "物理": "zh/physics" - }, - "技巧": { - "按需渲染": "zh/rendering-on-demand", - "调试JavaScript": "zh/debugging-javascript", - "调试着色器": "zh/debugging-glsl", - "canvas截图": "zh/tips#screenshot", - "防止canvas被清空": "zh/tips#preservedrawingbuffer", - "从canvas获取键盘输入": "zh/tips#tabindex", - "透明化canvas": "zh/tips#transparent-canvas", - "在HTML中使用three.js动画背景": "zh/tips#html-background" - }, - "优化": { - "大量对象的优化": "zh/optimize-lots-of-objects", - "优化对象的同时保持动画效果": "zh/optimize-lots-of-objects-animated", - "在Web Worker中使用离屏渲染": "zh/offscreencanvas" - }, - "解决方案": { - "加载 .OBJ 文件": "zh/load-obj", - "加载 .GLTF 文件": "zh/load-gltf", - "添加背景或天空盒": "zh/backgrounds", - "如何绘制透明的物体": "zh/transparency", - "多个画布, 多个场景": "zh/multiple-scenes", - "鼠标选取对象": "zh/picking", - "后期处理": "zh/post-processing", - "使用Shadertoy中的着色器": "zh/shadertoy", - "对齐HTML元素到3D对象": "zh/align-html-elements-to-3d", - "使用纹理索引来拾取和着色": "zh/indexed-textures", - "使用Canvas生成动态纹理": "zh/canvas-textures", - "广告牌(Billboards)": "zh/billboards", - "释放资源": "zh/cleanup", - "体素几何体 (Minecraft)": "zh/voxel-geometry", - "来试试做一个游戏吧": "zh/game" - }, - "WebGPU": { - "WebGPU渲染器": "zh/webgpurenderer", - "后处理": "zh/webgpu-postprocessing" - }, - "WebXR": { - "VR - 基础": "zh/webxr-basics", - "VR - 用目光进行选择": "zh/webxr-look-to-select", - "VR - 用点进行选择": "zh/webxr-point-to-select" - }, - "参考": { - "材质特性表": "zh/material-table" - } + "Optimization": { + "Optimizing Lots of Objects": "optimize-lots-of-objects", + "Optimizing Lots of Objects Animated": "optimize-lots-of-objects-animated", + "Using OffscreenCanvas in a Web Worker": "offscreencanvas" + }, + "Solutions": { + "Load an .OBJ file": "load-obj", + "Load a .GLTF file": "load-gltf", + "Add a Background or Skybox": "backgrounds", + "How to Draw Transparent Objects": "transparency", + "Multiple Canvases, Multiple Scenes": "multiple-scenes", + "Picking Objects with the mouse": "picking", + "Post Processing": "post-processing", + "Using Shadertoy shaders": "shadertoy", + "Aligning HTML Elements to 3D": "align-html-elements-to-3d", + "Using Indexed Textures for Picking and Color": "indexed-textures", + "Using A Canvas for Dynamic Textures": "canvas-textures", + "Billboards and Facades": "billboards", + "Freeing Resources": "cleanup", + "Making Voxel Geometry (Minecraft)": "voxel-geometry", + "Start making a Game": "game" + }, + "WebGPU": { + "WebGPURenderer": "webgpurenderer", + "Post-Processing": "webgpu-postprocessing" + }, + "WebXR": { + "VR - Basics": "webxr-basics", + "VR - Look To Select": "webxr-look-to-select", + "VR - Point To Select": "webxr-point-to-select" + }, + "Reference": { + "Material Table": "material-table" } -} \ No newline at end of file +} diff --git a/manual/en/align-html-elements-to-3d.html b/manual/pages/align-html-elements-to-3d.html similarity index 100% rename from manual/en/align-html-elements-to-3d.html rename to manual/pages/align-html-elements-to-3d.html diff --git a/manual/en/animation-system.html b/manual/pages/animation-system.html similarity index 100% rename from manual/en/animation-system.html rename to manual/pages/animation-system.html diff --git a/manual/en/backgrounds.html b/manual/pages/backgrounds.html similarity index 100% rename from manual/en/backgrounds.html rename to manual/pages/backgrounds.html diff --git a/manual/en/billboards.html b/manual/pages/billboards.html similarity index 100% rename from manual/en/billboards.html rename to manual/pages/billboards.html diff --git a/manual/en/cameras.html b/manual/pages/cameras.html similarity index 100% rename from manual/en/cameras.html rename to manual/pages/cameras.html diff --git a/manual/en/canvas-textures.html b/manual/pages/canvas-textures.html similarity index 100% rename from manual/en/canvas-textures.html rename to manual/pages/canvas-textures.html diff --git a/manual/en/cleanup.html b/manual/pages/cleanup.html similarity index 100% rename from manual/en/cleanup.html rename to manual/pages/cleanup.html diff --git a/manual/en/color-management.html b/manual/pages/color-management.html similarity index 100% rename from manual/en/color-management.html rename to manual/pages/color-management.html diff --git a/manual/en/creating-a-scene.html b/manual/pages/creating-a-scene.html similarity index 100% rename from manual/en/creating-a-scene.html rename to manual/pages/creating-a-scene.html diff --git a/manual/en/creating-text.html b/manual/pages/creating-text.html similarity index 100% rename from manual/en/creating-text.html rename to manual/pages/creating-text.html diff --git a/manual/en/custom-buffergeometry.html b/manual/pages/custom-buffergeometry.html similarity index 100% rename from manual/en/custom-buffergeometry.html rename to manual/pages/custom-buffergeometry.html diff --git a/manual/en/debugging-glsl.html b/manual/pages/debugging-glsl.html similarity index 100% rename from manual/en/debugging-glsl.html rename to manual/pages/debugging-glsl.html diff --git a/manual/en/debugging-javascript.html b/manual/pages/debugging-javascript.html similarity index 100% rename from manual/en/debugging-javascript.html rename to manual/pages/debugging-javascript.html diff --git a/manual/en/drawing-lines.html b/manual/pages/drawing-lines.html similarity index 100% rename from manual/en/drawing-lines.html rename to manual/pages/drawing-lines.html diff --git a/manual/en/faq.html b/manual/pages/faq.html similarity index 100% rename from manual/en/faq.html rename to manual/pages/faq.html diff --git a/manual/en/fog.html b/manual/pages/fog.html similarity index 100% rename from manual/en/fog.html rename to manual/pages/fog.html diff --git a/manual/en/fundamentals.html b/manual/pages/fundamentals.html similarity index 100% rename from manual/en/fundamentals.html rename to manual/pages/fundamentals.html diff --git a/manual/en/game.html b/manual/pages/game.html similarity index 100% rename from manual/en/game.html rename to manual/pages/game.html diff --git a/manual/en/how-to-create-vr-content.html b/manual/pages/how-to-create-vr-content.html similarity index 100% rename from manual/en/how-to-create-vr-content.html rename to manual/pages/how-to-create-vr-content.html diff --git a/manual/en/how-to-dispose-of-objects.html b/manual/pages/how-to-dispose-of-objects.html similarity index 100% rename from manual/en/how-to-dispose-of-objects.html rename to manual/pages/how-to-dispose-of-objects.html diff --git a/manual/en/how-to-update-things.html b/manual/pages/how-to-update-things.html similarity index 100% rename from manual/en/how-to-update-things.html rename to manual/pages/how-to-update-things.html diff --git a/manual/en/how-to-use-post-processing.html b/manual/pages/how-to-use-post-processing.html similarity index 100% rename from manual/en/how-to-use-post-processing.html rename to manual/pages/how-to-use-post-processing.html diff --git a/manual/en/indexed-textures.html b/manual/pages/indexed-textures.html similarity index 100% rename from manual/en/indexed-textures.html rename to manual/pages/indexed-textures.html diff --git a/manual/en/installation.html b/manual/pages/installation.html similarity index 100% rename from manual/en/installation.html rename to manual/pages/installation.html diff --git a/manual/en/libraries-and-plugins.html b/manual/pages/libraries-and-plugins.html similarity index 100% rename from manual/en/libraries-and-plugins.html rename to manual/pages/libraries-and-plugins.html diff --git a/manual/en/lights.html b/manual/pages/lights.html similarity index 100% rename from manual/en/lights.html rename to manual/pages/lights.html diff --git a/manual/en/load-gltf.html b/manual/pages/load-gltf.html similarity index 100% rename from manual/en/load-gltf.html rename to manual/pages/load-gltf.html diff --git a/manual/en/load-obj.html b/manual/pages/load-obj.html similarity index 100% rename from manual/en/load-obj.html rename to manual/pages/load-obj.html diff --git a/manual/en/loading-3d-models.html b/manual/pages/loading-3d-models.html similarity index 100% rename from manual/en/loading-3d-models.html rename to manual/pages/loading-3d-models.html diff --git a/manual/en/material-table.html b/manual/pages/material-table.html similarity index 100% rename from manual/en/material-table.html rename to manual/pages/material-table.html diff --git a/manual/en/materials.html b/manual/pages/materials.html similarity index 100% rename from manual/en/materials.html rename to manual/pages/materials.html diff --git a/manual/en/matrix-transformations.html b/manual/pages/matrix-transformations.html similarity index 100% rename from manual/en/matrix-transformations.html rename to manual/pages/matrix-transformations.html diff --git a/manual/en/multiple-scenes.html b/manual/pages/multiple-scenes.html similarity index 100% rename from manual/en/multiple-scenes.html rename to manual/pages/multiple-scenes.html diff --git a/manual/en/offscreencanvas.html b/manual/pages/offscreencanvas.html similarity index 100% rename from manual/en/offscreencanvas.html rename to manual/pages/offscreencanvas.html diff --git a/manual/en/optimize-lots-of-objects-animated.html b/manual/pages/optimize-lots-of-objects-animated.html similarity index 100% rename from manual/en/optimize-lots-of-objects-animated.html rename to manual/pages/optimize-lots-of-objects-animated.html diff --git a/manual/en/optimize-lots-of-objects.html b/manual/pages/optimize-lots-of-objects.html similarity index 100% rename from manual/en/optimize-lots-of-objects.html rename to manual/pages/optimize-lots-of-objects.html diff --git a/manual/en/physics.html b/manual/pages/physics.html similarity index 100% rename from manual/en/physics.html rename to manual/pages/physics.html diff --git a/manual/en/picking.html b/manual/pages/picking.html similarity index 100% rename from manual/en/picking.html rename to manual/pages/picking.html diff --git a/manual/en/post-processing.html b/manual/pages/post-processing.html similarity index 100% rename from manual/en/post-processing.html rename to manual/pages/post-processing.html diff --git a/manual/en/prerequisites.html b/manual/pages/prerequisites.html similarity index 100% rename from manual/en/prerequisites.html rename to manual/pages/prerequisites.html diff --git a/manual/en/primitives.html b/manual/pages/primitives.html similarity index 100% rename from manual/en/primitives.html rename to manual/pages/primitives.html diff --git a/manual/en/rendering-on-demand.html b/manual/pages/rendering-on-demand.html similarity index 100% rename from manual/en/rendering-on-demand.html rename to manual/pages/rendering-on-demand.html diff --git a/manual/en/rendertargets.html b/manual/pages/rendertargets.html similarity index 100% rename from manual/en/rendertargets.html rename to manual/pages/rendertargets.html diff --git a/manual/en/responsive.html b/manual/pages/responsive.html similarity index 100% rename from manual/en/responsive.html rename to manual/pages/responsive.html diff --git a/manual/en/scenegraph.html b/manual/pages/scenegraph.html similarity index 100% rename from manual/en/scenegraph.html rename to manual/pages/scenegraph.html diff --git a/manual/en/setup.html b/manual/pages/setup.html similarity index 100% rename from manual/en/setup.html rename to manual/pages/setup.html diff --git a/manual/en/shadertoy.html b/manual/pages/shadertoy.html similarity index 100% rename from manual/en/shadertoy.html rename to manual/pages/shadertoy.html diff --git a/manual/en/shadows.html b/manual/pages/shadows.html similarity index 100% rename from manual/en/shadows.html rename to manual/pages/shadows.html diff --git a/manual/en/textures.html b/manual/pages/textures.html similarity index 100% rename from manual/en/textures.html rename to manual/pages/textures.html diff --git a/manual/en/tips.html b/manual/pages/tips.html similarity index 100% rename from manual/en/tips.html rename to manual/pages/tips.html diff --git a/manual/en/transparency.html b/manual/pages/transparency.html similarity index 100% rename from manual/en/transparency.html rename to manual/pages/transparency.html diff --git a/manual/en/uniform-types.html b/manual/pages/uniform-types.html similarity index 100% rename from manual/en/uniform-types.html rename to manual/pages/uniform-types.html diff --git a/manual/en/useful-links.html b/manual/pages/useful-links.html similarity index 98% rename from manual/en/useful-links.html rename to manual/pages/useful-links.html index 81c2ac42df31fa..7e176b0f2f1faf 100644 --- a/manual/en/useful-links.html +++ b/manual/pages/useful-links.html @@ -48,7 +48,7 @@

Tutorials and courses

Getting started with three.js

  • - [link:https://threejs.org/manual/#en/fundamentals Three.js Fundamentals starting lesson] + [link:https://threejs.org/manual/#fundamentals Three.js Fundamentals starting lesson]
  • [link:https://codepen.io/rachsmith/post/beginning-with-3d-webgl-pt-1-the-scene Beginning with 3D WebGL] by [link:https://codepen.io/rachsmith/ Rachel Smith]. diff --git a/manual/en/voxel-geometry.html b/manual/pages/voxel-geometry.html similarity index 100% rename from manual/en/voxel-geometry.html rename to manual/pages/voxel-geometry.html diff --git a/manual/en/webgl-compatibility-check.html b/manual/pages/webgl-compatibility-check.html similarity index 100% rename from manual/en/webgl-compatibility-check.html rename to manual/pages/webgl-compatibility-check.html diff --git a/manual/en/webgpu-postprocessing.html b/manual/pages/webgpu-postprocessing.html similarity index 100% rename from manual/en/webgpu-postprocessing.html rename to manual/pages/webgpu-postprocessing.html diff --git a/manual/en/webgpurenderer.html b/manual/pages/webgpurenderer.html similarity index 100% rename from manual/en/webgpurenderer.html rename to manual/pages/webgpurenderer.html diff --git a/manual/en/webxr-basics.html b/manual/pages/webxr-basics.html similarity index 100% rename from manual/en/webxr-basics.html rename to manual/pages/webxr-basics.html diff --git a/manual/en/webxr-look-to-select.html b/manual/pages/webxr-look-to-select.html similarity index 100% rename from manual/en/webxr-look-to-select.html rename to manual/pages/webxr-look-to-select.html diff --git a/manual/en/webxr-point-to-select.html b/manual/pages/webxr-point-to-select.html similarity index 100% rename from manual/en/webxr-point-to-select.html rename to manual/pages/webxr-point-to-select.html diff --git a/manual/resources/lesson.js b/manual/resources/lesson.js index 7a8824c5415ccc..5fe923f0337f1b 100644 --- a/manual/resources/lesson.js +++ b/manual/resources/lesson.js @@ -9,7 +9,7 @@ const re = /^(.*?\/manual\/)(.*?)$/; const [ , baseURL, articlePath ] = re.exec( window.location.href ); - const href = `${baseURL}#${articlePath.replace( '.html', '' )}`; + const href = `${baseURL}#${articlePath.replace( 'pages/', '' ).replace( '.html', '' )}`; window.location.replace( href ); // lgtm[js/client-side-unvalidated-url-redirection] } diff --git a/manual/resources/tools/geo-picking/README.md b/manual/resources/tools/geo-picking/README.md index d11e0ec9d11408..504aa5f801e63d 100644 --- a/manual/resources/tools/geo-picking/README.md +++ b/manual/resources/tools/geo-picking/README.md @@ -1,8 +1,8 @@ # Geo Picking Data These tools were used to generate data for both -[the article on aligning HTML elements to 3D](https://threejs.org/manual/en/align-html-elements-to-3d.html) -and [the article on using indexed textures for picking and color](https://threejs.org/manual/en/indexed-textures.html) +[the article on aligning HTML elements to 3D](https://threejs.org/manual/#align-html-elements-to-3d) +and [the article on using indexed textures for picking and color](https://threejs.org/manual/#indexed-textures) I'm not going to go into details on how they work but you can look inside and see them draw the images and extract the data. Maybe you can use them diff --git a/manual/ru/align-html-elements-to-3d.html b/manual/ru/align-html-elements-to-3d.html deleted file mode 100644 index c9a04614f8a5d2..00000000000000 --- a/manual/ru/align-html-elements-to-3d.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Aligning HTML Elements to 3D - - - - - - - - - - - - - -
    -
    -

    Aligning HTML Elements to 3D

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/backgrounds.html b/manual/ru/backgrounds.html deleted file mode 100644 index 4a15730714bb8d..00000000000000 --- a/manual/ru/backgrounds.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Backgrounds and Skyboxes - - - - - - - - - - - - - -
    -
    -

    Backgrounds and Skyboxes

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/billboards.html b/manual/ru/billboards.html deleted file mode 100644 index 2ddb73a49a68b3..00000000000000 --- a/manual/ru/billboards.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Billboards - - - - - - - - - - - - - -
    -
    -

    Billboards

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/cameras.html b/manual/ru/cameras.html deleted file mode 100644 index b0fe8e6ac18e03..00000000000000 --- a/manual/ru/cameras.html +++ /dev/null @@ -1,555 +0,0 @@ - - - - Камера - - - - - - - - - - - - - -
    -
    -

    - Камера

    -
    -
    -
    -

    Эта статья является частью серии статей о three.js. -Первая была об основах. -Если вы её еще не читали, советую вам сделать это.

    -

    Давайте поговорим о камерах в three.js. Мы рассмотрели некоторые из них в первой статье -, но мы расскажем здесь об этом более подробно.

    -

    Самая распространенная камера в Three.js и та, которую мы использовали до этого момента, - PerspectiveCamera. -Она дает трехмерный вид, где вещи на расстоянии кажутся меньше, чем вещи рядом.

    -

    PerspectiveCamera определяет frustum. Frustum - усеченная пирамида, твердое тело. -Под твердым телом я подразумеваю, например, куб, конус, сферу, -цилиндр и усеченный конус - все названия различных видов твердых тел.

    -
    -
    cube
    -
    cone
    -
    sphere
    -
    cylinder
    -
    frustum
    -
    - -

    Я только указываю на это, потому что я не знал это в течение многих лет. -Если в какой-нибудь книге или на веб странице будет упоминание frustum я закатывал глаза. -Понимание того, что это название сплошной формы, сделало эти описания внезапно более понятными 😅

    -

    A PerspectiveCameraопределяет свой frustum на основе 4 свойств. near определяет, -где начинается фронт усечения. far определяет, где он заканчивается. fovполе обзора -определяет высоту передней и задней частей усеченного конуса, вычисляя правильную высоту, -чтобы получить указанное поле обзора в near единицах измерения от камеры. aspect определяет, -насколько широким передние и задняя часть усеченного есть. Ширина усеченного конуса - -это просто высота, умноженная на aspect.

    -

    -

    Давайте используем сцену из предыдущей статьи которая имеет плоскость -земли, сферу и куб, и сделаем так, чтобы мы могли регулировать настройки камеры

    -

    Для этого мы сделаем MinMaxGUIHelper для параметров near и far, так чтобы far -всегда был больше, чем near. У него будут свойства min и max, которые lil-gui будет -настраивать. После настройки они установят 2 свойства, которые мы указываем.

    -
    class MinMaxGUIHelper {
    -  constructor(obj, minProp, maxProp, minDif) {
    -    this.obj = obj;
    -    this.minProp = minProp;
    -    this.maxProp = maxProp;
    -    this.minDif = minDif;
    -  }
    -  get min() {
    -    return this.obj[this.minProp];
    -  }
    -  set min(v) {
    -    this.obj[this.minProp] = v;
    -    this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
    -  }
    -  get max() {
    -    return this.obj[this.maxProp];
    -  }
    -  set max(v) {
    -    this.obj[this.maxProp] = v;
    -    this.min = this.min;  // это вызовет setter min
    -  }
    -}
    -
    -

    Теперь мы можем настроить наш графический интерфейс следующим образом

    -
    function updateCamera() {
    -  camera.updateProjectionMatrix();
    -}
    -
    -const gui = new GUI();
    -gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
    -const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
    -gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
    -gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
    -
    -

    Каждый раз, когда меняются настройки камеры, нам нужно вызывать функцию камеры -updateProjectionMatrix поэтому мы сделали -функцию updateCamera передав ее в lil-gui, чтобы вызывать ее, когда что-то меняется.

    -

    - -

    -

    Вы можете просто значения и посмотреть, как они работают. Обратите внимание, что мы не делали -aspect сеттер, так как aspect взят из размера окна, поэтому, если вы хотите настроить aspect, -откройте пример в новом окне, а затем измените размер окна.

    -

    Тем не менее, я думаю, что это немного трудно увидеть, поэтому давайте изменим пример, чтобы он имел 2 камеры. -Один покажет нашу сцену, как мы видим ее выше, другой покажет другую камеру, смотрящую на сцену, -которую рисует первая камера, и показывает frustum камеры.

    -

    Для этого мы можем использовать функцию ножниц (scissor) Three.js. Давайте изменим это, чтобы -нарисовать 2 сцены с 2 камерами рядом, используя функцию scissor

    -

    Для начала давайте используем HTML и CSS, чтобы определить 2 элемента рядом друг с другом. -Это также поможет нам с событиями, так что обе камеры могут иметь свои собственные OrbitControls.

    -
    <body>
    -  <canvas id="c"></canvas>
    -+  <div class="split">
    -+     <div id="view1" tabindex="1"></div>
    -+     <div id="view2" tabindex="2"></div>
    -+  </div>
    -</body>
    -
    -

    Для начала давайте используем HTML и CSS, чтобы расположить 2 элемента рядом друг с другом. -Это также поможет нам с событиями, так что обе камеры могут иметь свои собственные

    -
    .split {
    -  position: absolute;
    -  left: 0;
    -  top: 0;
    -  width: 100%;
    -  height: 100%;
    -  display: flex;
    -}
    -.split>div {
    -  width: 100%;
    -  height: 100%;
    -}
    -
    -

    Затем в нашем коде мы добавим CameraHelper. CameraHelper рисует frustum для Camera

    -
    const cameraHelper = new THREE.CameraHelper(camera);
    -
    -...
    -
    -scene.add(cameraHelper);
    -
    -

    Теперь давайте посмотрим на 2 элемента view.

    -
    const view1Elem = document.querySelector('#view1');
    -const view2Elem = document.querySelector('#view2');
    -
    -

    И мы установим нашу существующую OrbitControls так, чтобы она отвечала -только за первый элемент представления.

    -
    -const controls = new OrbitControls(camera, canvas);
    -+const controls = new OrbitControls(camera, view1Elem);
    -
    -

    Создадим вторую PerspectiveCamera и вторую OrbitControls. -Вторая OrbitControls привязана ко второй камере и получает -ввод от второго элемента view.

    -
    const camera2 = new THREE.PerspectiveCamera(
    -  60,  // fov
    -  2,   // aspect
    -  0.1, // near
    -  500, // far
    -);
    -camera2.position.set(40, 10, 30);
    -camera2.lookAt(0, 5, 0);
    -
    -const controls2 = new OrbitControls(camera2, view2Elem);
    -controls2.target.set(0, 5, 0);
    -controls2.update();
    -
    -

    Наконец, нам нужно визуализировать сцену с точки зрения каждой камеры, используя -функцию ножниц (scissor), чтобы визуализировать только часть холста.

    -

    Вот функция, которая для данного элемента будет вычислять прямоугольник этого -элемента, который перекрывает холст. Затем он установит плоскость отсечения (scissor) и область -просмотра (fov) в этот прямоугольник и вернет aspect для этого размера.

    -
    function setScissorForElement(elem) {
    -  const canvasRect = canvas.getBoundingClientRect();
    -  const elemRect = elem.getBoundingClientRect();
    -
    -  // вычисляем относительный прямоугольник холста
    -  const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
    -  const left = Math.max(0, elemRect.left - canvasRect.left);
    -  const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
    -  const top = Math.max(0, elemRect.top - canvasRect.top);
    -
    -  const width = Math.min(canvasRect.width, right - left);
    -  const height = Math.min(canvasRect.height, bottom - top);
    -
    -  //  установка области отсечения для рендеринга только на эту часть холста
    -  renderer.setScissor(left, top, width, height);
    -  renderer.setViewport(left, top, width, height);
    -
    -  // return aspect
    -  return width / height;
    -}
    -
    -

    И теперь мы можем использовать эту функцию, чтобы нарисовать сцену дважды в нашей функции render

    -
      function render() {
    -
    --    if (resizeRendererToDisplaySize(renderer)) {
    --      const canvas = renderer.domElement;
    --      camera.aspect = canvas.clientWidth / canvas.clientHeight;
    --      camera.updateProjectionMatrix();
    --    }
    -
    -+    resizeRendererToDisplaySize(renderer);
    -+
    -+    // включить область отсечения
    -+    renderer.setScissorTest(true);
    -+
    -+    // render the original view
    -+    {
    -+      const aspect = setScissorForElement(view1Elem);
    -+
    -+      // настроить камеру для этого соотношения сторон
    -+      camera.aspect = aspect;
    -+      camera.updateProjectionMatrix();
    -+      cameraHelper.update();
    -+
    -+      // не рисуем Helper камеры в исходном представлении
    -+      cameraHelper.visible = false;
    -+
    -+      scene.background.set(0x000000);
    -+
    -+      // отрисовка
    -+      renderer.render(scene, camera);
    -+    }
    -+
    -+    // отрисовка со 2-й камеры
    -+    {
    -+      const aspect = setScissorForElement(view2Elem);
    -+
    -+      // настроить камеру для этого соотношения сторон
    -+      camera2.aspect = aspect;
    -+      camera2.updateProjectionMatrix();
    -+
    -+      // рисуем Helper камеры во втором представлении
    -+      cameraHelper.visible = true;
    -+
    -+      scene.background.set(0x000040);
    -+
    -+      renderer.render(scene, camera2);
    -+    }
    -
    --    renderer.render(scene, camera);
    -
    -    requestAnimationFrame(render);
    -  }
    -
    -  requestAnimationFrame(render);
    -}
    -
    -

    Приведенный выше код устанавливает цвет фона сцены при рендеринге -второго представления темно-синим, чтобы было проще различать два представления.

    -

    Мы также можем удалить наш updateCamera код, так как мы обновляем все в функции render.

    -
    -function updateCamera() {
    --  camera.updateProjectionMatrix();
    --}
    -
    -const gui = new GUI();
    --gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
    -+gui.add(camera, 'fov', 1, 180);
    -const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
    --gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
    --gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
    -+gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near');
    -+gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far');
    -
    -

    И теперь вы можете использовать один вид, чтобы увидеть frustum другого.

    -

    - -

    -

    Слева вы можете увидеть исходный вид, а справа вы можете увидеть вид, -показывающий frustum камеры слева. Можно настроить -near, far, fov и перемещать камеру с помощью мыши. Вы можете увидеть, -как то, что внутри frustum, показаное справа, появляется на сцене слева.

    -

    Отрегулируйте near примерно до 20, и вы легко увидите, как передние -объекты исчезают, поскольку их больше нет в усеченном конусе. -Отрегулируйте far ниже примерно 35, и вы начнете видеть, -что наземная плоскость исчезает, поскольку она больше не находится -в не усеченной области.

    -

    Возникает вопрос, почему бы просто не установить near значение 0,0000000001 и far -10000000000000 или что-то в этом роде, чтобы вы могли видеть все? Причина в том, что -ваш GPU имеет столько точности, чтобы решить, находится ли что-то впереди или -позади чего-то другого. Эта точность распределена между -near и far. Хуже того, по умолчанию точность закрытия камеры детализирована (резкое отсечение), -а точность далеко от камеры - конечна. near медленно расширяется по мере приближения far.

    -

    Начиная с верхнего примера, давайте изменим код, вставив 20 сфер в ряд.

    -
    {
    -  const sphereRadius = 3;
    -  const sphereWidthDivisions = 32;
    -  const sphereHeightDivisions = 16;
    -  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
    -  const numSpheres = 20;
    -  for (let i = 0; i < numSpheres; ++i) {
    -    const sphereMat = new THREE.MeshPhongMaterial();
    -    sphereMat.color.setHSL(i * .73, 1, 0.5);
    -    const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    -    mesh.position.set(-sphereRadius - 1, sphereRadius + 2, i * sphereRadius * -2.2);
    -    scene.add(mesh);
    -  }
    -}
    -
    -

    и давайте установим near = 0.00001

    -
    const fov = 45;
    -const aspect = 2;  // the canvas default
    --const near = 0.1;
    -+const near = 0.00001;
    -const far = 100;
    -const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -
    -

    Нам также нужно немного подправить код графического интерфейса, -чтобы позволить 0.00001, если значение редактируется

    -
    -gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
    -+gui.add(minMaxGUIHelper, 'min', 0.00001, 50, 0.00001).name('near').onChange(updateCamera);
    -
    -

    Как ты думаешь, что произойдет?

    -

    - -

    -

    Это пример z fighting (сшивание), когда графический процессор на вашем компьютере не обладает -достаточной точностью, чтобы определить, какие пиксели находятся спереди, а какие - сзади.

    -

    На тот случай, если проблема не отображается на вашей машине, вот что я вижу на своей машине

    -
    - -

    Одно из решений состоит в том, чтобы указать использование three.js другому методу для вычисления того, -какие пиксели находятся спереди, а какие - сзади. Мы можем сделать это, включив, -logarithmicDepthBuffer когда мы создаем WebGLRenderer

    -
    -const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -+const renderer = new THREE.WebGLRenderer({
    -+  antialias: true,
    -+  canvas,
    -+  logarithmicDepthBuffer: true,
    -+});
    -
    -

    и с этим это может работать

    -

    - -

    -

    Если это не помогло решить проблему, вы столкнулись с одной из причин, по которой -вы не всегда можете использовать это решение. Причина в том, что это поддерживают -только определенные графические процессоры. По состоянию на сентябрь 2018 года -практически ни одно мобильное устройство не поддерживает это решение, как это -делают большинство настольных компьютеров.

    -

    Другая причина не выбирать это решение - оно может быть значительно медленнее, -чем стандартное решение.

    -

    Даже при таком решении разрешение все еще ограничено. Сделайте near еще меньше или -far больше, и вы в конечном итоге столкнетесь с теми же проблемами.

    -

    Это означает, что вы всегда должны прилагать усилия к тому, чтобы выбрать параметр nearfar, которые соответствуют вашему варианту использования. -Установите near как можно дальше от камеры, чтобы все не исчезло. -Установите far как можно ближе к камере, чтобы все не исчезло. Если вы пытаетесь -нарисовать гигантскую сцену и показать крупным планом чье-то лицо, чтобы вы -могли видеть их ресницы, в то время как на заднем плане вы можете видеть весь -путь в горы на расстоянии 50 километров, тогда вам нужно будет найти другое -креативные решения, которые, возможно, мы рассмотрим позже. На данный момент, -просто знайте, что вы должны позаботиться о том, чтобы выбрать подходящие -near и far для ваших нужд.

    -

    2-ая ​​самая распространенная камера - OrthographicCamera. Вместо того, -чтобы указать frustum он указывает прямоугольный паралелепипед (box) -с параметрами left, right, top, bottom, near, и far. -Поскольку он проецирует box, перспективы нет.

    -

    Давайте изменим приведенный выше пример 2 для использования OrthographicCamera -в первом представлении.

    -

    Сначала давайте настроим OrthographicCamera.

    -
    const left = -1;
    -const right = 1;
    -const top = 1;
    -const bottom = -1;
    -const near = 5;
    -const far = 50;
    -const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
    -camera.zoom = 0.2;
    -
    -

    Мы устанавливаем left и bottom = -1 и right и top = 1. Это сделало бы -прямоугольник шириной 2 единицы и высотой 2 единицы, но мы собираемся отрегулировать left и top -в соответствии со отношением сторон прямоугольника, к которому мы рисуем. -Мы будем использовать свойство zoom, чтобы упростить настройку количества единиц, отображаемых камерой.

    -

    Давайте добавим настройки GUI для zoom

    -
    const gui = new GUI();
    -+gui.add(camera, 'zoom', 0.01, 1, 0.01).listen();
    -
    -

    Вызовем listen говорящий lil-gui следить за изменениями. -Потому что OrbitControls также может управлять масштабированием. -Например, колесо прокрутки на мыши будет масштабироваться с помощью OrbitControls.

    -

    Наконец, нам просто нужно изменить часть, которая отображает левую сторону, -чтобы обновить OrthographicCamera.

    -
    {
    -  const aspect = setScissorForElement(view1Elem);
    -
    -  // обновить камеру для этого соотношения сторон
    --  camera.aspect = aspect;
    -+  camera.left   = -aspect;
    -+  camera.right  =  aspect;
    -  camera.updateProjectionMatrix();
    -  cameraHelper.update();
    -
    -  // не рисуем Helper камеры в исходном view
    -  cameraHelper.visible = false;
    -
    -  scene.background.set(0x000000);
    -  renderer.render(scene, camera);
    -}
    -
    -

    и теперь вы можете увидеть OrthographicCamera в работе.

    -

    - -

    -

    OrthographicCamera чаще всего используется для рисования 2D-объектов. -Вы решаете, сколько единиц вы хотите, чтобы камера показывала. Например, -если вы хотите, чтобы один пиксель холста соответствовал одному элементу -камеры, вы можете сделать что-то вроде:

    -

    Поместить начало координат в центр и иметь 1 пиксель = 1 единицу three.js что-то вроде:

    -
    camera.left = -canvas.width / 2;
    -camera.right = canvas.width / 2;
    -camera.top = canvas.heigth / 2;
    -camera.bottom = -canvas.height / 2;
    -camera.near = -1;
    -camera.far = 1;
    -camera.zoom = 1;
    -
    -

    Или, если бы мы хотели, чтобы источник находился в верхнем левом углу, -как 2D-холст, мы могли бы использовать это

    -
    camera.left = 0;
    -camera.right = canvas.width;
    -camera.top = 0;
    -camera.bottom = canvas.height;
    -camera.near = -1;
    -camera.far = 1;
    -camera.zoom = 1;
    -
    -

    В этом случае верхний левый угол будет 0,0, как 2D холст

    -

    Давай попробуем! Сначала давайте настроим камеру

    -
    const left = 0;
    -const right = 300;  // default canvas size
    -const top = 0;
    -const bottom = 150;  // default canvas size
    -const near = -1;
    -const far = 1;
    -const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
    -camera.zoom = 1;
    -
    -

    Затем давайте загрузим 6 текстур и сделаем 6 плоскостей, по одной на каждую текстуру. -Мы будем привязывать каждую плоскость к THREE.Object3D чтобы было легче сместить плоскость, -чтобы ее центр находился в ее верхнем левом углу.

    -
    const loader = new THREE.TextureLoader();
    -const textures = [
    -  loader.load('../resources/images/flower-1.jpg'),
    -  loader.load('../resources/images/flower-2.jpg'),
    -  loader.load('../resources/images/flower-3.jpg'),
    -  loader.load('../resources/images/flower-4.jpg'),
    -  loader.load('../resources/images/flower-5.jpg'),
    -  loader.load('../resources/images/flower-6.jpg'),
    -];
    -const planeSize = 256;
    -const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    -const planes = textures.map((texture) => {
    -  const planePivot = new THREE.Object3D();
    -  scene.add(planePivot);
    -  texture.magFilter = THREE.NearestFilter;
    -  const planeMat = new THREE.MeshBasicMaterial({
    -    map: texture,
    -    side: THREE.DoubleSide,
    -  });
    -  const mesh = new THREE.Mesh(planeGeo, planeMat);
    -  planePivot.add(mesh);
    -  // move plane so top left corner is origin
    -  mesh.position.set(planeSize / 2, planeSize / 2, 0);
    -  return planePivot;
    -});
    -
    -

    и нам нужно обновить камеру, если размер холста изменится.

    -
    function render() {
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    camera.right = canvas.width;
    -    camera.bottom = canvas.height;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -  ...
    -
    -

    planes - массив THREE.Mesh, по одному для каждой плоскости. -Давайте переместим их в зависимости от времени.

    -
    function render(time) {
    -  time *= 0.001;  // конвертировать в секунды;
    -
    -  ...
    -
    -  const distAcross = Math.max(20, canvas.width - planeSize);
    -  const distDown = Math.max(20, canvas.height - planeSize);
    -
    -  // total distance to move across and back
    -  const xRange = distAcross * 2;
    -  const yRange = distDown * 2;
    -  const speed = 180;
    -
    -  planes.forEach((plane, ndx) => {
    -    // compute a unique time for each plane
    -    const t = time * speed + ndx * 300;
    -
    -    // get a value between 0 and range
    -    const xt = t % xRange;
    -    const yt = t % yRange;
    -
    -    // set our position going forward if 0 to half of range
    -    // and backward if half of range to range
    -    const x = xt < distAcross ? xt : xRange - xt;
    -    const y = yt < distDown   ? yt : yRange - yt;
    -
    -    plane.position.set(x, y, 0);
    -  });
    -
    -  renderer.render(scene, camera);
    -
    -

    И вы можете видеть, как изображения отскакивают от пикселей идеально по краям холста, -используя пиксельную математику, как 2D холст

    -

    - -

    -

    Другое распространенное использование OrthographicCamera для рисования - это отображение вверх, -вниз, влево, вправо, спереди, сзади программ трехмерного моделирования или редактора игрового движка.

    -
    - -

    На скриншоте выше вы можете видеть 1 вид в перспективе и 3 вида в ортогональном виде.

    -

    Это основы камер. Мы рассмотрим несколько распространенных способов перемещения камер в других статьях. -А пока давайте перейдем к теням.

    -

    - - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/canvas-textures.html b/manual/ru/canvas-textures.html deleted file mode 100644 index b4ec263ca24c6d..00000000000000 --- a/manual/ru/canvas-textures.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Canvas Textures - - - - - - - - - - - - - -
    -
    -

    Canvas Textures

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/cleanup.html b/manual/ru/cleanup.html deleted file mode 100644 index 9bd753f85e5780..00000000000000 --- a/manual/ru/cleanup.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Cleanup - - - - - - - - - - - - - -
    -
    -

    Cleanup

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/custom-buffergeometry.html b/manual/ru/custom-buffergeometry.html deleted file mode 100644 index 863df54850638b..00000000000000 --- a/manual/ru/custom-buffergeometry.html +++ /dev/null @@ -1,429 +0,0 @@ - - - Пользовательская BufferGeometry - - - - - - - - - - - - - -
    -
    -

    Пользовательская BufferGeometry

    -
    -
    -
    -

    - -

    BufferGeometry с другой стороны использует названный BufferAttributes. -Каждый атрибут BufferAttribute представляет собой массив данных одного типа: позиции, нормали, цвета и ультрафиолетовые лучи. -Вместе добавленные атрибуты BufferAttributes представляют параллельные массивы всех данных для каждой вершины.

    -
    - -

    Вы можете видеть, что у нас есть 4 атрибута: position, normal, color, uv. -Они представляют параллельные массивы, что означает, что N-й набор данных в каждом атрибуте принадлежит одной и той же вершине. -Вершина с индексом = 4 подсвечивается, чтобы показать, что параллельные данные по всем атрибутам определяют одну вершину.

    -

    Это поднимает точку, вот схема куба с одним выделенным углом.

    -
    - -

    Думая об этом, один угол нуждается в разной нормали для каждой грани куба. -Для каждой стороны тоже нужны разные ультрафиолеты. Это указывает на самую большую разницу между Geometry и BufferGeometry. Ничего общего с BufferGeometry. -Одна вершина - это комбинация всех ее частей. Если вершина нуждается в какой-либо части, то она должна быть другой.

    -

    Правда в том, что когда вы используете Geometry three.js преобразует его в этот формат. -Вот откуда появляется дополнительная память и время при использовании Geometry. -Дополнительная память для всех объектов Vector3s, Vector2s, Face3s и массива, а затем дополнительное время для преобразования всех этих данных -в параллельные массивы в форме атрибутов BufferAttributes, как указано выше. -Иногда это облегчает использование Geometry. С BufferGeometry мы можем предоставить данные, уже преобразованные в этот формат.

    -

    В качестве простого примера давайте сделаем куб, используя BufferGeometry. -Куб интересен тем, что кажется, что он разделяет вершины в углах, но на самом деле это не так. В нашем примере мы перечислим все вершины со всеми их данными, -а затем преобразуем эти данные в параллельные массивы и, наконец, используем их для создания атрибутов Buffer и добавления их в BufferGeometry.

    -

    Начиная с примера координат текстуры из предыдущей статьи, мы удалили весь код, связанный с настройкой Geometry. -Затем мы перечисляем все данные, необходимые для куба. Помните еще раз, что если вершина имеет какие-либо уникальные части, она должна быть отдельной вершиной. -Для создания куба необходимо 36 вершин. 2 треугольника на грань, 3 вершины на треугольник, 6 граней = 36 вершин.

    -
    const vertices = [
    -  // front
    -  { pos: [-1, -1,  1], norm: [ 0,  0,  1], uv: [0, 0], },
    -  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
    -  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
    -
    -  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
    -  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
    -  { pos: [ 1,  1,  1], norm: [ 0,  0,  1], uv: [1, 1], },
    -  // right
    -  { pos: [ 1, -1,  1], norm: [ 1,  0,  0], uv: [0, 0], },
    -  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
    -  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
    -
    -  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
    -  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
    -  { pos: [ 1,  1, -1], norm: [ 1,  0,  0], uv: [1, 1], },
    -  // back
    -  { pos: [ 1, -1, -1], norm: [ 0,  0, -1], uv: [0, 0], },
    -  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
    -  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
    -
    -  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
    -  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
    -  { pos: [-1,  1, -1], norm: [ 0,  0, -1], uv: [1, 1], },
    -  // left
    -  { pos: [-1, -1, -1], norm: [-1,  0,  0], uv: [0, 0], },
    -  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
    -  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
    -
    -  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
    -  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
    -  { pos: [-1,  1,  1], norm: [-1,  0,  0], uv: [1, 1], },
    -  // top
    -  { pos: [ 1,  1, -1], norm: [ 0,  1,  0], uv: [0, 0], },
    -  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
    -  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
    -
    -  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
    -  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
    -  { pos: [-1,  1,  1], norm: [ 0,  1,  0], uv: [1, 1], },
    -  // bottom
    -  { pos: [ 1, -1,  1], norm: [ 0, -1,  0], uv: [0, 0], },
    -  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
    -  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
    -
    -  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
    -  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
    -  { pos: [-1, -1, -1], norm: [ 0, -1,  0], uv: [1, 1], },
    -];
    -
    -

    Затем мы можем перевести все это в 3 параллельных массива

    -
    const positions = [];
    -const normals = [];
    -const uvs = [];
    -for (const vertex of vertices) {
    -  positions.push(...vertex.pos);
    -  normals.push(...vertex.norm);
    -  uvs.push(...vertex.uv);
    -}
    -
    -

    Наконец, мы можем создать BufferGeometry, а затем BufferAttribute для каждого массива и добавить его в BufferGeometry.

    -
      const geometry = new THREE.BufferGeometry();
    -  const positionNumComponents = 3;
    -  const normalNumComponents = 3;
    -  const uvNumComponents = 2;
    -  geometry.setAttribute(
    -      'position',
    -      new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
    -  geometry.setAttribute(
    -      'normal',
    -      new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
    -  geometry.setAttribute(
    -      'uv',
    -      new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
    -
    -

    Обратите внимание, что имена являются значительными. Вы должны назвать свои атрибуты именами, которые соответствуют ожиданиям three.js -(если вы не создаете пользовательский шейдер). В этом случае position, normal и uv. Если вы хотите цвета вершин, назовите свой атрибут color.

    -

    Выше мы создали 3 собственных массива JavaScript, positions, normals и uvs . Затем мы конвертируем их в -TypedArrays -типа Float32Array. Атрибут BufferAttribute требует TypedArray, а не собственного массива. Атрибут BufferAttribute также требует, чтобы вы указали, -сколько компонентов в каждой вершине. Для позиций и нормалей у нас есть 3 компонента на вершину, x, y и z. Для UV у нас есть 2, u и v.

    -

    - -

    -

    Это много данных. Небольшая вещь, которую мы можем сделать, это использовать индексы для ссылки на вершины. -Оглядываясь назад на данные нашего куба, каждая грань состоит из 2 треугольников с 3 вершинами в каждом, всего 6 вершин, но 2 из этих вершин абсолютно одинаковы; -Та же самая position, та же самая normal, и та же самая uv. -Таким образом, мы можем удалить совпадающие вершины и затем ссылаться на них по индексу. Сначала мы удаляем совпадающие вершины.

    -
    const vertices = [
    -  // front
    -  { pos: [-1, -1,  1], norm: [ 0,  0,  1], uv: [0, 0], }, // 0
    -  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], }, // 1
    -  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], }, // 2
    --
    --  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
    --  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
    -  { pos: [ 1,  1,  1], norm: [ 0,  0,  1], uv: [1, 1], }, // 3
    -  // right
    -  { pos: [ 1, -1,  1], norm: [ 1,  0,  0], uv: [0, 0], }, // 4
    -  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], }, // 5
    --
    --  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
    --  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
    -  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], }, // 6
    -  { pos: [ 1,  1, -1], norm: [ 1,  0,  0], uv: [1, 1], }, // 7
    -  // back
    -  { pos: [ 1, -1, -1], norm: [ 0,  0, -1], uv: [0, 0], }, // 8
    -  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], }, // 9
    --
    --  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
    --  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
    -  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], }, // 10
    -  { pos: [-1,  1, -1], norm: [ 0,  0, -1], uv: [1, 1], }, // 11
    -  // left
    -  { pos: [-1, -1, -1], norm: [-1,  0,  0], uv: [0, 0], }, // 12
    -  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], }, // 13
    --
    --  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
    --  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
    -  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], }, // 14
    -  { pos: [-1,  1,  1], norm: [-1,  0,  0], uv: [1, 1], }, // 15
    -  // top
    -  { pos: [ 1,  1, -1], norm: [ 0,  1,  0], uv: [0, 0], }, // 16
    -  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], }, // 17
    --
    --  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
    --  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
    -  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], }, // 18
    -  { pos: [-1,  1,  1], norm: [ 0,  1,  0], uv: [1, 1], }, // 19
    -  // bottom
    -  { pos: [ 1, -1,  1], norm: [ 0, -1,  0], uv: [0, 0], }, // 20
    -  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], }, // 21
    --
    --  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
    --  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
    -  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], }, // 22
    -  { pos: [-1, -1, -1], norm: [ 0, -1,  0], uv: [1, 1], }, // 23
    -];
    -
    -

    Итак, теперь у нас есть 24 уникальные вершины. -Затем мы указываем 36 индексов для 36 вершин, которые нам нужно нарисовать, чтобы сделать 12 треугольников, вызывая BufferGeometry.setIndex с массивом индексов.

    -
    geometry.setAttribute(
    -    'position',
    -    new THREE.BufferAttribute(positions, positionNumComponents));
    -geometry.setAttribute(
    -    'normal',
    -    new THREE.BufferAttribute(normals, normalNumComponents));
    -geometry.setAttribute(
    -    'uv',
    -    new THREE.BufferAttribute(uvs, uvNumComponents));
    -
    -+geometry.setIndex([
    -+   0,  1,  2,   2,  1,  3,  // front
    -+   4,  5,  6,   6,  5,  7,  // right
    -+   8,  9, 10,  10,  9, 11,  // back
    -+  12, 13, 14,  14, 13, 15,  // left
    -+  16, 17, 18,  18, 17, 19,  // top
    -+  20, 21, 22,  22, 21, 23,  // bottom
    -+]);
    -
    -

    - -

    -

    Как и в Geometry, в BufferGeometry есть метод computeVertexNormals для вычисления нормалей, -если вы их не предоставляете. В отличие от версии Geometry той же функции, -поскольку позиции не могут быть общими, если любая другая часть вершины отличается, результаты вызова computeVertexNormals будут другими.

    -
    -
    -
    -
    BufferGeometry
    -
    -
    -
    -
    Geometry
    -
    -
    - -

    Вот 2 цилиндра, где нормали были созданы с использованием computeVertexNormals. -Если вы посмотрите внимательно, на левом цилиндре есть шов. Это связано с тем, -что нет возможности совместно использовать вершины в начале и конце цилиндра, так как они требуют разных UV. -Просто небольшая вещь, чтобы быть в курсе. -Решение состоит в том, чтобы предоставить свои собственные normals.

    -

    Мы также можем использовать TypedArrays -с самого начала вместо собственных массивов JavaScript. Недостатком TypedArrays является то, что вы должны указать их размер заранее. -Конечно, это не так уж сложно, но с помощью собственных массивов мы можем просто push значения в них и посмотреть, -какого размера они заканчиваются, проверив их length в конце. -В TypedArrays нет функции push, поэтому нам нужно вести собственную бухгалтерию при добавлении значений к ним.

    -

    В этом примере узнать длину заранее довольно просто, так как для начала мы используем большой блок статических данных.

    -
    -const positions = [];
    --const normals = [];
    --const uvs = [];
    -+const numVertices = vertices.length;
    -+const positionNumComponents = 3;
    -+const normalNumComponents = 3;
    -+const uvNumComponents = 2;
    -+const positions = new Float32Array(numVertices * positionNumComponents);
    -+const normals = new Float32Array(numVertices * normalNumComponents);
    -+const uvs = new Float32Array(numVertices * uvNumComponents);
    -+let posNdx = 0;
    -+let nrmNdx = 0;
    -+let uvNdx = 0;
    -for (const vertex of vertices) {
    --  positions.push(...vertex.pos);
    --  normals.push(...vertex.norm);
    --  uvs.push(...vertex.uv);
    -+  positions.set(vertex.pos, posNdx);
    -+  normals.set(vertex.norm, nrmNdx);
    -+  uvs.set(vertex.uv, uvNdx);
    -+  posNdx += positionNumComponents;
    -+  nrmNdx += normalNumComponents;
    -+  uvNdx += uvNumComponents;
    -}
    -
    -geometry.setAttribute(
    -    'position',
    --    new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
    -+    new THREE.BufferAttribute(positions, positionNumComponents));
    -geometry.setAttribute(
    -    'normal',
    --    new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
    -+    new THREE.BufferAttribute(normals, normalNumComponents));
    -geometry.setAttribute(
    -    'uv',
    --    new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
    -+    new THREE.BufferAttribute(uvs, uvNumComponents));
    -
    -geometry.setIndex([
    -   0,  1,  2,   2,  1,  3,  // front
    -   4,  5,  6,   6,  5,  7,  // right
    -   8,  9, 10,  10,  9, 11,  // back
    -  12, 13, 14,  14, 13, 15,  // left
    -  16, 17, 18,  18, 17, 19,  // top
    -  20, 21, 22,  22, 21, 23,  // bottom
    -]);
    -
    -

    - -

    -

    Хорошая причина использовать typedarrays - если вы хотите динамически обновлять любую часть вершин.

    -

    Я не мог придумать действительно хороший пример динамического обновления вершин, -поэтому я решил создать сферу и переместить каждый четырехугольник внутрь и наружу от центра. Надеюсь, это полезный пример.

    -

    Вот код для генерации позиций и индексов для сферы. Код разделяет вершины внутри четырехугольника, -но не разделяет вершины между четырьмя, потому что мы хотим иметь возможность перемещать каждый четырёхугольник по отдельности.

    -

    Поскольку я ленивый, я использовал небольшую иерархию из 3 объектов Object3D для вычисления точек сферы. Как это работает, объясняется в -статье об оптимизации множества объектов. .

    -
    function makeSpherePositions(segmentsAround, segmentsDown) {
    -  const numVertices = segmentsAround * segmentsDown * 6;
    -  const numComponents = 3;
    -  const positions = new Float32Array(numVertices * numComponents);
    -  const indices = [];
    -
    -  const longHelper = new THREE.Object3D();
    -  const latHelper = new THREE.Object3D();
    -  const pointHelper = new THREE.Object3D();
    -  longHelper.add(latHelper);
    -  latHelper.add(pointHelper);
    -  pointHelper.position.z = 1;
    -  const temp = new THREE.Vector3();
    -
    -  function getPoint(lat, long) {
    -    latHelper.rotation.x = lat;
    -    longHelper.rotation.y = long;
    -    longHelper.updateMatrixWorld(true);
    -    return pointHelper.getWorldPosition(temp).toArray();
    -  }
    -
    -  let posNdx = 0;
    -  let ndx = 0;
    -  for (let down = 0; down < segmentsDown; ++down) {
    -    const v0 = down / segmentsDown;
    -    const v1 = (down + 1) / segmentsDown;
    -    const lat0 = (v0 - 0.5) * Math.PI;
    -    const lat1 = (v1 - 0.5) * Math.PI;
    -
    -    for (let across = 0; across < segmentsAround; ++across) {
    -      const u0 = across / segmentsAround;
    -      const u1 = (across + 1) / segmentsAround;
    -      const long0 = u0 * Math.PI * 2;
    -      const long1 = u1 * Math.PI * 2;
    -
    -      positions.set(getPoint(lat0, long0), posNdx);  posNdx += numComponents;
    -      positions.set(getPoint(lat1, long0), posNdx);  posNdx += numComponents;
    -      positions.set(getPoint(lat0, long1), posNdx);  posNdx += numComponents;
    -      positions.set(getPoint(lat1, long1), posNdx);  posNdx += numComponents;
    -
    -      indices.push(
    -        ndx, ndx + 1, ndx + 2,
    -        ndx + 2, ndx + 1, ndx + 3,
    -      );
    -      ndx += 4;
    -    }
    -  }
    -  return {positions, indices};
    -}
    -
    -

    Затем мы можем вызвать это так

    -
    const segmentsAround = 24;
    -const segmentsDown = 16;
    -const {positions, indices} = makeSpherePositions(segmentsAround, segmentsDown);
    -
    -

    Поскольку возвращаемые позиции являются позициями единичных сфер, -они являются точно такими же значениями, которые нам нужны для нормалей, поэтому мы можем просто дублировать их для нормалей.

    -
    const normals = positions.slice();
    -
    -

    И тогда мы устанавливаем атрибуты, как раньше

    -
    const geometry = new THREE.BufferGeometry();
    -const positionNumComponents = 3;
    -const normalNumComponents = 3;
    -
    -+const positionAttribute = new THREE.BufferAttribute(positions, positionNumComponents);
    -+positionAttribute.setUsage(THREE.DynamicDrawUsage);
    -geometry.setAttribute(
    -    'position',
    -+    positionAttribute);
    -geometry.setAttribute(
    -    'normal',
    -    new THREE.BufferAttribute(normals, normalNumComponents));
    -geometry.setIndex(indices);
    -
    -

    Я выделил несколько различий. Мы сохраняем ссылку на атрибут позиции. -Мы также отмечаем его как динамический. Это намек на THREE.js, что мы будем часто менять содержимое атрибута.

    -

    В нашем цикле рендеринга мы обновляем позиции на основе их нормалей каждый кадр.

    -
    const temp = new THREE.Vector3();
    -
    -...
    -
    -for (let i = 0; i < positions.length; i += 3) {
    -  const quad = (i / 12 | 0);
    -  const ringId = quad / segmentsAround | 0;
    -  const ringQuadId = quad % segmentsAround;
    -  const ringU = ringQuadId / segmentsAround;
    -  const angle = ringU * Math.PI * 2;
    -  temp.fromArray(normals, i);
    -  temp.multiplyScalar(THREE.MathUtils.lerp(1, 1.4, Math.sin(time + ringId + angle) * .5 + .5));
    -  temp.toArray(positions, i);
    -}
    -positionAttribute.needsUpdate = true;
    -
    -

    И мы устанавливаем positionAttribute.needsUpdate, чтобы THREE.js указывал использовать наши изменения.

    -

    - -

    -

    Я надеюсь, что это были полезные примеры того, как использовать -BufferGeometry напрямую для создания собственной геометрии и как динамически -обновлять содержимое BufferAttribute. То, что вы используете, Geometry или BufferGeometry, -действительно зависит от ваших потребностей.

    -

    - - - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/debugging-glsl.html b/manual/ru/debugging-glsl.html deleted file mode 100644 index 4ad443e02ecdda..00000000000000 --- a/manual/ru/debugging-glsl.html +++ /dev/null @@ -1,100 +0,0 @@ - - - Отладка - GLSL - - - - - - - - - - - - - -
    -
    -

    Отладка - GLSL

    -
    -
    -
    -

    Этот сайт пока не учит GLSL так же, как не учит JavaScript. Это действительно большие темы. Если вы хотите изучать GLSL, рассмотрите -эти статьи в качестве отправной точки.

    -

    Если вы уже знаете GLSL, вот несколько советов по отладке.

    -

    Когда я делаю новый шейдер GLSL, и вообще ничего не появляется, первое, что я делаю, -это изменяю фрагментный шейдер, чтобы он возвращал сплошной цвет. Например, в самом низу шейдера я мог бы поставить

    -
    void main() {
    -
    -  ...
    -
    -  gl_FragColor = vec4(1, 0, 0, 1);  // red
    -}
    -
    -

    Если я вижу объект, который пытался нарисовать, то я знаю, что проблема связана с моим фрагментным шейдером. -Это может быть что-то вроде плохих текстур, неинициализированной униформы, -униформы с неправильными значениями, но, по крайней мере, у меня есть направление, чтобы посмотреть.

    -

    Чтобы проверить некоторые из них, я мог бы начать пытаться сделать некоторые из входных данных. -Например, если я использую нормали в фрагментном шейдере, я мог бы добавить

    -
    gl_FragColor = vec4(vNormal * 0.5 + 0.5, 1);
    -
    -

    Нормали переходят от -1 к +1, поэтому, умножив на 0,5 и добавив 0,5, -мы получим значения, которые идут от 0,0 до 1,0, что делает их полезными для цветов.

    -

    Попробуйте это с некоторыми вещами, которые, как вы знаете, работают, и вы начнете понимать, как обычно выглядят нормали. -Если ваши нормали не выглядят нормально, то у вас есть ключ к пониманию, где искать. -Если вы манипулируете нормалями в шейдере фрагментов, вы можете использовать ту же технику, чтобы нарисовать результат этой манипуляции.

    -
    - -

    Точно так же, если мы используем текстуры, будут координаты текстуры, и мы можем нарисовать их чем-то вроде

    -
    gl_FragColor = vec4(fract(vUv), 0, 1);
    -
    -

    fract есть в случае, если мы используем текстурные координаты, которые выходят за пределы диапазона от 0 до 1. -Это часто встречается, если для texture.repeat установлено значение больше 1.

    -
    - -

    Вы можете делать подобные вещи для всех значений в вашем фрагментном шейдере. -Выясните, каким может быть их диапазон, добавьте код для установки gl_FragColor с диапазоном от 0,0 до 1,0.

    -

    Чтобы проверить текстуры, попробуйте CanvasTexture или DataTexture, которые, как вы знаете, работают.

    -

    И наоборот, если после установки gl_FragColor на красный я все еще ничего не вижу, -у меня есть подсказка, моя проблема может быть в направлении вещей, связанных с вершинным шейдером. -Некоторые матрицы могут быть неправильными, или мои атрибуты могут содержать неверные данные или неправильно настроены.

    -

    Я бы сначала посмотрел на матрицы. Я мог бы поставить точку останова сразу после моего вызова renderer.render(scene, camera), -а затем начать расширять объекты в инспекторе. Матрица мира и проекционная матрица камеры, по крайней мере, не полны NaN? -Расширяя сцену и глядя на ее children, я бы проверил, что мировые матрицы выглядят разумно (без NaN) и последние 4 значения каждой матрицы выглядят разумно для моей сцены. -Если я ожидаю, что моя сцена будет иметь размеры 50x50x50 единиц, а некоторые матрицы показывают 552352623.123, то явно что-то не так.

    -
    - -

    Как и в случае с фрагментным шейдером, мы также можем рисовать значения из вершинного шейдера, передавая их фрагментному шейдеру. -Объявите переменные в обоих и передайте значение, которое вы не уверены, правильно. Фактически, если мой шейдер использует нормали, -я изменю фрагментный шейдер так, чтобы он отображался так, как указано выше, а затем просто установите для vNormal значение, которое я хочу отобразить, -но масштабировать, чтобы значения изменялись от 0,0 до 1,0. Затем я смотрю на результаты и проверяю, соответствуют ли они моим ожиданиям.

    -

    Еще одна полезная вещь - использовать более простой шейдер. -Можете ли вы нарисовать свои данные с помощью MeshBasicMaterial? -Если вы можете тогда попробуйте и убедитесь, что он показывает, как ожидалось.

    -

    Если нет, то какой самый простой вершинный шейдер, который позволит вам визуализировать вашу геометрию? Обычно это так же просто, как

    -
    gl_Position = projection * modelView * vec4(position.xyz, 1);
    -
    -

    Если это работает, начните добавлять изменения постепенно.

    -

    Еще одна вещь, которую вы можете сделать, это использовать расширение -Shader Editor для Chrome -или аналогичное для других браузеров. Это отличный способ посмотреть, как работают другие шейдеры. Это также хорошо, -поскольку вы можете внести некоторые из предложенных выше изменений во время работы кода.

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/debugging-javascript.html b/manual/ru/debugging-javascript.html deleted file mode 100644 index 1a5b12999f0962..00000000000000 --- a/manual/ru/debugging-javascript.html +++ /dev/null @@ -1,446 +0,0 @@ - - - Отладка JavaScript - - - - - - - - - - - - - -
    -
    -

    Отладка JavaScript

    -
    -
    -
    -

    Большая часть этой статьи посвящена не непосредственно -THREE.js, а скорее об отладке JavaScript в целом. -Мне показалось важным, что многие люди, начинающие с THREE.js, -также начинают с JavaScript, поэтому я надеюсь, что это поможет -им легче решать любые возникающие проблемы.

    -

    Отладка - большая тема, и я, вероятно, не смогу охватить все, что нужно знать, но если вы новичок в JavaScript, -вот несколько советов. Я настоятельно рекомендую вам потратить некоторое время на их изучение. Они очень помогут вам в вашем обучении.

    -

    Изучите инструменты разработчика вашего браузера

    -

    Все браузеры имеют инструменты разработчика. -Chrome, -Firefox, -Safari, -Edge.

    -

    В Chrome вы можете кликнуть значок , выбрать More Tools->Developer Tools -чтобы перейти к инструментам разработчика. Сочетание клавиш также показано там.

    -
    - -

    В Firefox вы кликаете значок , выбираете "Web Developer", затем выбираете -"Toggle Tools"

    -
    - -

    В Safari сначала необходимо включить меню «Разработка» в разделе -«Дополнительные настройки Safari».

    -
    - -

    Затем в меню «Разработка» вы можете выбрать «Показать / подключить веб-инспектора».

    -
    - -

    С Chrome вы также можете -использовать Chrome на своем компьютере для отладки веб-страниц, работающих на Chrome, на вашем телефоне или планшете Android. -Точно так же с Safari вы можете -использовать свой компьютер для отладки веб-страниц, работающих в Safari на iPhone и iPad.

    -

    Я наиболее знаком с Chrome, поэтому в этом руководстве будет использоваться Chrome в качестве примера при обращении к инструментам, -но большинство браузеров имеют схожие функции, поэтому здесь должно быть легко применить что-либо для всех браузеров.

    -

    Выключить кеш

    -

    Браузеры пытаются повторно использовать уже загруженные данные. Это очень удобно для пользователей, -поэтому, если вы заходите на веб-сайт во второй раз, многие файлы, используемые для отображения сайта, больше не будут загружаться.

    -

    С другой стороны, это может быть плохо для веб-разработки. -Вы изменяете файл на своем компьютере, перезагружаете страницу -и не видите изменений, потому что браузер использует версию, полученную в прошлый раз.

    -

    Сначала выберите настройки из углового меню

    -
    - -

    Затем выберите "Отключить кэш (пока открыт DevTools)".

    -
    - -

    Используйте консоль JavaScript

    -

    Внутри всех devtools есть консоль. Показывает предупреждения и сообщения об ошибках.

    -

    ЧИТАЙТЕ СООБЩЕНИЯ!!

    -

    Обычно должно быть только 1 или 2 сообщения.

    -
    - -

    Если вы видите какие-либо другие ПРОЧИТАЙТЕ ИХ. Например:

    -
    - -

    Я неправильно написал «three» как «threee»

    -

    Вы также можете распечатать свою собственную информацию на консоли с помощью console.log, как в

    -
    console.log(someObject.position.x, someObject.position.y, someObject.position.z);
    -
    -

    Даже круче, если вы регистрируете объект, вы можете инспектировать его. Например, если мы регистрируем объект корневой сцены из статьи gLTF

    -
      {
    -    const gltfLoader = new GLTFLoader();
    -    gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
    -      const root = gltf.scene;
    -      scene.add(root);
    -+      console.log(root);
    -
    -

    Затем мы можем развернуть этот объект в консоли JavaScript

    -
    - -

    Вы также можете использовать console.error, который сообщает, что сообщение красным цветом включает в себя трассировку стека.

    -

    Поместить данные на экран

    -

    Другой очевидный, но часто упускаемый из виду способ - добавить теги <div> или <pre> и поместить в них данные.

    -

    Самый очевидный способ - сделать некоторые элементы HTML

    -
    <canvas id="c"></canvas>
    -+<div id="debug">
    -+  <div>x:<span id="x"></span></div>
    -+  <div>y:<span id="y"></span></div>
    -+  <div>z:<span id="z"></span></div>
    -+</div>
    -
    -

    Разместите стиле вверху холста. (при условии, что ваш холст заполняет страницу)

    -
    <style>
    -#debug {
    -  position: absolute;
    -  left: 1em;
    -  top: 1em;
    -  padding: 1em;
    -  background: rgba(0, 0, 0, 0.8);
    -  color: white;
    -  font-family: monospace;
    -}
    -</style>
    -
    -

    А затем найдите элементы и настройте их содержимое.

    -
    // at init time
    -const xElem = document.querySelector('#x');
    -const yElem = document.querySelector('#y');
    -const zElem = document.querySelector('#z');
    -
    -// at render or update time
    -xElem.textContent = someObject.position.x.toFixed(3);
    -yElem.textContent = someObject.position.y.toFixed(3);
    -zElem.textContent = someObject.position.z.toFixed(3);
    -
    -

    Это более полезно для значений в реальном времени

    -

    - -

    -

    Еще один способ вывести данные на экран - это очистить регистратор. -Я только что придумал этот термин, но многие игры, над которыми я работал, -использовали это решение. Идея в том, что у вас есть буфер, который -отображает сообщения только для одного кадра. Любая часть вашего кода, которая хочет отображать данные, -вызывает некоторую функцию для добавления данных в этот буфер каждый кадр. -Это гораздо меньше работы, чем создание элемента на часть данных выше.

    -

    Например, давайте изменим HTML сверху только на это

    -
    <canvas id="c"></canvas>
    -<div id="debug">
    -  <pre></pre>
    -</div>
    -
    -

    И давайте создадим простой класс для управления этим буфером очистки.

    -
    class ClearingLogger {
    -  constructor(elem) {
    -    this.elem = elem;
    -    this.lines = [];
    -  }
    -  log(...args) {
    -    this.lines.push([...args].join(' '));
    -  }
    -  render() {
    -    this.elem.textContent = this.lines.join('\n');
    -    this.lines = [];
    -  }
    -}
    -
    -

    Тогда давайте сделаем простой пример, который каждый раз, когда мы щелкаем мышью, -создает сетку, которая движется в произвольном направлении в течение 2 секунд. -Мы начнем с одного из примеров из статьи о том, как сделать вещи отзывчивыми

    -

    Вот код, который добавляет новую сетку каждый раз, когда мы щелкаем мышью

    -
    const geometry = new THREE.SphereGeometry();
    -const material = new THREE.MeshBasicMaterial({color: 'red'});
    -
    -const things = [];
    -
    -function rand(min, max) {
    -  if (max === undefined) {
    -    max = min;
    -    min = 0;
    -  }
    -  return Math.random() * (max - min) + min;
    -}
    -
    -function createThing() {
    -  const mesh = new THREE.Mesh(geometry, material);
    -  scene.add(mesh);
    -  things.push({
    -    mesh,
    -    timer: 2,
    -    velocity: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5)),
    -  });
    -}
    -
    -canvas.addEventListener('click', createThing);
    -
    -

    А вот код, который перемещает созданные нами meshes, регистрирует их и удаляет их, когда у них заканчивается таймер

    -
    const logger = new ClearingLogger(document.querySelector('#debug pre'));
    -
    -let then = 0;
    -function render(now) {
    -  now *= 0.001;  // convert to seconds
    -  const deltaTime = now - then;
    -  then = now;
    -
    -  ...
    -
    -  logger.log('fps:', (1 / deltaTime).toFixed(1));
    -  logger.log('num things:', things.length);
    -  for (let i = 0; i < things.length;) {
    -    const thing = things[i];
    -    const mesh = thing.mesh;
    -    const pos = mesh.position;
    -    logger.log(
    -        'timer:', thing.timer.toFixed(3),
    -        'pos:', pos.x.toFixed(3), pos.y.toFixed(3), pos.z.toFixed(3));
    -    thing.timer -= deltaTime;
    -    if (thing.timer <= 0) {
    -      // remove this thing. Note we don't advance `i`
    -      things.splice(i, 1);
    -      scene.remove(mesh);
    -    } else {
    -      mesh.position.addScaledVector(thing.velocity, deltaTime);
    -      ++i;
    -    }
    -  }
    -
    -  renderer.render(scene, camera);
    -  logger.render();
    -
    -  requestAnimationFrame(render);
    -}
    -
    -

    Теперь кликните мышкой в примере ниже.

    -

    - -

    -

    Параметры запроса

    -

    Следует также помнить, что на веб-страницах могут передаваться данные либо через параметры запроса, либо через привязку, иногда называемую поиском и хэшем.

    -
    https://domain/path/?query#anchor
    -

    Вы можете использовать это, чтобы сделать функции необязательными или передать параметры.

    -

    Например, давайте возьмем предыдущий пример и сделаем так, чтобы материал отладки отображался только в том случае, если мы добавили ?debug=true в URL.

    -

    Сначала нам нужен код для разбора строки запроса

    -
    /**
    -  * Returns the query parameters as a key/value object.
    -  * Example: If the query parameters are
    -  *
    -  *    abc=123&def=456&name=gman
    -  *
    -  * Then `getQuery()` will return an object like
    -  *
    -  *    {
    -  *      abc: '123',
    -  *      def: '456',
    -  *      name: 'gman',
    -  *    }
    -  */
    -function getQuery() {
    -  return Object.fromEntries(new URLSearchParams(window.location.search).entries());
    -}
    -
    -

    Тогда мы можем сделать элемент отладки скрытым по умолчанию

    -
    <canvas id="c"></canvas>
    -+<div id="debug" style="display: none;">
    -  <pre></pre>
    -</div>
    -
    -

    Затем в коде мы читаем параметры и выбираем не скрывать отладочную информацию тогда и только тогда, когда передается ?debug=true

    -
    const query = getQuery();
    -const debug = query.debug === 'true';
    -const logger = debug
    -   ? new ClearingLogger(document.querySelector('#debug pre'))
    -   : new DummyLogger();
    -if (debug) {
    -  document.querySelector('#debug').style.display = '';
    -}
    -
    -

    Мы также создали DummyLogger, который ничего не делает, и решили использовать его, если ?debug=true не был передан.

    -
    class DummyLogger {
    -  log() {}
    -  render() {}
    -}
    -
    -

    Вы можете увидеть, если мы используем этот URL:

    -

    debug-js-params.html

    -

    отладочной информации нет, но если мы используем этот URL:

    -

    debug-js-params.html?debug=true

    -

    есть отладочная информация..

    -

    Несколько параметров можно передать, разделив их символом '&' как в somepage.html?someparam=somevalue&someotherparam=someothervalue. -Используя такие параметры, мы можем передавать все виды опций. Может быть speed=0.01 чтобы замедлить наше приложение для облегчения понимания чего-либо, или showHelpers=true -для того, чтобы добавлять или нет помощников, которые показывают свет, тень или усечение камеры, рассматриваемые в других уроках.

    -

    Научитесь пользоваться отладчиком

    -

    В каждом браузере есть отладчик, в котором вы можете останавливать -вашу программу шаг за шагом и проверять все переменные.

    -

    Обучение тому, как использовать отладчик - слишком большая тема для этой статьи, но вот несколько ссылок

    - -

    Проверьте NaN в отладчике или в другом месте

    -

    NaN это сокращение от Not A Number. -Это то, что JavaScript будет назначать в качестве значения, когда вы делаете что-то, что не имеет смысла математически.

    -

    В качестве простого примера

    -
    - - -

    Часто, когда я что-то делаю и на экране ничего не появляется, -я проверяю некоторые значения, и если я вижу NaN, имеет место быть, чтобы начать поиск.

    -

    В качестве примера, когда я впервые начал создавать путь для -статьи о загрузке файлов gLTF я сделал кривую, используя класс SplineCurve, который создает 2D-кривую.

    -

    Затем я использовал эту кривую, чтобы двигать автомобили

    -
    curve.getPointAt(zeroToOnePointOnCurve, car.position);
    -
    -

    Внутренне curve.getPointAt вызывает функцию set для объекта, переданного в качестве второго аргумента. -В этом случае вторым аргументом является car.position, который является Vector3. - Функция set в Vector3 требует 3 аргумента, x, y и z, но SplineCurve является 2D-кривой и поэтому вызывает car.position.set только с x и y.

    -

    В результате car.position.set устанавливает x в x, y в y, а z в undefined.

    -

    Беглый взгляд в отладчике matrixWorld автомобиля -показал множество значений NaN.

    -
    - -

    Видя, что в матрице присутствуют NaN можно предположить что-то вроде position, -rotation, scale или какой-либо другой функции, которая влияет на то, что в матрице содержались неверные данные. -Работая в обратном направлении, было легко отследить проблему.

    -

    В верхней части NaN есть также Infinity, что является аналогичным признаком того, что где-то есть математическая ошибка.

    -

    Смотри в коде!

    -

    THREE.js является открытым исходным кодом. Не бойтесь заглянуть внутрь кода! Вы можете заглянуть внутрь на github. -Вы также можете заглянуть внутрь, войдя в функции в отладчике. -Когда вы сделаете это, рассмотрите возможность использования three.js вместо более распространенного three.min.js. -three.min.js - уменьшенная, сжатая и поэтому уменьшенная для загрузки версия. three.js - более крупная, но более простая в отладке версия. -Я часто переключаю свой код на использование three.js, чтобы пройтись по коду и посмотреть, что происходит.

    -

    Поместите requestAnimationFrame внизу вашей функции рендеринга.

    -

    Я часто вижу эту закономерность

    -
    function render() {
    -   requestAnimationFrame(render);
    -
    -   // -- do stuff --
    -
    -   renderer.render(scene, camera);
    -}
    -requestAnimationFrame(render);
    -
    -

    Я бы предложил поместить вызов requestAnimationFrame внизу, как в

    -
    function render() {
    -   // -- do stuff --
    -
    -   renderer.render(scene, camera);
    -
    -   requestAnimationFrame(render);
    -}
    -requestAnimationFrame(render);
    -
    -

    Основная причина в том, что ваш код остановится – возникновение ошибки. -Помещение requestAnimationFrame вверху означает, что ваш код будет продолжать работать, -даже если у вас есть ошибка, так как вы уже запросили другой кадр. Лучше найти эти ошибки, чем игнорировать их. -Они могут быть причиной того, что что-то не появляется так, как вы ожидаете, но если ваш код не остановится, вы можете даже не заметить.

    -

    Проверьте свои единицы!

    -

    Это в основном означает понимание, например, когда использовать градусы, а когда использовать радианы. -К сожалению, THREE.js не использует везде одинаковые единицы измерения. Всё что в -поле зрении камеры - в градусах. Все остальные углы указаны в радианах.

    -

    Другое место, на которое стоит обратить внимание - ваши единицы измерения. -До недавнего времени 3D-приложения могли выбирать любую единицу измерения, который они хотели. -Одно приложение может выбрать 1 единицу = 1 см. Другой может выбрать 1 единицу = 1 фут. -На самом деле все еще верно, что вы можете выбрать любые единицы измерения для определенных приложений. -Тем не менее, THREE.js предполагает 1 единицу = 1 метр. Это важно для таких вещей, как физический рендеринг с использованием метров для вычисления световых эффектов. -Это также важно для AR и VR, которые должны иметь дело с реальными устройствами, такими как ваш телефон или как контроллеры VR.

    -

    Создание Minimal, Complete, Verifiable, Example для Stack Overflow

    -

    Если вы решите задать вопрос о THREE.js, -вам почти всегда необходимо предоставить MCVE, что означает «Минимальный, Полный, Проверяемый, Пример».

    -

    Минимальная часть важна. Допустим, у вас возникла проблема с перемещением пути в последнем примере загрузки статьи gLTF. -Этот пример имеет много частей. Включите их в список

    -
      -
    1. A bunch of HTML
    2. -
    3. Some CSS
    4. -
    5. Lights
    6. -
    7. Shadows
    8. -
    9. lil-gui code to manipulate shadows
    10. -
    11. Code to load a .GLTF file
    12. -
    13. Code to resize the canvas.
    14. -
    15. Code to move the cars along paths
    16. -
    -

    Это довольно много. Если ваш вопрос касается только пути, вы можете удалить большую часть HTML, -так как вам нужен только тег <canvas> и <script> для THREE.js. Вы можете удалить CSS и код изменения размера. -Вы можете удалить код .GLTF, потому что вам нужен только путь. -Вы можете удалить источники света и тени с помощью MeshBasicMaterial. -Вы, конечно, можете удалить код lil-gui. Код создает плоскость с текстурой. Было бы проще использовать GridHelper. -Наконец, если наш вопрос касается перемещения объектов по пути, -мы могли бы просто использовать кубы на пути вместо загруженных моделей автомобилей.

    -

    Вот более минимальный пример, учитывающий все вышеперечисленное. Он сократился с 271 строки до 135. -Мы могли бы подумать об уменьшении его еще больше, упрощая наш путь. Возможно, путь с 3 или 4 точками будет работать так же хорошо, как и наш путь с 21 точкой.

    -

    - -

    -

    Я сохранил OrbitController только потому, что полезно перемещать камеру и выяснять, что происходит, но в зависимости от вашей проблемы вы также можете удалить это.

    -

    Лучшее в создании MCVE - это то, что мы часто решаем нашу собственную проблему. Процесс удаления всего, что не нужно, и делая наименьший пример, мы решаем проблему, чем наоборот.

    -

    Вдобавок ко всему, с уважением относимся к тому времени, за которое люди будут смотреть на ваш код в Stack Overflow. -Делая минимальный пример, вы облегчаете им задачу. Вы также будете учиться в процессе.

    -

    Также важно, когда вы отправляете свой вопрос в Stack Overflow, поместите свой код во фрагмент. -Конечно, вы можете использовать JSFiddle, Codepen или аналогичный сайт для тестирования MCVE, но как только вы действительно отправите свой вопрос в Stack Overflow, -вам потребуется поместить код для воспроизведения вашей проблемы в сам вопрос. -Делая фрагмент, вы удовлетворяете это требование.

    -

    Также обратите внимание, что все живые примеры на этом сайте должны работать как фрагменты. Просто скопируйте части HTML, CSS и JavaScript в соответствующие части -редактора фрагментов. -Просто не забудьте попытаться удалить части, не относящиеся к вашей проблеме, и постарайтесь сделать свой код минимально необходимым.

    -

    Следуйте этим советам, и у вас будет гораздо больше шансов получить помощь по вашей проблеме.

    -

    Используйте MeshBasicMaterial

    -

    Поскольку MeshBasicMaterial не использует источники света, это один из способов устранения причин, по которым что-то может не отображаться. -Если ваши объекты отображаются с использованием MeshBasicMaterial, но не с какими-либо материалами, которые вы использовали, то вы знаете, -что проблема, скорее всего, связана с материалами или источниками света, а не с какой-либо другой частью кода.

    -

    Проверьте near и far настройки вашей камеры

    -

    PerspectiveCamera имеет ближние и дальние настройки, которые описаны в -статье о камерах. Убедитесь, что они установлены в соответствии с пространством, в котором находятся ваши объекты. -Возможно, даже просто временно установите для них что-то большое, например, near = 0,001 и far = 1000000. -Скорее всего, у вас возникнут проблемы с разрешением по глубине, но вы по крайней мере сможете увидеть ваши объекты, если они находятся перед камерой.

    -

    Проверьте вашу сцену перед камерой

    -

    Иногда вещи не появляются, потому что они не перед камерой.Если ваша камера не управляема попробуйте добавить управление камерой, -как OrbitController так что вы можете посмотреть вокруг и найти вашу сцену. Или, попробовать кадрирование сцены, используя код, который транслируется в этой статье. -Этот код находит размер части сцены, а затем перемещает камеру и регулирует near и far настройки, чтобы сделать его видимым. Затем можно заглянуть в -отладчик или добавить некоторые в console.log сообщения для печати размера и центра сцены.

    -

    Поставь что-нибудь перед сценой камеры

    -

    Это просто еще один способ сказать, если все остальное терпит неудачу начнем с того, что работает, а потом потихоньку будем добавлять код обратно. -Если вы получаете пустой экран, то попробуйте поместить что-то прямо перед камерой. Сделать сферу или поле, дать ему простой материал, -как MeshBasicMaterial и убедитесь, что вы можете сделать это на экране. -Затем начать добавлять код немного назад во времени и тестирования. -В конце концов вы либо найдёте вашу ошибку, или вы найдете пути её решения.

    -
    -

    Это были несколько советов для отладки JavaScript. Давайте перейдём к некоторым советам для отладки на GLSL.

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/fog.html b/manual/ru/fog.html deleted file mode 100644 index 4d4d0ddb643488..00000000000000 --- a/manual/ru/fog.html +++ /dev/null @@ -1,250 +0,0 @@ - - - Туман - - - - - - - - - - - - - -
    -
    -

    Туман

    -
    -
    -
    -

    Эта статья является частью серии статей о three.js. Первая статья - основы Three.js. -Если вы еще не читали их, и вы новичок в three.js, возможно, вы захотите начать с них. -Если вы еще не читали о камерах, вы можете начать с этой статьи.

    -

    Туман в 3D-движке - это, как правило, способ затухания до определенного цвета в зависимости от расстояния до камеры. -В three.js вы добавляете туман, создавая объект Fog или FogExp2 и устанавливая его в свойстве сцены -fog.

    -

    Fog позволяет выбирать near и far настройки, которые находятся на расстоянии от камеры. -Все, что near не подвержено влиянию тумана. -Все, что far - это цвет тумана. Части между near и far переходят от их материального цвета к цвету тумана.

    -

    Есть также FogExp2, который растет экспоненциально с расстоянием от камеры.

    -

    Чтобы использовать любой тип тумана, вы создаете его и назначаете его сцене, как в

    -
    const scene = new THREE.Scene();
    -{
    -  const color = 0xFFFFFF;  // white
    -  const near = 10;
    -  const far = 100;
    -  scene.fog = new THREE.Fog(color, near, far);
    -}
    -
    -

    или для FogExp2 это будет

    -
    const scene = new THREE.Scene();
    -{
    -  const color = 0xFFFFFF;
    -  const density = 0.1;
    -  scene.fog = new THREE.FogExp2(color, density);
    -}
    -
    -

    FogExp2 ближе к реальности, но Fog используется чаще, поскольку он позволяет вам выбрать место для нанесения тумана, -чтобы вы могли решить показать чистую сцену на определенном расстоянии, а затем исчезновение до некоторого цвета за этим расстоянием.

    -
    -
    -
    -
    THREE.Fog
    -
    -
    -
    -
    THREE.FogExp2
    -
    -
    - -

    Важно отметить, что туман применяется к вещам, которые отображаются. -Это часть расчета каждого пикселя цвета объекта. - Это означает, что если вы хотите, чтобы ваша сцена стала блеклой, вам нужно установить туман и цвет фона на один и тот же цвет. -Цвет фона устанавливается с помощью свойства scene.background. Чтобы выбрать цвет фона, вы прикрепляете к нему THREE.Color. Например

    -
    scene.background = new THREE.Color('#F00');  // red
    -
    -
    -
    -
    -
    fog blue, background red
    -
    -
    -
    -
    fog blue, background blue
    -
    -
    - -

    Вот один из наших предыдущих примеров с добавленным туманом. -Единственное добавление - сразу после настройки сцены мы добавляем туман и устанавливаем цвет фона сцены

    -
    const scene = new THREE.Scene();
    -
    -+{
    -+  const near = 1;
    -+  const far = 2;
    -+  const color = 'lightblue';
    -+  scene.fog = new THREE.Fog(color, near, far);
    -+  scene.background = new THREE.Color(color);
    -+}
    -
    -

    В приведенном ниже примере near камеры равен 0,1, а far - 5. Камера находится в точке z = 2. -Кубики имеют размер 1 и имеют значение z = 0. -Это означает, что при настройке тумана near = 1 и far = 2 кубики исчезнут прямо вокруг их центра.

    -

    - -

    -

    Давайте добавим интерфейс, чтобы мы могли настроить туман. Мы снова будем использовать -lil-gui. lil-gui принимает объект и свойство и автоматически создает интерфейс для этого типа свойства. -Мы могли бы просто позволить ему манипулировать свойствами near и far тумана, но недопустимо иметь near больше, чем far, поэтому давайте создадим помощник, -чтобы lil-gui мог манипулировать свойством near и far, -но мы убедимся, что near меньше или равно far и far больше или равно near.

    -
    // We use this class to pass to lil-gui
    -// so when it manipulates near or far
    -// near is never > far and far is never < near
    -class FogGUIHelper {
    -  constructor(fog) {
    -    this.fog = fog;
    -  }
    -  get near() {
    -    return this.fog.near;
    -  }
    -  set near(v) {
    -    this.fog.near = v;
    -    this.fog.far = Math.max(this.fog.far, v);
    -  }
    -  get far() {
    -    return this.fog.far;
    -  }
    -  set far(v) {
    -    this.fog.far = v;
    -    this.fog.near = Math.min(this.fog.near, v);
    -  }
    -}
    -
    -

    Затем мы можем добавить это так

    -
    {
    -  const near = 1;
    -  const far = 2;
    -  const color = 'lightblue';
    -  scene.fog = new THREE.Fog(color, near, far);
    -  scene.background = new THREE.Color(color);
    -+
    -+  const fogGUIHelper = new FogGUIHelper(scene.fog);
    -+  gui.add(fogGUIHelper, 'near', near, far).listen();
    -+  gui.add(fogGUIHelper, 'far', near, far).listen();
    -}
    -
    -

    Параметры near и far задают минимальные и максимальные значения для регулировки тумана. Они устанавливаются при настройке камеры.

    -

    .Listen () в конце последних 2 строк указывает lil-gui прослушивать -изменения. Таким образом, когда мы меняем near на far или мы меняем far на near lil-gui обновит интерфейс другого свойства для нас.

    -

    Также было бы неплохо иметь возможность изменить цвет тумана, но, как было упомянуто выше, нам нужно синхронизировать цвет тумана и цвет фона. -Итак, давайте добавим еще одно виртуальное свойство в наш помощник, который будет устанавливать оба цвета, когда lil-gui манипулирует им.

    -

    lil-gui может манипулировать цветами 4 способами, как шестнадцатеричная строка из 6 цифр CSS (например: # 112233). -Как тон, насыщенность, яркость объекта (например: {h: 60, s: 1, v:}). -Как массив RGB (например: [255, 128, 64]). Или как массив RGBA (например: [127, 200, 75, 0.3]).

    -

    Для нашей цели проще всего использовать шестнадцатеричную версию, поскольку таким образом lil-gui манипулирует только одним значением. -К счастью, THREE.Color как метод getHexString -который мы используем, чтобы легко получить такую cтроку - нам просто нужно добавить «#» вперед.

    -
    // We use this class to pass to lil-gui
    -// so when it manipulates near or far
    -// near is never > far and far is never < near
    -+// Also when lil-gui manipulates color we'll
    -+// update both the fog and background colors.
    -class FogGUIHelper {
    -*  constructor(fog, backgroundColor) {
    -    this.fog = fog;
    -+    this.backgroundColor = backgroundColor;
    -  }
    -  get near() {
    -    return this.fog.near;
    -  }
    -  set near(v) {
    -    this.fog.near = v;
    -    this.fog.far = Math.max(this.fog.far, v);
    -  }
    -  get far() {
    -    return this.fog.far;
    -  }
    -  set far(v) {
    -    this.fog.far = v;
    -    this.fog.near = Math.min(this.fog.near, v);
    -  }
    -+  get color() {
    -+    return `#${this.fog.color.getHexString()}`;
    -+  }
    -+  set color(hexString) {
    -+    this.fog.color.set(hexString);
    -+    this.backgroundColor.set(hexString);
    -+  }
    -}
    -
    -

    Затем мы вызываем gui.addColor, чтобы добавить интерфейс цвета для виртуального свойства нашего помощника.

    -
    {
    -  const near = 1;
    -  const far = 2;
    -  const color = 'lightblue';
    -  scene.fog = new THREE.Fog(color, near, far);
    -  scene.background = new THREE.Color(color);
    -
    -*  const fogGUIHelper = new FogGUIHelper(scene.fog, scene.background);
    -  gui.add(fogGUIHelper, 'near', near, far).listen();
    -  gui.add(fogGUIHelper, 'far', near, far).listen();
    -+  gui.addColor(fogGUIHelper, 'color');
    -}
    -
    -

    - -

    -

    Вы можете видеть near до 1,9, а far до 2,0 дает очень резкий переход между незатуманенным и полностью затуманенным. -где near = 1,1 и far = 2,9 должны быть примерно самыми гладкими, учитывая, что наши кубики вращаются на 2 единицы от камеры.

    -

    И последнее: на материале существует логическое свойство fog, -определяющее, влияет ли туман на объекты, созданные с этим материалом. -По умолчанию это true для большинства материалов. В качестве примера того, почему вы можете захотеть отключить туман, представьте, -что вы делаете 3D-симулятор автомобиля с видом с места водителя или из кабины. -Вы, вероятно, хотите, чтобы тумана не было внутри, если смотреть изнутри автомобиля.

    -

    Лучшим примером может быть дом и густой туман вне дома. Допустим, туман установлен на расстоянии 2 метра ( near = 2) и полностью затуманен на 4 метра ( far = 4). -Комнаты длиннее 2 метров, а дом, вероятно, длиннее 4 метров, поэтому вам необходимо установить материалы для внутренней части дома, -чтобы не было тумана, в противном случае, если вы будете стоять внутри дома, глядя на стену в дальнем конце комнаты, она будет в тумане.

    -
    -
    -
    -
    fog: true, all
    -
    -
    - -

    Обратите внимание, что на стены и потолок в дальнем конце комнаты распространяется туман. Отключив туман для материалов дома, мы можем решить эту проблему.

    -
    -
    -
    -
    fog: true, only outside materials
    -
    -
    - -

    - - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/fundamentals.html b/manual/ru/fundamentals.html deleted file mode 100644 index 268625f119ac7e..00000000000000 --- a/manual/ru/fundamentals.html +++ /dev/null @@ -1,272 +0,0 @@ - - - Основы - - - - - - - - - - - - - -
    -
    -

    Основы

    -
    -
    -
    -

    -

    Это первая статья в серии статей о three.js. -Three.js это 3D-библиотека, которая максимально -упрощает создание 3D-контента на веб-странице.

    -

    Three.js часто путают с WebGL, поскольку чаще всего, -но не всегда, three.js использует WebGL для рисования 3D. -WebGL - это очень низкоуровневое api, рисующее только точки, линии и треугольники. -Чтобы сделать что-нибудь полезное с WebGL, как правило, требуется немало кода, -и именно здесь приходит Three.js. Он обрабатывает такие вещи, как сцены, -источники света, тени, материалы, текстуры, 3D-математику, все, -что вам нужно было бы написать самостоятельно, если бы вы использовали WebGL напрямую.

    -

    В этих руководствах предполагается, что вы уже знаете JavaScript, -и по большей части они будут использовать стандарт ES6+. Смотрите здесь -краткий список вещей, которые вы, как ожидается, уже знаете. -Большинство браузеров, которые поддерживают three.js, -обновляются автоматически, поэтому большинство пользователей -должны иметь возможность запускать этот код. Если вы хотите, -чтобы этот код запускался в действительно старых браузерах, -посмотрите на транспайлер, такой как Babel. -Конечно, пользователи, использующие действительно старые браузеры, -вероятно, имеют машины, которые не могут запускать three.js.

    -

    При изучении большинства языков программирования первое, что делают люди, -это заставляют компьютер напечатать "Hello World!". Для 3D одна из самых -распространенных задач - создать 3D-куб, так что давайте начнем с "Hello Cube!"

    -

    Первое, что нам нужно, это тэг <canvas>:

    -
    <body>
    -  <canvas id="c"></canvas>
    -</body>
    -

    Three.js будет рисовать на этом холсте, так что нам нужно найти -его и передать three.js.

    -
    <script type="module">
    -import * as THREE from 'three';
    -
    -function main() {
    -  const canvas = document.querySelector('#c');
    -  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -  ...
    -</script>
    -

    Обратите внимание, что здесь есть некоторые не явные детали. -Если вы не передадите холст в three.js, библиотека создаст его за вас, -но затем нужно будет добавить его в DOM. Место добавления -может меняться в зависимости от вашего варианта использования, -и вам придется изменить свой код, поэтому я считаю, что передача canvas -в three.js выглядит немного более гибкой. Я могу поместить холст где угодно, -и код найдет его там, как если бы у меня был код для вставки холста в документ, -и мне, вероятно, пришлось бы изменить этот код, если бы изменился мой вариант -использования.

    -

    Когда канвас найден, мы создаем WebGLRenderer. Renderer - это то, что отвечает -за фактическое получение всех предоставленных вами данных и их отрисовку на холст.

    -

    Далее нам нужна камера.

    -
    const fov = 75;
    -const aspect = 2;  // значение для canvas по умолчанию
    -const near = 0.1;
    -const far = 5;
    -const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -

    fov сокращение от field of view, поле зрения. В этом случае 75 градусов в -вертикальном измерении. Обратите внимание, что большинство углов в Three.js -указаны в радианах, но по какой-то причине перспективная камера принимает градусы.

    -

    aspect это соотношение сторон холста (англ. aspect ratio). Мы рассмотрим детали -в другой статье, но по умолчанию холст имеет размер 300x150 пикселей, -значит соотношение сторон 300/150 или 2.

    -

    near и far представляют пространство перед камерой, которое будет отображаться. -Все, что находится до или после этого диапазона, будет обрезано (не нарисовано).

    -

    Эти 4 параметра определяют усеченную пирамиду "frustum". Frustum это -название 3D фигуры, напоминающей пирамиду с отсеченной верхушкой. Другими словами, -думайте о слове "frustum" как о трехмерной фигуре, -такой как сфера, куб и призма.

    -

    -

    Высота ближней и дальней плоскостей определяется полем зрения (field of view). -Ширина обеих плоскостей определяется полем зрения и соотношением сторон (aspect).

    -

    Все, что находится внутри определенного усеченного контура, будет нарисовано. -Снаружи ничего не будет.

    -

    По умолчанию камера смотрит вниз по оси -Z и вверх по оси +Y. Мы поместим наш куб -в начало координат (origin), поэтому нам нужно немного отодвинуть камеру назад, -чтобы что-то увидеть.

    -
    camera.position.z = 2;
    -

    Вот как мы её направили.

    -

    -

    На диаграмме выше мы видим, что наша камера находится в z = 2. И смотрит вниз по оси -Z. -Усеченная пирамида начинается с 0.1 единицы спереди камеры и до 5 единиц перед камерой. -Поскольку на этой диаграмме мы смотрим вниз, поле зрения (fov) зависит от отношения -сторон (aspect). Так как ширина холста в 2 раза больше высоты, при просмотре поле обзора -будет намного шире, чем указанные нами 75 градусов, которые являются вертикальным -полем зрения.

    -

    Далее создадим Scene. Scene в three.js корень формы графа сцены. -Все, что вы хотите нарисовать необходимо добавить на сцену. Мы рассмотрим подробнее, -как работают сцены, в следующей статье.

    -
    const scene = new THREE.Scene();
    -

    Далее мы создаем BoxGeometry который содержит данные для прямоугольного параллелепипеда. -Почти все, что мы хотим отобразить в Three.js, нуждается в геометрии, -которая определяет вершины нашего трехмерного объекта.

    -
    const boxWidth = 1;
    -const boxHeight = 1;
    -const boxDepth = 1;
    -const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    -

    Затем мы создаем основной материал и устанавливаем его цвет. -Цвета могут быть определены с использованием 6-значных шестнадцатеричных -значений цвета, как в CSS.

    -
    const material = new THREE.MeshBasicMaterial({color: 0x44aa88});
    -

    Затем мы создаем полигональную сетку -Mesh. Mesh в three.js представляет комбинацию -формы объекта Geometry и Material (как нарисовать объект, -блестящий или плоский, какой цвет, какую текстуру(ры) применить и т.д.) -а также положение, ориентацию, и масштаб этого объекта в сцене.

    -
    const cube = new THREE.Mesh(geometry, material);
    -

    И, наконец, мы добавляем Mesh на сцену

    -
    scene.add(cube);
    -

    Затем мы можем отрендерить сцену, вызвав функцию render рендерера -передав ей сцену и камеру.

    -
    renderer.render(scene, camera);
    -

    Вот рабочий пример

    -

    - -

    -

    Трудно сказать, что это 3D-куб, так как мы видим его непосредственно по оси --Z, а сам куб выровнен по этой оси, поэтому мы видим только одну грань.

    -

    Давайте оживим его, и, надеюсь, это прояснит, что он рисуется в 3D. Для его -анимации мы будем отрисовывать внутри цикла отрисовки, используя -requestAnimationFrame.

    -

    Вот наш цикл

    -
    function render(time) {
    -  time *= 0.001;  // конвертировать время в секунды
    -
    -  cube.rotation.x = time;
    -  cube.rotation.y = time;
    -
    -  renderer.render(scene, camera);
    -
    -  requestAnimationFrame(render);
    -}
    -requestAnimationFrame(render);
    -

    requestAnimationFrame это запрос к браузеру, что вы хотите что-то анимировать. -Вы передаете ему функцию для вызова. В нашем случае эта функция render. -Браузер вызовет вашу функцию, и если вы обновите что-либо, связанное с -отображением страницы, браузер выполнит перерисовку страницы. -В нашем случае мы вызываем renderer.render, которая нарисует нашу сцену.

    -

    requestAnimationFrame передает время с момента загрузки страницы в нашу функцию. -Это время приходит в миллисекундах. Я считаю, что работать с секундами намного проще, -поэтому здесь мы конвертируем время в секунды.

    -

    Затем мы устанавливаем вращение куба по X и Y на текущее время. Эти повороты в -радианах. В круге 2 пи радиана, -поэтому наш куб должен повернуться вокруг каждой оси примерно за 6.28 -секунд.

    -

    Затем мы отрисовываем сцену и запрашиваем еще один кадр анимации, -чтобы продолжить наш цикл.

    -

    Вне цикла мы вызываем requestAnimationFrame один раз, чтобы запустить цикл.

    -

    - -

    -

    Это немного лучше, но все еще трудно увидеть 3d. Что может помочь, так это -добавить немного освещения, поэтому давайте добавим источник света. -В Three.js есть много разных источников света, о которых мы поговорим в -следующей статье. А пока давайте создадим направленный свет.

    -
    {
    -  const color = 0xFFFFFF;
    -  const intensity = 3;
    -  const light = new THREE.DirectionalLight(color, intensity);
    -  light.position.set(-1, 2, 4);
    -  scene.add(light);
    -}
    -

    Направленные источники имеет положение и цель. Оба по умолчанию равны 0, 0, 0. В нашем -случае мы устанавливаем положение источника света на -1, 2, 4 чтобы оно было немного слева, -сверху и позади нашей камеры. Цель по-прежнему 0, 0, 0, поэтому они будут светить -в направлении начала координат.

    -

    Нам также нужно изменить материал. MeshBasicMaterial не воспреимчив к свету. -Давайте изменим его на MeshPhongMaterial, который отражает свет.

    -
    -const material = new THREE.MeshBasicMaterial({color: 0x44aa88});  // greenish blue
    -+const material = new THREE.MeshPhongMaterial({color: 0x44aa88});  // greenish blue
    -

    И вот оно работает.

    -

    - -

    -

    Теперь должно быть довольно четко видно 3D.

    -

    Просто для удовольствия добавим еще 2 кубика.

    -

    Мы будем использовать одну и ту же геометрию для каждого куба, но -создадим другой материал, чтобы каждый куб мог иметь свой цвет.

    -

    Сначала мы сделаем функцию, которая создает новый материал с указанным цветом. -Затем создает mesh, используя указанную геометрию, добавляет ее к сцене и -устанавливает ей позицию X.

    -
    function makeInstance(geometry, color, x) {
    -  const material = new THREE.MeshPhongMaterial({color});
    -
    -  const cube = new THREE.Mesh(geometry, material);
    -  scene.add(cube);
    -
    -  cube.position.x = x;
    -
    -  return cube;
    -}
    -

    Затем мы будем вызывать его 3 раза с 3 разными цветами и позициями X, -сохраняя экземпляры Mesh в массив.

    -
    const cubes = [
    -  makeInstance(geometry, 0x44aa88,  0),
    -  makeInstance(geometry, 0x8844aa, -2),
    -  makeInstance(geometry, 0xaa8844,  2),
    -];
    -

    Наконец, мы закрутим все 3 куба в нашей функции отрисовки. -Мы рассчитываем немного разные коэффициенты вращения для каждого.

    -
    function render(time) {
    -  time *= 0.001;  // конвертировать время в секунды
    -
    -  cubes.forEach((cube, ndx) => {
    -    const speed = 1 + ndx * .1;
    -    const rot = time * speed;
    -    cube.rotation.x = rot;
    -    cube.rotation.y = rot;
    -  });
    -
    -  ...
    -

    и вот оно.

    -

    - -

    -

    Если вы сравните его с диаграммой сверху вниз, вы увидите, что она соответствует -нашим ожиданиям. С кубами в X = -2 и X = +2 они частично находятся вне нашей -усеченной пирамиды. Они также несколько искривлены, так как -поле зрения на холсте очень велико.

    -

    Я надеюсь, что это короткое вступление поможет вам начать изучение. -Далее мы рассмотрим, как сделать отзывчивый дизайн, чтобы код можно было применять -к различным ситуациям.

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/game.html b/manual/ru/game.html deleted file mode 100644 index 55bf05dbb1be7b..00000000000000 --- a/manual/ru/game.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Making a Game - - - - - - - - - - - - - -
    -
    -

    Making a Game

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/indexed-textures.html b/manual/ru/indexed-textures.html deleted file mode 100644 index dbc08d677311d2..00000000000000 --- a/manual/ru/indexed-textures.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Indexed Textures for Picking and Color - - - - - - - - - - - - - -
    -
    -

    Indexed Textures for Picking and Color

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/lights.html b/manual/ru/lights.html deleted file mode 100644 index 4dcf43bd91b5e0..00000000000000 --- a/manual/ru/lights.html +++ /dev/null @@ -1,480 +0,0 @@ - - - - Освещение - - - - - - - - - - - - - -
    -
    -

    - Освещение

    -
    -
    -
    -

    Эта статья является частью серии статей о three.js. -Первая была об основах. -Если вы её еще не читали, советую вам сделать это. -Предыдущая статья была о текстурах.

    -

    Давайте рассмотрим, как использовать различные виды освещения в three.js.

    -

    Начинем с одного из наших предыдущих примеров, давайте обновим камеру. -Мы установим поле зрения (fov) на 45 градусов, дальнюю плоскость (far) на 100 единиц, -и мы переместим камеру на 10 единиц вверх и на 20 единиц назад от начала -координат.

    -
    *const fov = 45;
    -const aspect = 2;  // значение по умолчанию для холста
    -const near = 0.1;
    -*const far = 100;
    -const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -+camera.position.set(0, 10, 20);
    -
    -

    Далее давайте добавим OrbitControls. OrbitControls позволить пользователю вращать -или поворачивать камеру вокруг некоторой точки. OrbitControls - это -дополнительные функции three.js, поэтому сначала нам нужно -включить их в нашу страницу.

    -
    import * as THREE from 'three';
    -+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -
    -

    Теперь мы можем использовать их. Мы передаем в OrbitControls камеру для -управления и элемент DOM для получения входных событий

    -
    const controls = new OrbitControls(camera, canvas);
    -controls.target.set(0, 5, 0);
    -controls.update();
    -
    -

    Мы также устанавливаем target на 5 орбит вокруг источника, а затем вызываем -controls.update чтобы элементы управления использовали новую цель.

    -

    Далее давайте сделаем некоторые вещи, чтобы включить освещение. -Сначала мы сделаем плоскость земли. Мы применим крошечную текстуру -шахматной доски размером 2x2, которая выглядит следующим образом

    -
    - -
    - -

    Сначала мы загружаем текстуру, устанавливаем фильтрацию nearest -и устанавливаем число повторений. -Поскольку текстура представляет собой шахматную доску размером 2x2 пикселя, -при повторении и установке повторения равным половине размера -плоскости каждая клетка на шахматной доске будет иметь размер -ровно 1 единицу;

    -
    const planeSize = 40;
    -
    -const loader = new THREE.TextureLoader();
    -const texture = loader.load('../resources/images/checker.png');
    -texture.wrapS = THREE.RepeatWrapping;
    -texture.wrapT = THREE.RepeatWrapping;
    -texture.magFilter = THREE.NearestFilter;
    -texture.colorSpace = THREE.SRGBColorSpace;
    -const repeats = planeSize / 2;
    -texture.repeat.set(repeats, repeats);
    -
    -

    Затем мы создаем геометрию плоскости, материал для плоскости и сетку, -чтобы вставить ее в сцену. Плоскости по умолчанию находятся в плоскости -XY, но земля находится в плоскости XZ, поэтому мы вращаем ее.

    -
    const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    -const planeMat = new THREE.MeshPhongMaterial({
    -  map: texture,
    -  side: THREE.DoubleSide,
    -});
    -const mesh = new THREE.Mesh(planeGeo, planeMat);
    -mesh.rotation.x = Math.PI * -.5;
    -scene.add(mesh);
    -
    -

    Давайте добавим куб и сферу, чтобы у нас было 3 вещи для освещения, -включая плоскость

    -
    {
    -  const cubeSize = 4;
    -  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
    -  const cubeMat = new THREE.MeshPhongMaterial({color: '#8AC'});
    -  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
    -  mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
    -  scene.add(mesh);
    -}
    -{
    -  const sphereRadius = 3;
    -  const sphereWidthDivisions = 32;
    -  const sphereHeightDivisions = 16;
    -  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
    -  const sphereMat = new THREE.MeshPhongMaterial({color: '#CA8'});
    -  const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    -  mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
    -  scene.add(mesh);
    -}
    -
    -

    Теперь, когда у нас есть сцена для освещения, давайте добавим свет!

    -

    AmbientLight

    -

    Сначала давайте сделаем AmbientLight

    -
    const color = 0xFFFFFF;
    -const intensity = 1;
    -const light = new THREE.AmbientLight(color, intensity);
    -scene.add(light);
    -
    -

    Давайте также сделаем так, чтобы мы могли регулировать параметры света. -Мы снова будем использовать lil-gui. -Чтобы иметь возможность настроить цвет с помощью lil-gui, нам нужен небольшой -помощник, который представляет свойство для lil-gui, которое выглядит как -шестнадцатеричная цветовая строка CSS (например: #FF8844). -Наш helper получит цвет из именованного свойства, преобразует его в -шестнадцатеричную строку, чтобы предложить lil-gui. Когда lil-gui -попытается установить свойство helper'а, мы присвоим результат -обратно цвету источника света.

    -

    Вот helper:

    -
    class ColorGUIHelper {
    -  constructor(object, prop) {
    -    this.object = object;
    -    this.prop = prop;
    -  }
    -  get value() {
    -    return '#' + this.object[this.prop].getHexString();
    -  }
    -  set value(hexString) {
    -    this.object[this.prop].set(hexString);
    -  }
    -}
    -
    -

    И вот наш код настройки lil-gui

    -
    const gui = new GUI();
    -gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
    -gui.add(light, 'intensity', 0, 5, 0.01);
    -
    -

    И вот результат

    -

    - -

    -

    Нажмите и перетащите сценy, чтобы вращать камеру.

    -

    Обратите внимание, формы плоские. AmbientLight только умножил цвет материала -на цвет света с учетом интенсивности.

    -
    color = materialColor * light.color * light.intensity;
    -

    Вот и все. У него нет направления. -Этот стиль окружающего (ambient) освещения на самом деле не так полезен, -как освещение, так как он на 100% даже за исключением изменения цвета -всего на сцене, не очень похож на освещение. Что помогает, так это -делает темные не слишком темными.

    -

    HemisphereLight

    -

    Давайте переключим код на HemisphereLight. HemisphereLight -принимает цвет неба и основной цвет и просто умножает цвет материала -между этими двумя цветами. Цвет неба, если поверхность объекта направлена -​​вверх, и цвет земли, если поверхность объекта направлена ​​вниз.

    -

    Вот новый код

    -
    -const color = 0xFFFFFF;
    -+const skyColor = 0xB1E1FF;  // light blue
    -+const groundColor = 0xB97A20;  // brownish orange
    -const intensity = 1;
    --const light = new THREE.AmbientLight(color, intensity);
    -+const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
    -scene.add(light);
    -
    -

    Давайте также обновим код lil-gui для редактирования обоих цветов.

    -
    const gui = new GUI();
    --gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
    -+gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('skyColor');
    -+gui.addColor(new ColorGUIHelper(light, 'groundColor'), 'value').name('groundColor');
    -gui.add(light, 'intensity', 0, 5, 0.01);
    -
    -

    Результат:

    -

    - -

    -

    Еще раз обратите внимание, что объема почти нет, все выглядит плоско. -HemisphereLight используется в сочетании с другим светом и может -помочь дать хороший вид влияния цвета неба и земли. -Таким образом, его лучше всего использовать в сочетании с другим источником -света или заменой AmbientLight.

    -

    DirectionalLight

    -

    Давайте переключим код на DirectionalLight. -DirectionalLight часто используется для воспроизведения солнца.

    -
    const color = 0xFFFFFF;
    -const intensity = 1;
    -const light = new THREE.DirectionalLight(color, intensity);
    -light.position.set(0, 10, 0);
    -light.target.position.set(-5, 0, 0);
    -scene.add(light);
    -scene.add(light.target);
    -
    -

    Обратите внимание, что мы должны были добавить light и light.target -к сцене. DirectionalLight будет светить в направлении к своей цели.

    -

    Давайте сделаем так, чтобы мы могли перемещать цель, -добавляя ее в наш графический интерфейс.

    -
    const gui = new GUI();
    -gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
    -gui.add(light, 'intensity', 0, 5, 0.01);
    -gui.add(light.target.position, 'x', -10, 10);
    -gui.add(light.target.position, 'z', -10, 10);
    -gui.add(light.target.position, 'y', 0, 10);
    -
    -

    - -

    -

    Трудно понять, что происходит. Three.js имеет несколько вспомогательных -объектов, которые мы можем добавить к нашей сцене, чтобы помочь -визуализировать невидимые части сцены. В этом случае мы будем -использовать тот DirectionalLightHelper, который нарисует плоскость, -чтобы изобразить источник света, и линию от света к цели. -Мы просто передаем ему свет и добавляем его на сцену.

    -
    const helper = new THREE.DirectionalLightHelper(light);
    -scene.add(helper);
    -
    -

    Пока мы работаем с ним, давайте сделаем так, чтобы мы могли установить -как положение источника света, так и цели. Для этого мы сделаем функцию, -которая по заданному Vector3 скорректирует его x, y и z свойства, -используя lil-gui.

    -
    function makeXYZGUI(gui, vector3, name, onChangeFn) {
    -  const folder = gui.addFolder(name);
    -  folder.add(vector3, 'x', -10, 10).onChange(onChangeFn);
    -  folder.add(vector3, 'y', 0, 10).onChange(onChangeFn);
    -  folder.add(vector3, 'z', -10, 10).onChange(onChangeFn);
    -  folder.open();
    -}
    -
    -

    Обратите внимание, что нам нужно вызывать update функцию помощника -каждый раз, когда мы что-то меняем, чтобы помощник знал, что нужно -обновить себя. Таким образом, мы передаем onChangeFn функцию -для вызова в любое время, а lil-gui обновляет значение.

    -

    Затем мы можем использовать это как для положения источника света, -так и для цели, как тут

    -
    +function updateLight() {
    -+  light.target.updateMatrixWorld();
    -+  helper.update();
    -+}
    -+updateLight();
    -
    -const gui = new GUI();
    -gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
    -gui.add(light, 'intensity', 0, 5, 0.01);
    -
    -+makeXYZGUI(gui, light.position, 'position', updateLight);
    -+makeXYZGUI(gui, light.target.position, 'target', updateLight);
    -
    -

    Теперь мы можем переместить свет и его цель

    -

    - -

    -

    Вращайтесь по орбите камеры, и это станет легче видеть. Плоскость представляет собой -DirectionalLight потому что направленный свет вычисляет свет -поступающий в одном направлении. Нет точки откуда исходит свет, -это бесконечная плоскость света, излучающая параллельные лучи света.

    -

    PointLight

    -

    PointLight - это свет, который сидит в какой-то точке и излучает свет -во всех направлениях от этой точки. Давайте изменим код.

    -
    const color = 0xFFFFFF;
    --const intensity = 1;
    -+const intensity = 150;
    --const light = new THREE.DirectionalLight(color, intensity);
    -+const light = new THREE.PointLight(color, intensity);
    -light.position.set(0, 10, 0);
    --light.target.position.set(-5, 0, 0);
    -scene.add(light);
    --scene.add(light.target);
    -
    -

    Давайте также перейдем к PointLightHelper

    -
    -const helper = new THREE.DirectionalLightHelper(light);
    -+const helper = new THREE.PointLightHelper(light);
    -scene.add(helper);
    -
    -

    и поскольку нет цели, то onChange функция может быть проще.

    -
    function updateLight() {
    --  light.target.updateMatrixWorld();
    -  helper.update();
    -}
    --updateLight();
    -
    -

    Обратите внимание, что на каком-то уровне PointLightHelper не имеет точки. -Он просто рисует маленький каркас ромба. Это может быть любая -форма, которую вы хотите, просто добавьте mesh к самому источнику света.

    -

    PointLight имеет дополнительное свойство distance. -Если distance = 0, то PointLight светит до бесконечности. Если значение -distance больше 0, то свет излучает свою полную интенсивность и -исчезает, с увеличением distance вдали от света.

    -

    Давайте настроим графический интерфейс, чтобы мы могли регулировать расстояние.

    -
    const gui = new GUI();
    -gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
    -gui.add(light, 'intensity', 0, 250, 1);
    -+gui.add(light, 'distance', 0, 40).onChange(updateLight);
    -
    -makeXYZGUI(gui, light.position, 'position', updateLight);
    --makeXYZGUI(gui, light.target.position, 'target', updateLight);
    -
    -

    А теперь попробуйте.

    -

    - -

    -

    Обратите внимание, когда distance > 0, как свет гаснет.

    -

    SpotLight

    -

    Прожекторы - это точечный источник света с прикрепленным к нему -конусом, который светит только внутри конуса. Там на самом деле -2 конуса. Внешний конус и внутренний конус. Между внутренним -и внешним конусом свет исчезает от полной интенсивности до нуля.

    -

    Чтобы использовать SpotLight нам нужна цель, т.к. это направленный свет. -Конус света открываем по направлению к цели.

    -

    Модификация нашего DirectionalLight с помощником сверху

    -
    const color = 0xFFFFFF;
    --const intensity = 1;
    -+const intensity = 150;
    --const light = new THREE.DirectionalLight(color, intensity);
    -+const light = new THREE.SpotLight(color, intensity);
    -scene.add(light);
    -scene.add(light.target);
    -
    --const helper = new THREE.DirectionalLightHelper(light);
    -+const helper = new THREE.SpotLightHelper(light);
    -scene.add(helper);
    -
    -

    Угол конуса прожектора задается с помощью свойства angle -в радианах. Мы будем использовать наш DegRadHelper из -статьи про текстуры для представления пользовательского интерфейса в градусах..

    -
    gui.add(new DegRadHelper(light, 'angle'), 'value', 0, 90).name('angle').onChange(updateLight);
    -
    -

    Внутренний конус определяется путем установки свойства penumbra -в процентах от внешнего конуса. Другими словами, когда penumbra = 0 , то внутренний код -имеет такой же размер (0 = нет разницы) от внешнего конуса. Когда значение -penumbra равно 1, свет гаснет, начиная с центра конуса до внешнего конуса. -Когда penumbra равно .5, то свет гаснет, начиная с 50% между центром внешнего конуса.

    -
    gui.add(light, 'penumbra', 0, 1, 0.01);
    -
    -

    - -

    -

    Обратите внимание, что при значении по умолчанию penumbra = 0 и прожектор -имеет очень резкий край. По мере того, как вы наращиваете penumbra к 1 -края размываются.

    -

    Может быть трудно увидеть конус прожектора. Причина в том, что он ниже земли. -Сократите расстояние до 5, и вы увидите открытый конец конуса.

    -

    RectAreaLight

    -

    Есть еще один тип света - RectAreaLight, который представляет именно то, -на что это похоже, прямоугольную область света, такую ​​как длинный флуоресцентный -свет или, возможно, матовый небесный свет в потолке.

    -

    RectAreaLight работает только с MeshStandardMaterai и -MeshPhysicalMaterial поэтому давайте изменим все наши материалы на MeshStandardMaterial

    -
      ...
    -
    -  const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    --  const planeMat = new THREE.MeshPhongMaterial({
    -+  const planeMat = new THREE.MeshStandardMaterial({
    -    map: texture,
    -    side: THREE.DoubleSide,
    -  });
    -  const mesh = new THREE.Mesh(planeGeo, planeMat);
    -  mesh.rotation.x = Math.PI * -.5;
    -  scene.add(mesh);
    -}
    -{
    -  const cubeSize = 4;
    -  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
    -- const cubeMat = new THREE.MeshPhongMaterial({color: '#8AC'});
    -+ const cubeMat = new THREE.MeshStandardMaterial({color: '#8AC'});
    -  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
    -  mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
    -  scene.add(mesh);
    -}
    -{
    -  const sphereRadius = 3;
    -  const sphereWidthDivisions = 32;
    -  const sphereHeightDivisions = 16;
    -  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
    --  const sphereMat = new THREE.MeshPhongMaterial({color: '#CA8'});
    -+ const sphereMat = new THREE.MeshStandardMaterial({color: '#CA8'});
    -  const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    -  mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
    -  scene.add(mesh);
    -}
    -
    -

    Для использования RectAreaLight нам нужно включить некоторые дополнительные возможности three.js

    -
    import * as THREE from 'three';
    -+import {RectAreaLightUniformsLib} from 'three/addons/lights/RectAreaLightUniformsLib.js';
    -+import {RectAreaLightHelper} from 'three/addons/helpers/RectAreaLightHelper.js';
    -
    -
    function main() {
    -  const canvas = document.querySelector('#c');
    -  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -+  RectAreaLightUniformsLib.init();
    -
    -

    Если вы забудете RectAreaLightUniformsLib, индикатор все равно будет работать, -но он будет выглядеть забавно, поэтому не забудьте включить дополнительный код.

    -

    Теперь мы можем создать свет

    -
    const color = 0xFFFFFF;
    -*const intensity = 5;
    -+const width = 12;
    -+const height = 4;
    -*const light = new THREE.RectAreaLight(color, intensity, width, height);
    -light.position.set(0, 10, 0);
    -+light.rotation.x = THREE.MathUtils.degToRad(30);
    -scene.add(light);
    -
    -*const helper = new RectAreaLightHelper(light);
    -scene.add(helper);
    -
    -

    Единственное, что следует заметить, в отличие от DirectionalLight и SpotLight, -RectAreaLight не использует цель. Он просто использует свой поворот.

    -

    Давайте также настроим графический интерфейс. Мы сделаем так, чтобы мы могли вращать -свет и регулировать его width и height

    -
    const gui = new GUI();
    -gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
    -gui.add(light, 'intensity', 0, 10, 0.01);
    -gui.add(light, 'width', 0, 20);
    -gui.add(light, 'height', 0, 20);
    -gui.add(new DegRadHelper(light.rotation, 'x'), 'value', -180, 180).name('x rotation');
    -gui.add(new DegRadHelper(light.rotation, 'y'), 'value', -180, 180).name('y rotation');
    -gui.add(new DegRadHelper(light.rotation, 'z'), 'value', -180, 180).name('z rotation');
    -
    -makeXYZGUI(gui, light.position, 'position');
    -
    -

    И вот что.

    -

    - -

    - -

    Важно отметить, что каждый источник света, который вы добавляете в сцену, -замедляет скорость рендеринга сцены в three.js, поэтому вы всегда должны -стараться использовать как можно меньше для достижения своих целей.

    -

    Далее давайте перейдем к работе с камерами.

    -

    - - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/load-gltf.html b/manual/ru/load-gltf.html deleted file mode 100644 index 228bd6f6e9b186..00000000000000 --- a/manual/ru/load-gltf.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Loading a .GLTF File - - - - - - - - - - - - - -
    -
    -

    Loading a .GLTF File

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/load-obj.html b/manual/ru/load-obj.html deleted file mode 100644 index 261a4b568cd620..00000000000000 --- a/manual/ru/load-obj.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Loading a .OBJ File - - - - - - - - - - - - - -
    -
    -

    Loading a .OBJ File

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/material-table.html b/manual/ru/material-table.html deleted file mode 100644 index 424cb3bbe8b206..00000000000000 --- a/manual/ru/material-table.html +++ /dev/null @@ -1,47 +0,0 @@ - - - Таблица характеристик материалов - - - - - - - - - - - - - -
    -
    -

    Таблица характеристик материалов

    -
    -
    -
    -

    Наиболее распространенными материалами в three.js являются материалы -Mesh. Вот таблица, показывающая, какие функции поддерживают каждый из материалов.

    -
    -
    - - -
    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/materials.html b/manual/ru/materials.html deleted file mode 100644 index 78f736501065d0..00000000000000 --- a/manual/ru/materials.html +++ /dev/null @@ -1,318 +0,0 @@ - - - Материалы - - - - - - - - - - - - - -
    -
    -

    Материалы

    -
    -
    -
    -

    Эта статья является частью серии статей о three.js. -Первая была об основах. -Если вы её еще не читали, советую вам сделать это.

    -

    Three.js предоставляет несколько типов материалов. Они определяют, -как объекты будут появляться на сцене. Какие материалы вы используете, -зависит от того, чего вы пытаетесь достичь.

    -

    Есть 2 способа установить большинство свойств материала. -Один во время создания, который мы видели раньше.

    -
    const material = new THREE.MeshPhongMaterial({
    -  color: 0xFF0000,    // red (можно также использовать css цвета)
    -  flatShading: true,
    -});
    -
    -

    Другой после создания

    -
    const material = new THREE.MeshPhongMaterial();
    -material.color.setHSL(0, 1, .5);  // red
    -material.flatShading = true;
    -
    -

    обратите внимание, что свойства типа THREE.Color могут быть установлены несколькими способами.

    -
    material.color.set(0x00FFFF);    // так же, как в CSS #RRGGBB
    -material.color.set(cssString);   // любой CSS цвет, например 'purple', '#F32',
    -                                 // 'rgb(255, 127, 64)',
    -                                 // 'hsl(180, 50%, 25%)'
    -material.color.set(someColor)    // или другой THREE.Color
    -material.color.setHSL(h, s, l)   // где h, s, и l от 0 до 1
    -material.color.setRGB(r, g, b)   // где r, g, и b от 0 до 1
    -
    -

    И во время создания вы можете передать либо шестнадцатеричное число либо строку CSS

    -
    const m1 = new THREE.MeshBasicMaterial({color: 0xFF0000});         // red
    -const m2 = new THREE.MeshBasicMaterial({color: 'red'});            // red
    -const m3 = new THREE.MeshBasicMaterial({color: '#F00'});           // red
    -const m4 = new THREE.MeshBasicMaterial({color: 'rgb(255,0,0)'});   // red
    -const m5 = new THREE.MeshBasicMaterial({color: 'hsl(0,100%,50%)'}); // red
    -
    -

    прим. переводчика: -Блик - световое пятно на ярко освещённой выпуклой или плоской глянцевой поверхности. -Зеркальное отражение я часто буду называть бликом, -хотя это скорее частный случай.

    -

    Итак, давайте рассмотрим набор материалов Three.js.

    -

    MeshBasicMaterial не зависит от света. -MeshLambertMaterial вычисляет освещение только в вершинах vs, MeshPhongMaterial который -вычисляет освещение в каждом пикселе и MeshPhongMaterial также поддерживающий -блики.

    -
    -
    -
    -
    Basic
    -
    -
    -
    -
    Lambert
    -
    -
    -
    -
    Phong
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    низкополигональные модели с теми же материалами
    - -

    shininess устанавливает MeshPhongMaterial определяя блеск от бликов. Значение по умолчанию - 30.

    -
    -
    -
    -
    shininess: 0
    -
    -
    -
    -
    shininess: 30
    -
    -
    -
    -
    shininess: 150
    -
    -
    - -

    Обратите внимание, что установка светимости (emissive свойства) для цвета -MeshLambertMaterial или MeshPhongMaterial и установка color в черный -(и shininess в 0 для Фонга) в конечном итоге будет выглядеть как MeshBasicMaterial.

    -
    -
    -
    -
    -
    Basic
    -
    color: 'purple'
    -
    -
    -
    -
    -
    -
    Lambert
    -
    color: 'black'
    -
    emissive: 'purple'
    -
    -
    -
    -
    -
    -
    Phong
    -
    color: 'black'
    -
    emissive: 'purple'
    -
    shininess: 0
    -
    -
    -
    - -

    Зачем нам все 3, когда MeshPhongMaterial может делать то же самое, что MeshBasicMaterialMeshLambertMaterial? Причина в том, что более сложный материал требует больше ресурсов -графического процессора. На более медленном GPU, например, на мобильном телефоне, возможно, -вы захотите уменьшить мощность графического процессора, необходимую для рисования вашей сцены, -используя один из менее сложных материалов. Из этого также следует, что если вам не нужны -дополнительные функции, используйте самый простой материал. Если вам не нужно освещение и -блики, используйте MeshBasicMaterial.

    -

    MeshToonMaterial похож на MeshPhongMaterial с одной большой разницей. -Вместо плавного затенения он использует карту градиента -(текстуру размером X на 1) для выбора оттенка. По умолчанию используется карта градиента, -яркость которой составляет 70% для первых 70% и 100% после, но вы можете предоставить -свою собственную карту градиента. Это в конечном итоге дает 2 тона, которые -выглядят как мультфильм.

    -
    -
    -
    - -

    Далее идут 2 физически обоснованных материала. Physically Based -Rendering часто сокращается как PBR.

    -

    Приведенные выше материалы используют простую математику для создания материалов, -которые выглядят трехмерными, но это не то, что происходит в реальности. -2 PBR материала используют гораздо более сложную математику, чтобы приблизиться -к тому, что на самом деле происходит в реальном мире.

    -

    Первый - MeshStandardMaterial. Самая большая разница с -MeshPhongMaterial и MeshStandardMaterial - использование различных параметров. -У MeshPhongMaterial была shininess настройка. MeshStandardMaterial имеет 2 -настройки roughness и metalness.

    -

    По простому шероховатость -roughness это противоположность -shininess. Что-то с высокой шероховатостью, например, баскетбольный мяч не имеет -жестких отражений, а что-то не грубое, как бильярдный шар, очень блестящий. -Шероховатость задается в интервале от 0 до 1.

    -

    Другая настройка - metalness. Она говорит о том, -насколько металлический материал. Металлы ведут себя иначе, чем неметаллы, -и поэтому этот параметр изменяется от 0 для не металла вообще, до единицы - 100% металла.

    -

    Вот краткий пример MeshStandardMaterial с roughness от 0 до 1 -поперёк и metalness от 0 до 1 вниз.

    -
    - -

    MeshPhysicalMaterial же самое, что и MeshStandardMaterial но он добавляет clearcoat -параметр, который идет от 0 до 1 для определения степени применения слоя -глянцевого покрытия, и clearCoatRoughness параметр, который указывает, -насколько шероховатым является слой глянца.

    -

    Вот та же сетка roughness по metalness как и до этого, но с -clearcoat и clearCoatRoughness.

    -
    - -

    Различные стандартные материалы от самых быстрых к самым медленным: -MeshBasicMaterialMeshLambertMaterialMeshPhongMaterial ➡ -MeshStandardMaterialMeshPhysicalMaterial. Более медленные материалы -могут создавать более реалистичные сцены, но вам может потребоваться писать -дополнительный код, чтобы использовать более быстрые материалы на маломощных -или мобильных устройствах.

    -

    Есть 3 материала, которые имеют специальное использование. ShadowMaterial -используется для получения данных, созданных из теней. Мы еще не изучали тени. -Когда мы это сделаем, мы будем использовать этот материал, чтобы оценить, -что происходит за кулисами.

    -

    MeshDepthMaterial отрисовывает глубину каждого пикселя, где пиксели при -отрицательном near камеры равны 0 -и при отрицательном far равны 1. -Некоторые специальные эффекты могут использовать эти данные , которые мы получим в в другое время.

    -
    -
    -
    -
    -
    - -

    The MeshNormalMaterial Покажет вам нормали геометрии. -Нормали - это направление конкретного треугольника или грани пикселя. -MeshNormalMaterial рисует пространство просмотра нормалей (нормали относительно камеры). -x - красный, -y - зеленый и -z - синий, поэтому грани, направленные вправо, -будут красного цвета, «вверх» - будут зеленого цвета, а к экрану будут синие.

    -
    -
    -
    -
    -
    - -

    ShaderMaterial предназначен для изготовления нестандартных материалов -с использованием шейдерной системы three.js. -RawShaderMaterial предназначен для создания полностью пользовательских -шейдеров без помощи three.js. Обе эти темы большие и будут рассмотрены позже.

    -

    Большинство материалов имеют множество настроек, определенных Material. -Посмотрите документацию по ним, и давайте рассмотрим два -наиболее часто используемых свойства.

    -

    flatShading: -выглядит ли объект граненным или гладким. По умолчанию = false.

    -
    -
    -
    -
    flatShading: false
    -
    -
    -
    -
    flatShading: true
    -
    -
    - -

    side: какие стороны треугольников показать. По умолчанию = THREE.FrontSide. -Другие варианты - THREE.BackSide и THREE.DoubleSide (с обеих сторон). -Большинство трехмерных объектов, нарисованных в three, вероятно, являются непрозрачными -твердыми телами, поэтому не нужно рисовать задние стороны (стороны, -обращенные внутрь твердого тела). Наиболее распространенная причина установки side

    -
      -
    • для плоскостей или других нетвердых объектов, где обычно видны задние стороны треугольников.
    • -
    -

    Вот 6 плоскостей с THREE.FrontSide и THREE.DoubleSide.

    -
    -
    -
    -
    side: THREE.FrontSide
    -
    -
    -
    -
    side: THREE.DoubleSide
    -
    -
    - -

    С материалами действительно есть над чем поразмыслить, и нам еще многое -предстоит сделать. Мы в основном игнорировали текстуры, -которые открывают множество свойств. Прежде чем мы рассмотрим текстуры, -мы должны сделать перерыв и обсудить -настройку разрабочего окружения

    -
    -

    material.needsUpdate

    -

    -Эта тема редко затрагивает большинство приложений three.js, но для общей информированности... -Three.js применяет настройки материала, когда материал используется, где "используется" -значит "что-то отрисовывается с использованием материала". Некоторые настройки материала применяются -только один раз, так как их изменение требует много работы с three.js. -В этих случаях вам нужно указать material.needsUpdate = true чтобы -three.js применил ваши существенные изменения. Наиболее распространенные настройки, -которые необходимо установить, needsUpdate если вы измените настройки -после использования материала: -

    -
      -
    • flatShading
    • -
    • добавление или удаление текстуры. -

      - Смена текстуры - это нормально, но если вы хотите переключиться с использования - без текстуры на использование текстуры или с использования текстуры на - использование без текстуры, то вам нужно установить needsUpdate = true. -

      -

      В случае перехода от текстуры к "без текстуры" - часто просто лучше использовать белую текстуру 1x1 пикселей.

      -
    • -
    -

    Как упоминалось выше, большинство приложений никогда не сталкиваются с этими проблемами. -Большинство приложений не переключаются между - -flat shaded - и не flat shaded. - Большинство приложений либо используют текстуры либо сплошной цвет для одного и того же материала, - они редко переключаются с использования одного на использование другого. -

    -
    - -

    - - - -
    -
    -
    - - - - - - - - diff --git a/manual/ru/multiple-scenes.html b/manual/ru/multiple-scenes.html deleted file mode 100644 index e8fadd309687ef..00000000000000 --- a/manual/ru/multiple-scenes.html +++ /dev/null @@ -1,536 +0,0 @@ - - - - Несколько холстов и Несколько сцен - - - - - - - - - - - - - -
    -
    -

    - Несколько холстов и Несколько сцен

    -
    -
    -
    -

    Допустим, вы хотите создать сайт электронной коммерции или сделать -страницу с множеством трехмерных диаграмм. На первый взгляд все -выглядит просто. Просто сделайте холст для каждой диаграммы. -Для каждого холста сделайте Renderer.

    -

    Вы быстро обнаружите, что у вас возникли проблемы.

    -
      -
    1. Браузер ограничивает количество контекстов WebGL, которые вы можете иметь.

      -

      Обычно этот предел составляет около 8. -Как только вы создадите 9-й контекст, самый старый будет потерян.

      -
    2. -
    3. Ресурсы WebGL не могут быть разделены между контекстами (no shared memory)

      -

      Это означает, что если вы хотите загрузить 10-мегабайтную модель в 2 полотна, -и эта модель использует 20 мегабайт текстур, ваша 10-мегабайтная модель должна -быть загружена дважды, а ваши текстуры также будут загружены дважды. Ничто не -может быть разделено между контекстами. Это также означает, что вещи должны -быть инициализированы дважды, шейдеры скомпилированы дважды и т.д. Всё -ухудшается, когда появляется больше холстов.

      -
    4. -
    -

    Так в чем же решение?

    -

    Решением является один холст, который заполняет область просмотра в фоновом режиме -и некоторый другой элемент для представления каждого «виртуального» холста. -Мы делаем один Renderer и затем одну Scene для каждого виртуального холста. -Затем мы проверим положение элементов виртуального холста и, если они будут на -экране, мы скажем THREE.js нарисовать их сцену в правильном месте.

    -

    С этим решением есть только 1 холст, поэтому мы решаем обе задачи 1 и 2 выше. -Мы не будем сталкиваться с ограничением контекста WebGL, потому что мы будем -использовать только один контекст. Мы также не будем сталкиваться с проблемами -обмена по тем же причинам.

    -

    Давайте начнем с простого примера с двумя сценами. Сначала мы сделаем HTML

    -
    <canvas id="c">шутки про three js</canvas>
    -<p>
    -  <span id="box" class="diagram left"></span>
    -  Я люблю коробки (boxes). Подарки приходят в коробках.
    -  Когда я нахожу новую коробку, я всегда рад узнать, что внутри.
    -</p>
    -<p>
    -  <span id="pyramid" class="diagram right"></span>
    -    Когда я был ребенком, я мечтал отправиться в экспедицию внутри пирамиды (pyramid).
    -    и найти неоткрытую гробницу, полную мумий и сокровищ.
    -</p>
    -
    -

    Затем мы можем настроить CSS, как-то так

    -
    #c {
    -  position: fixed;
    -  left: 0;
    -  top: 0;
    -  width: 100%;
    -  height: 100%;
    -  display: block;
    -  z-index: -1;
    -}
    -.diagram {
    -  display: inline-block;
    -  width: 5em;
    -  height: 3em;
    -  border: 1px solid black;
    -}
    -.left {
    -  float: left;
    -  margin-right: .25em;
    -}
    -.right {
    -  float: right;
    -  margin-left: .25em;
    -}
    -
    -

    Мы устанавливаем холст, чтобы заполнить экран, и мы устанавливаем z-index = --1 , чтобы он появлялся позади других элементов. Нам также нужно указать некоторую -ширину и высоту для наших виртуальных элементов холста, поскольку внутри -нет ничего, что могло бы придать им какой-либо размер.

    -

    Теперь мы сделаем 2 сцены с подсветкой и камерой. -К одной сцене мы добавим куб, а к другой ромб.

    -
    function makeScene(elem) {
    -  const scene = new THREE.Scene();
    -
    -  const fov = 45;
    -  const aspect = 2;  // по умолчанию для холста
    -  const near = 0.1;
    -  const far = 5;
    -  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -  camera.position.z = 2;
    -  camera.position.set(0, 1, 2);
    -  camera.lookAt(0, 0, 0);
    -
    -  {
    -    const color = 0xFFFFFF;
    -    const intensity = 1;
    -    const light = new THREE.DirectionalLight(color, intensity);
    -    light.position.set(-1, 2, 4);
    -    scene.add(light);
    -  }
    -
    -  return {scene, camera, elem};
    -}
    -
    -function setupScene1() {
    -  const sceneInfo = makeScene(document.querySelector('#box'));
    -  const geometry = new THREE.BoxGeometry(1, 1, 1);
    -  const material = new THREE.MeshPhongMaterial({color: 'red'});
    -  const mesh = new THREE.Mesh(geometry, material);
    -  sceneInfo.scene.add(mesh);
    -  sceneInfo.mesh = mesh;
    -  return sceneInfo;
    -}
    -
    -function setupScene2() {
    -  const sceneInfo = makeScene(document.querySelector('#pyramid'));
    -  const radius = .8;
    -  const widthSegments = 4;
    -  const heightSegments = 2;
    -  const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
    -  const material = new THREE.MeshPhongMaterial({
    -    color: 'blue',
    -    flatShading: true,
    -  });
    -  const mesh = new THREE.Mesh(geometry, material);
    -  sceneInfo.scene.add(mesh);
    -  sceneInfo.mesh = mesh;
    -  return sceneInfo;
    -}
    -
    -const sceneInfo1 = setupScene1();
    -const sceneInfo2 = setupScene2();
    -
    -

    И тогда мы сделаем функцию для рендеринга каждой сцены, только если элемент -находится на экране. Мы можем указать THREE.js визуализировать только часть -холста, включив тест scissor через Renderer.setScissorTest, затем установим -плоскость обрезания и область просмотра (Viewport) с помощью -Renderer.setViewport и Renderer.setScissor.

    -
    function renderSceneInfo(sceneInfo) {
    -  const {scene, camera, elem} = sceneInfo;
    -
    -  // получаем относительную позицию окна просмотра этого элемента
    -  const {left, right, top, bottom, width, height} =
    -      elem.getBoundingClientRect();
    -
    -  const isOffscreen =
    -      bottom < 0 ||
    -      top > renderer.domElement.clientHeight ||
    -      right < 0 ||
    -      left > renderer.domElement.clientWidth;
    -
    -  if (isOffscreen) {
    -    return;
    -  }
    -
    -  camera.aspect = width / height;
    -  camera.updateProjectionMatrix();
    -
    -  const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
    -  renderer.setScissor(left, positiveYUpBottom, width, height);
    -  renderer.setViewport(left, positiveYUpBottom, width, height);
    -
    -  renderer.render(scene, camera);
    -}
    -
    -

    И тогда наша функция рендеринга просто сначала очистит экран, -а затем отрисует каждую сцену.

    -
    function render(time) {
    -  time *= 0.001;
    -
    -  resizeRendererToDisplaySize(renderer);
    -
    -  renderer.setScissorTest(false);
    -  renderer.clear(true, true);
    -  renderer.setScissorTest(true);
    -
    -  sceneInfo1.mesh.rotation.y = time * .1;
    -  sceneInfo2.mesh.rotation.y = time * .1;
    -
    -  renderSceneInfo(sceneInfo1);
    -  renderSceneInfo(sceneInfo2);
    -
    -  requestAnimationFrame(render);
    -}
    -
    -

    И вот оно

    -

    - -

    -

    Вы можете увидеть, где на первом месте <span> находится красный куб, -а на втором span - синий ромб.

    -

    Синхронизация

    -

    Код выше работает, но есть одна небольшая проблема. -Если ваши сцены сложные или по какой-либо причине требуется -слишком много времени для рендеринга, положение сцен, -нарисованных на холсте, будет отставать от остальной части страницы.

    -

    Если мы дадим каждой области границу

    -
    .diagram {
    -  display: inline-block;
    -  width: 5em;
    -  height: 3em;
    -+  border: 1px solid black;
    -}
    -
    -

    И устанавливаем фон каждой сцены

    -
    const scene = new THREE.Scene();
    -+scene.background = new THREE.Color('red');
    -
    -

    И если мы быстро прокрутим вверх и вниз -мы увидим проблему. Вот анимация прокрутки, замедленная в 10 раз.

    -
    - -

    Мы можем использовать другой метод, который имеет другой компромисс. -Мы переключим CSS холста с position: fixed на position: absolute.

    -
    #c {
    --  position: fixed;
    -+  position: absolute;
    -
    -

    Затем мы установим преобразование (transform) холста, чтобы переместить его так, -чтобы верх холста находился в верхней части любой части, -на которую в данный момент прокручивается страница.

    -
    function render(time) {
    -  ...
    -
    -  const transform = `translateY(${window.scrollY}px)`;
    -  renderer.domElement.style.transform = transform;
    -
    -

    position: fixed удерживает холст от прокрутки вообще, -в то время как остальная часть страницы прокручивалась поверх него. -position: absolute позволит холсту прокручиваться с остальной частью -страницы, что означает, что все, что мы рисуем, будет прилипать -к странице, когда она прокручивается, даже если мы слишком медленны -для рендеринга. Когда мы наконец получаем возможность -рендеринга, мы перемещаем холст так, чтобы он соответствовал -месту прокрутки страницы, и затем мы повторно визуализируем. -Это означает, что только на краях окна будут отображаться некоторые не визуализированные биты, но - материал в середине страницы должен совпадать, -а не скользить. Вот представление результатов нового метода, замедленного в 10 раз.

    -
    - -

    Делаем это более универсальным

    -

    Теперь, когда у нас работает несколько сцен, давайте сделаем это немного более обобщенным.

    -

    Мы могли бы сделать так, чтобы основная функция рендеринга, управляющая холстом, просто имела -список элементов и связанную с ними функцию рендеринга. Для каждого элемента он проверяет, -находится ли элемент на экране, и, если это так, вызывает соответствующую функцию рендеринга. -Таким образом, у нас была бы общая система, в которой отдельные сцены на самом деле не знают, -что знают, что их визуализируют в каком-то меньшем пространстве.

    -

    Вот основная функция отрисовки

    -
    const sceneElements = [];
    -function addScene(elem, fn) {
    -  sceneElements.push({elem, fn});
    -}
    -
    -function render(time) {
    -  time *= 0.001;
    -
    -  resizeRendererToDisplaySize(renderer);
    -
    -  renderer.setScissorTest(false);
    -  renderer.setClearColor(clearColor, 0);
    -  renderer.clear(true, true);
    -  renderer.setScissorTest(true);
    -
    -  const transform = `translateY(${window.scrollY}px)`;
    -  renderer.domElement.style.transform = transform;
    -
    -  for (const {elem, fn} of sceneElements) {
    -    // получаем относительную позицию окна просмотра этого элемента
    -    const rect = elem.getBoundingClientRect();
    -    const {left, right, top, bottom, width, height} = rect;
    -
    -    const isOffscreen =
    -        bottom < 0 ||
    -        top > renderer.domElement.clientHeight ||
    -        right < 0 ||
    -        left > renderer.domElement.clientWidth;
    -
    -    if (!isOffscreen) {
    -      const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
    -      renderer.setScissor(left, positiveYUpBottom, width, height);
    -      renderer.setViewport(left, positiveYUpBottom, width, height);
    -
    -      fn(time, rect);
    -    }
    -  }
    -
    -  requestAnimationFrame(render);
    -}
    -
    -

    Вы можете видеть, что он зацикливается на массиве объектов sceneElements, -каждый из которых имеет свойство elem и fn.

    -

    Он проверяет, находится ли элемент на экране. Если это так, то он вызывает fn -и передает ему текущее время и его прямоугольник.

    -

    Теперь установочный код для каждой сцены просто добавляет себя в список сцен

    -
    {
    -  const elem = document.querySelector('#box');
    -  const {scene, camera} = makeScene();
    -  const geometry = new THREE.BoxGeometry(1, 1, 1);
    -  const material = new THREE.MeshPhongMaterial({color: 'red'});
    -  const mesh = new THREE.Mesh(geometry, material);
    -  scene.add(mesh);
    -  addScene(elem, (time, rect) => {
    -    camera.aspect = rect.width / rect.height;
    -    camera.updateProjectionMatrix();
    -    mesh.rotation.y = time * .1;
    -    renderer.render(scene, camera);
    -  });
    -}
    -
    -{
    -  const elem = document.querySelector('#pyramid');
    -  const {scene, camera} = makeScene();
    -  const radius = .8;
    -  const widthSegments = 4;
    -  const heightSegments = 2;
    -  const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
    -  const material = new THREE.MeshPhongMaterial({
    -    color: 'blue',
    -    flatShading: true,
    -  });
    -  const mesh = new THREE.Mesh(geometry, material);
    -  scene.add(mesh);
    -  addScene(elem, (time, rect) => {
    -    camera.aspect = rect.width / rect.height;
    -    camera.updateProjectionMatrix();
    -    mesh.rotation.y = time * .1;
    -    renderer.render(scene, camera);
    -  });
    -}
    -
    -

    При этом нам больше не нужно sceneInfo1 и sceneInfo2. -Код, который вращал меши, теперь специфичен для каждой сцены.

    -

    - -

    -

    Использование набора данных в HTML

    -

    Еще одна, еще более общая вещь, которую мы можем сделать, это использовать -dataset. -Это способ добавить свои собственные данные в элемент HTML. Вместо использования id="..." -мы будем использовать data-diagram="..." как тут

    -
    <canvas id="c"></canvas>
    -<p>
    --  <span id="box" class="diagram left"></span>
    -+  <span data-diagram="box" class="left"></span>
    -  Я люблю коробки (boxes). Подарки приходят в коробках.
    -  Когда я нахожу новую коробку, я всегда рад узнать, что внутри.
    -</p>
    -<p>
    --  <span id="pyramid" class="diagram left"></span>
    -+  <span data-diagram="pyramid" class="right"></span>
    -   Когда я был ребенком, я мечтал отправиться в экспедицию внутри пирамиды (pyramid).
    -   и найти неоткрытую гробницу, полную мумий и сокровищ.
    -</p>
    -
    -

    Мы можем их изменить селектор CSS, чтобы выбрать для этого

    -
    -.diagram
    -+*[data-diagram] {
    -  display: inline-block;
    -  width: 5em;
    -  height: 3em;
    -}
    -
    -

    Мы изменим код установки сцены, чтобы он представлял собой карту имен для функций инициализации сцены -, которые возвращают функцию отрисовки сцены.

    -
    const sceneInitFunctionsByName = {
    -  'box': () => {
    -    const {scene, camera} = makeScene();
    -    const geometry = new THREE.BoxGeometry(1, 1, 1);
    -    const material = new THREE.MeshPhongMaterial({color: 'red'});
    -    const mesh = new THREE.Mesh(geometry, material);
    -    scene.add(mesh);
    -    return (time, rect) => {
    -      mesh.rotation.y = time * .1;
    -      camera.aspect = rect.width / rect.height;      camera.updateProjectionMatrix();
    -      renderer.render(scene, camera);
    -    };
    -  },
    -  'pyramid': () => {
    -    const {scene, camera} = makeScene();
    -    const radius = .8;
    -    const widthSegments = 4;
    -    const heightSegments = 2;
    -    const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
    -    const material = new THREE.MeshPhongMaterial({
    -      color: 'blue',
    -      flatShading: true,
    -    });
    -    const mesh = new THREE.Mesh(geometry, material);
    -    scene.add(mesh);
    -    return (time, rect) => {
    -      mesh.rotation.y = time * .1;
    -      camera.aspect = rect.width / rect.height;
    -      camera.updateProjectionMatrix();
    -      renderer.render(scene, camera);
    -    };
    -  },
    -};
    -
    -

    И для инициализации мы можем просто использовать querySelectorAll, -чтобы найти все диаграммы и вызвать соответствующую функцию инициализации для этой диаграммы.

    -
    document.querySelectorAll('[data-diagram]').forEach((elem) => {
    -  const sceneName = elem.dataset.diagram;
    -  const sceneInitFunction = sceneInitFunctionsByName[sceneName];
    -  const sceneRenderFunction = sceneInitFunction(elem);
    -  addScene(elem, sceneRenderFunction());
    -});
    -
    -

    Никаких изменений в визуальных элементах, но код легче переиспользовать.

    -

    -

    Добавление элементов управления к каждому элементу

    -

    Например, добавление TrackballControls в интерактивном режиме так же просто. -Сначала мы добавляем скрипт для контролов.

    -
    import {TrackballControls} from 'three/addons/controls/TrackballControls.js';
    -
    -

    И затем мы можем добавить TrackballControls к каждой сцене, передавая элемент, связанный с этой сценой.

    -
    -function makeScene() {
    -+function makeScene(elem) {
    -  const scene = new THREE.Scene();
    -
    -  const fov = 45;
    -  const aspect = 2;  // по умолчанию для canvas
    -  const near = 0.1;
    -  const far = 5;
    -  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -  camera.position.set(0, 1, 2);
    -  camera.lookAt(0, 0, 0);
    -+  scene.add(camera);
    -
    -+  const controls = new TrackballControls(camera, elem);
    -+  controls.noZoom = true;
    -+  controls.noPan = true;
    -
    -  {
    -    const color = 0xFFFFFF;
    -    const intensity = 1;
    -    const light = new THREE.DirectionalLight(color, intensity);
    -    light.position.set(-1, 2, 4);
    --    scene.add(light);
    -+    camera.add(light);
    -  }
    -
    --  return {scene, camera};
    -+ return {scene, camera, controls};
    -}
    -
    -

    Вы заметите, что мы добавили камеру на сцену и свет на камеру. Это делает свет -относительно камеры. Поскольку TrackballControls двигает камеру, -это, вероятно, то, что мы хотим. Он сохраняет свет, -сияющий на стороне объекта, на который мы смотрим.

    -

    Нам нужно обновить эти элементы управления в наших функциях отрисовки

    -
    const sceneInitFunctionsByName = {
    -- 'box': () => {
    --    const {scene, camera} = makeScene();
    -+ 'box': (elem) => {
    -+    const {scene, camera, controls} = makeScene(elem);
    -    const geometry = new THREE.BoxGeometry(1, 1, 1);
    -    const material = new THREE.MeshPhongMaterial({color: 'red'});
    -    const mesh = new THREE.Mesh(geometry, material);
    -    scene.add(mesh);
    -    return (time, rect) => {
    -      mesh.rotation.y = time * .1;
    -      camera.aspect = rect.width / rect.height;
    -      camera.updateProjectionMatrix();
    -+      controls.handleResize();
    -+      controls.update();
    -      renderer.render(scene, camera);
    -    };
    -  },
    --  'pyramid': () => {
    --    const {scene, camera} = makeScene();
    -+  'pyramid': (elem) => {
    -+    const {scene, camera, controls} = makeScene(elem);
    -    const radius = .8;
    -    const widthSegments = 4;
    -    const heightSegments = 2;
    -    const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
    -    const material = new THREE.MeshPhongMaterial({
    -      color: 'blue',
    -      flatShading: true,
    -    });
    -    const mesh = new THREE.Mesh(geometry, material);
    -    scene.add(mesh);
    -    return (time, rect) => {
    -      mesh.rotation.y = time * .1;
    -      camera.aspect = rect.width / rect.height;
    -      camera.updateProjectionMatrix();
    -+      controls.handleResize();
    -+      controls.update();
    -      renderer.render(scene, camera);
    -    };
    -  },
    -};
    -
    -

    Теперь, если вы перетащите объекты, они будут вращаться.

    -

    -

    Эти методы используются на самом сайте. В частности, -статья о примитивах и о материалах -используют эту технику для добавления различных примеров по всей статье.

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/offscreencanvas.html b/manual/ru/offscreencanvas.html deleted file mode 100644 index 3e8edf4b2756e8..00000000000000 --- a/manual/ru/offscreencanvas.html +++ /dev/null @@ -1,1010 +0,0 @@ - - - OffscreenCanvas - - - - - - - - - - - - - -
    -
    -

    OffscreenCanvas

    -
    -
    -
    -

    OffscreenCanvas - это относительно новая функция браузера, которая в настоящее время доступна только в Chrome, - но, очевидно, будет доступна и в других браузерах. OffscreenCanvas позволяет веб-воркеру выполнять - рендеринг на холст. Это способ переложить тяжелую работу, такую ​​как рендеринг сложной 3D-сцены, на веб-воркера, чтобы не замедлить скорость отклика браузера. - Это также означает, что данные загружаются и анализируются в воркере, поэтому возможно меньше мусора во время загрузки страницы.

    -

    Начать использовать его довольно просто. Давайте разберём пример 3 вращающихся кубов из статьи об отзывчивости.

    -

    Обычно у воркера есть свой код, разделенный в другой файл сценария. Для большинства примеров на этом сайте скрипты встроены в HTML-файл страницы, на которой они находятся.

    -

    В нашем случае мы создадим файл с именем offscreencanvas-cubes.js и скопируем в него весь JavaScript из адаптивного примера. Затем мы внесем изменения, необходимые для его работы в воркере.

    -

    Нам все еще нужен JavaScript в нашем HTML-файле. Первое, что нам нужно сделать там, это найти холст, -а затем передать управление этим холстом за пределы экрана, вызвав canvas.transferControlToOffscreen.

    -
    function main() {
    -  const canvas = document.querySelector('#c');
    -  const offscreen = canvas.transferControlToOffscreen();
    -
    -  ...
    -
    -

    Затем мы можем запустить наш воркер с new Worker(pathToScript, {type: 'module'}). -и передать ему offscreen.

    -
    function main() {
    -  const canvas = document.querySelector('#c');
    -  const offscreen = canvas.transferControlToOffscreen();
    -  const worker = new Worker('offscreencanvas-cubes.js', {type: 'module'});
    -  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
    -}
    -main();
    -
    -

    Важно отметить, что воркеры не могут получить доступ к DOM. Они не могут просматривать элементы HTML, а также получать события мыши или клавиатуры. -Единственное, что они обычно могут делать, - это отвечать на отправленные им сообщения.

    -

    Чтобы отправить сообщение воркеру, мы вызываем worker.postMessage and -и передаем ему 1 или 2 аргумента. Первый аргумент - это объект JavaScript, который будет клонирован -и отправлен исполнителю. Второй аргумент - это необязательный массив объектов, -которые являются частью первого объекта, который мы хотим передать воркеру. -Эти объекты не будут клонированы. Вместо этого они будут перенесены и перестанут существовать на главной странице. -Прекращение существования - это, вероятно, неправильное описание, скорее они кастрированы. -Вместо клонирования можно передавать только определенные типы объектов. -Они включают OffscreenCanvas, поэтому после переноса offscreen обратно на главную страницу он бесполезен.

    -

    Воркеры получают сообщения от своего обработчика сообщений onmessage. Объект, -который мы передали в postMessage, прибывает в объект event.data, переданный -обработчику onmessage на воркере. В приведенном выше коде объявляется type: 'main' в объекте, который он передает воркеру. Мы создадим обработчик, -который на основе типа будет вызывать другую функцию в воркере. Затем мы можем добавлять функции по мере необходимости и легко вызывать их с главной страницы.

    -
    const handlers = {
    -  main,
    -};
    -
    -self.onmessage = function(e) {
    -  const fn = handlers[e.data.type];
    -  if (typeof fn !== 'function') {
    -    throw new Error('no handler for type: ' + e.data.type);
    -  }
    -  fn(e.data);
    -};
    -
    -

    Вы можете видеть выше, что мы просто ищем обработчик в зависимости от type, передаем ему data, которые были отправлены с главной страницы.

    -

    Итак, теперь нам просто нужно начать изменять основной файл, который мы вставили в offscreencanvas-cubes.js - из адаптивной статьи.

    -

    Затем вместо того, чтобы искать холст в DOM, мы получим его из данных события.

    -
    -function main() {
    --  const canvas = document.querySelector('#c');
    -+function main(data) {
    -+  const {canvas} = data;
    -  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -
    -  ...
    -
    -

    Помня о том, что воркеры вообще не видят DOM, первая проблема, с которой мы сталкиваемся, - -resizeRendererToDisplaySize не может смотреть на canvas.clientWidth и canvas.clientHeight, поскольку это значения DOM. Вот исходный код

    -
    function resizeRendererToDisplaySize(renderer) {
    -  const canvas = renderer.domElement;
    -  const width = canvas.clientWidth;
    -  const height = canvas.clientHeight;
    -  const needResize = canvas.width !== width || canvas.height !== height;
    -  if (needResize) {
    -    renderer.setSize(width, height, false);
    -  }
    -  return needResize;
    -}
    -
    -

    Вместо этого нам нужно будет отправлять размеры по мере их изменения воркеру. Итак, давайте добавим некоторое глобальное состояние и сохраним там ширину и высоту.

    -
    const state = {
    -  width: 300,  // canvas default
    -  height: 150,  // canvas default
    -};
    -
    -

    Затем добавим обработчик size для обновления этих значений.

    -
    +function size(data) {
    -+  state.width = data.width;
    -+  state.height = data.height;
    -+}
    -
    -const handlers = {
    -  main,
    -+  size,
    -};
    -
    -

    Теперь мы можем изменить resizeRendererToDisplaySize, чтобы использовать state.width и state.height.

    -
    function resizeRendererToDisplaySize(renderer) {
    -  const canvas = renderer.domElement;
    --  const width = canvas.clientWidth;
    --  const height = canvas.clientHeight;
    -+  const width = state.width;
    -+  const height = state.height;
    -  const needResize = canvas.width !== width || canvas.height !== height;
    -  if (needResize) {
    -    renderer.setSize(width, height, false);
    -  }
    -  return needResize;
    -}
    -
    -

    и где мы вычисляем аспект, который нам нужен, аналогичные изменения

    -
    function render(time) {
    -  time *= 0.001;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    --    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -+    camera.aspect = state.width / state.height;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -  ...
    -
    -

    Вернувшись на главную страницу, мы будем отправлять событие size каждый раз, когда страница меняет размер.

    -
    const worker = new Worker('offscreencanvas-picking.js', {type: 'module'});
    -worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
    -
    -+function sendSize() {
    -+  worker.postMessage({
    -+    type: 'size',
    -+    width: canvas.clientWidth,
    -+    height: canvas.clientHeight,
    -+  });
    -+}
    -+
    -+window.addEventListener('resize', sendSize);
    -+sendSize();
    -
    -

    Мы также вызываем его один раз, чтобы отправить начальный размер.

    -

    И всего с этими несколькими изменениями, если ваш браузер полностью -поддерживает OffscreenCanvas, он должен работать. Прежде чем запустить его, -давайте проверим, действительно ли браузер поддерживает OffscreenCanvas, -и не отобразит ли он ошибку. Сначала добавим HTML-код для отображения ошибки.

    -
    <body>
    -  <canvas id="c"></canvas>
    -+  <div id="noOffscreenCanvas" style="display:none;">
    -+    <div>no OffscreenCanvas support</div>
    -+  </div>
    -</body>
    -
    -

    и немного CSS для этого

    -
    #noOffscreenCanvas {
    -    display: flex;
    -    width: 100%;
    -    height: 100%;
    -    align-items: center;
    -    justify-content: center;
    -    background: red;
    -    color: white;
    -}
    -
    -

    а затем мы можем проверить наличие transferControlToOffscreen, чтобы узнать, поддерживает ли браузер OffscreenCanvas

    -
    function main() {
    -  const canvas = document.querySelector('#c');
    -+  if (!canvas.transferControlToOffscreen) {
    -+    canvas.style.display = 'none';
    -+    document.querySelector('#noOffscreenCanvas').style.display = '';
    -+    return;
    -+  }
    -  const offscreen = canvas.transferControlToOffscreen();
    -  const worker = new Worker('offscreencanvas-picking.js', {type: 'module});
    -  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
    -
    -  ...
    -
    -

    и при этом, если ваш браузер поддерживает OffscreenCanvas, этот пример должен работать

    -

    - -

    -

    Так что это здорово, но поскольку не каждый браузер поддерживает OffscreenCanvas на данный момент, -давайте изменим код для работы с OffscreenCanvas, а если нет, то вернемся к использованию холста на главной странице, как обычно.

    -

    Кстати, если вам нужен OffscreenCanvas, чтобы ваша страница была отзывчивой, тогда неясно, -в чем смысл использования запасного варианта. Возможно, в зависимости от того, выполняете ли -вы в конечном итоге работу на главной странице или в воркере, вы можете настроить объем выполняемой работы так, -чтобы при работе в воркере вы могли делать больше, чем при работе на главной странице. Что вы делаете, действительно зависит от вас.

    -

    Первое, что нам, вероятно, следует сделать, - это отделить код three.js от кода, -специфичного для воркера. Что мы можем использовать один и тот же код как на главной странице, так и на рабочем. Другими словами, теперь у нас будет 3 файла

    -
      -
    1. наш html файл.

      -

      threejs-offscreencanvas-w-fallback.html

      -
    2. -
    3. JavaScript, содержащий наш код three.js.

      -

      shared-cubes.js

      -
    4. -
    5. наш код поддержки воркера

      -

      offscreencanvas-worker-cubes.js

      -
    6. -
    -

    shared-cubes.js и offscreencanvas-worker-cubes.js по сути являются разделением нашего -предыдущего файла offscreencanvas-cubes.js. Сначала мы копируем весь файл offscreencanvas-cubes.js в shared-cube.js. Затем мы переименовываем main в init, так как у нас уже есть main в нашем HTML-файле, и нам нужно экспортировать init и состояние

    -
    import * as THREE from 'three';
    -
    --const state = {
    -+export const state = {
    -  width: 300,   // canvas default
    -  height: 150,  // canvas default
    -};
    -
    --function main(data) {
    -+export function init(data) {
    -  const {canvas} = data;
    -  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -
    -

    и вырезать только части, не относящиеся к three.js

    -
    -function size(data) {
    --  state.width = data.width;
    --  state.height = data.height;
    --}
    --
    --const handlers = {
    --  main,
    --  size,
    --};
    --
    --self.onmessage = function(e) {
    --  const fn = handlers[e.data.type];
    --  if (typeof fn !== 'function') {
    --    throw new Error('no handler for type: ' + e.data.type);
    --  }
    --  fn(e.data);
    --};
    -
    -

    Затем мы копируем те части, которые мы только что удалили в offscreencanvas-worker-cubes.js. -и импорт shared-cubes.js, а также вызов init вместо main.

    -
    import {init, state} from './shared-cubes.js';
    -
    -function size(data) {
    -  state.width = data.width;
    -  state.height = data.height;
    -}
    -
    -const handlers = {
    --  main,
    -+  init,
    -  size,
    -};
    -
    -self.onmessage = function(e) {
    -  const fn = handlers[e.data.type];
    -  if (typeof fn !== 'function') {
    -    throw new Error('no handler for type: ' + e.data.type);
    -  }
    -  fn(e.data);
    -};
    -
    -

    Точно так же нам нужно включить three.js и shared-cubes.js на главную страницу.

    -
    <script type="module">
    -+import {init, state} from './shared-cubes.js';
    -
    -

    Мы можем удалить HTML и CSS, которые мы добавили ранее

    -
    <body>
    -  <canvas id="c"></canvas>
    --  <div id="noOffscreenCanvas" style="display:none;">
    --    <div>no OffscreenCanvas support</div>
    --  </div>
    -</body>
    -
    -

    и немного CSS для этого

    -
    -#noOffscreenCanvas {
    --    display: flex;
    --    width: 100%;
    --    height: 100%;
    --    align-items: center;
    --    justify-content: center;
    --    background: red;
    --    color: white;
    --}
    -
    -

    Затем давайте изменим код на главной странице для вызова той или иной функции запуска в зависимости от того, поддерживает ли браузер OffscreenCanvas.

    -
    function main() {
    -  const canvas = document.querySelector('#c');
    --  if (!canvas.transferControlToOffscreen) {
    --    canvas.style.display = 'none';
    --    document.querySelector('#noOffscreenCanvas').style.display = '';
    --    return;
    --  }
    --  const offscreen = canvas.transferControlToOffscreen();
    --  const worker = new Worker('offscreencanvas-picking.js', {type: 'module'});
    --  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
    -+  if (canvas.transferControlToOffscreen) {
    -+    startWorker(canvas);
    -+  } else {
    -+    startMainPage(canvas);
    -+  }
    -  ...
    -
    -

    Мы переместим весь код, который у нас был для настройки воркера, внутрь startWorker.

    -
    function startWorker(canvas) {
    -  const offscreen = canvas.transferControlToOffscreen();
    -  const worker = new Worker('offscreencanvas-worker-cubes.js', {type: 'module'});
    -  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
    -
    -  function sendSize() {
    -    worker.postMessage({
    -      type: 'size',
    -      width: canvas.clientWidth,
    -      height: canvas.clientHeight,
    -    });
    -  }
    -
    -  window.addEventListener('resize', sendSize);
    -  sendSize();
    -
    -  console.log('using OffscreenCanvas');
    -}
    -
    -

    и отправить init вместо main

    -
    -  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
    -+  worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);
    -
    -

    для начала на главной странице мы можем сделать это

    -
    function startMainPage(canvas) {
    -  init({canvas});
    -
    -  function sendSize() {
    -    state.width = canvas.clientWidth;
    -    state.height = canvas.clientHeight;
    -  }
    -  window.addEventListener('resize', sendSize);
    -  sendSize();
    -
    -  console.log('using regular canvas');
    -}
    -
    -

    и с этим наш пример будет запускаться либо в OffscreenCanvas, либо в качестве альтернативы запуску на главной странице.

    -

    - -

    -

    Так что это было относительно легко. Попробуем поковырять. -Мы возьмем код из примера RayCaster из и статьи о выборе -заставим его работать за экраном.

    -

    Давайте скопируем shared-cube.js в shared-picking.js и добавим части выбора. Копируем в PickHelper

    -
    class PickHelper {
    -  constructor() {
    -    this.raycaster = new THREE.Raycaster();
    -    this.pickedObject = null;
    -    this.pickedObjectSavedColor = 0;
    -  }
    -  pick(normalizedPosition, scene, camera, time) {
    -    // restore the color if there is a picked object
    -    if (this.pickedObject) {
    -      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
    -      this.pickedObject = undefined;
    -    }
    -
    -    // cast a ray through the frustum
    -    this.raycaster.setFromCamera(normalizedPosition, camera);
    -    // get the list of objects the ray intersected
    -    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
    -    if (intersectedObjects.length) {
    -      // pick the first object. It's the closest one
    -      this.pickedObject = intersectedObjects[0].object;
    -      // save its color
    -      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
    -      // set its emissive color to flashing red/yellow
    -      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
    -    }
    -  }
    -}
    -
    -const pickPosition = {x: 0, y: 0};
    -const pickHelper = new PickHelper();
    -
    -

    Мы обновили pickPosition с помощью мыши вот так

    -
    function getCanvasRelativePosition(event) {
    -  const rect = canvas.getBoundingClientRect();
    -  return {
    -    x: (event.clientX - rect.left) * canvas.width  / rect.width,
    -    y: (event.clientY - rect.top ) * canvas.height / rect.height,
    -  };
    -}
    -
    -function setPickPosition(event) {
    -  const pos = getCanvasRelativePosition(event);
    -  pickPosition.x = (pos.x / canvas.width ) *  2 - 1;
    -  pickPosition.y = (pos.y / canvas.height) * -2 + 1;  // note we flip Y
    -}
    -window.addEventListener('mousemove', setPickPosition);
    -
    -

    Воркер не может напрямую считывать положение мыши, поэтому, как и код размера, давайте отправим сообщение с указанием положения мыши. -Как и код размера, мы отправим позицию мыши и обновим pickPosition

    -
    function size(data) {
    -  state.width = data.width;
    -  state.height = data.height;
    -}
    -
    -+function mouse(data) {
    -+  pickPosition.x = data.x;
    -+  pickPosition.y = data.y;
    -+}
    -
    -const handlers = {
    -  init,
    -+  mouse,
    -  size,
    -};
    -
    -self.onmessage = function(e) {
    -  const fn = handlers[e.data.type];
    -  if (typeof fn !== 'function') {
    -    throw new Error('no handler for type: ' + e.data.type);
    -  }
    -  fn(e.data);
    -};
    -
    -

    Вернувшись на нашу главную страницу, нам нужно добавить код, чтобы передать мышь воркеру или главной странице.

    -
    +let sendMouse;
    -
    -function startWorker(canvas) {
    -  const offscreen = canvas.transferControlToOffscreen();
    -  const worker = new Worker('offscreencanvas-worker-picking.js', {type: 'module'});
    -  worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);
    -
    -+  sendMouse = (x, y) => {
    -+    worker.postMessage({
    -+      type: 'mouse',
    -+      x,
    -+      y,
    -+    });
    -+  };
    -
    -  function sendSize() {
    -    worker.postMessage({
    -      type: 'size',
    -      width: canvas.clientWidth,
    -      height: canvas.clientHeight,
    -    });
    -  }
    -
    -  window.addEventListener('resize', sendSize);
    -  sendSize();
    -
    -  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
    -}
    -
    -function startMainPage(canvas) {
    -  init({canvas});
    -
    -+  sendMouse = (x, y) => {
    -+    pickPosition.x = x;
    -+    pickPosition.y = y;
    -+  };
    -
    -  function sendSize() {
    -    state.width = canvas.clientWidth;
    -    state.height = canvas.clientHeight;
    -  }
    -  window.addEventListener('resize', sendSize);
    -  sendSize();
    -
    -  console.log('using regular canvas');  /* eslint-disable-line no-console */
    -}
    -
    -

    Затем мы можем скопировать весь код обработки мыши на главную страницу и внести незначительные изменения, чтобы использовать sendMouse.

    -
    function setPickPosition(event) {
    -  const pos = getCanvasRelativePosition(event);
    --  pickPosition.x = (pos.x / canvas.clientWidth ) *  2 - 1;
    --  pickPosition.y = (pos.y / canvas.clientHeight) * -2 + 1;  // note we flip Y
    -+  sendMouse(
    -+      (pos.x / canvas.clientWidth ) *  2 - 1,
    -+      (pos.y / canvas.clientHeight) * -2 + 1);  // note we flip Y
    -}
    -
    -function clearPickPosition() {
    -  // unlike the mouse which always has a position
    -  // if the user stops touching the screen we want
    -  // to stop picking. For now we just pick a value
    -  // unlikely to pick something
    --  pickPosition.x = -100000;
    --  pickPosition.y = -100000;
    -+  sendMouse(-100000, -100000);
    -}
    -window.addEventListener('mousemove', setPickPosition);
    -window.addEventListener('mouseout', clearPickPosition);
    -window.addEventListener('mouseleave', clearPickPosition);
    -
    -window.addEventListener('touchstart', (event) => {
    -  // prevent the window from scrolling
    -  event.preventDefault();
    -  setPickPosition(event.touches[0]);
    -}, {passive: false});
    -
    -window.addEventListener('touchmove', (event) => {
    -  setPickPosition(event.touches[0]);
    -});
    -
    -window.addEventListener('touchend', clearPickPosition);
    -
    -

    и с этим выбором следует работать с OffscreenCanvas.

    -

    - -

    -

    Сделаем еще один шаг и добавим OrbitControls. Это будет немного больше. -OrbitControls довольно широко используют DOM для проверки мыши, событий касания и клавиатуры.

    -

    В отличие от нашего кода, мы не можем использовать объект глобального state, не переписав весь код OrbitControls для работы с ним. OrbitControls принимают элемент, к которому они присоединяют большинство используемых ими событий DOM. Возможно, мы могли бы передать наш собственный объект, имеющий ту же поверхность API, что и элемент DOM. Нам нужно только поддерживать функции, которые необходимы OrbitControls.

    -

    Копаясь в исходном коде OrbitControls -похоже, что нам нужно обработать следующие события.

    -
      -
    • contextmenu
    • -
    • pointerdown
    • -
    • pointermove
    • -
    • pointerup
    • -
    • touchstart
    • -
    • touchmove
    • -
    • touchend
    • -
    • wheel
    • -
    • keydown
    • -
    -

    Для событий мыши нам нужны свойства ctrlKey, metaKey, shiftKey, -button, pointerType, clientX, clientY, pageX, и pageY.

    -

    Для событий нажатия клавиатуры нам нужны свойства ctrlKey, metaKey, shiftKey, -и keyCode.

    -

    Для события wheel нам нужно только свойство deltaY

    -

    А для событий касания нам понадобятся только pageX и pageY из свойства touches.

    -

    Итак, создадим пару прокси-объектов. Одна часть будет работать на главной странице, получать все эти события и передавать соответствующие значения свойств воркеру. Другая часть будет запускаться в воркере, получать эти события и передавать их, используя события, которые имеют ту же структуру, что и исходные события DOM, поэтому OrbitControls не сможет определить разницу.

    -

    Вот код рабочей части.

    -
    import {EventDispatcher} from 'three';
    -
    -class ElementProxyReceiver extends EventDispatcher {
    -  constructor() {
    -    super();
    -  }
    -  handleEvent(data) {
    -    this.dispatchEvent(data);
    -  }
    -}
    -
    -

    Все, что он делает, - это если он получает сообщение, то отправляет его. Он наследуется от EventDispatcher, который предоставляет такие методы, как addEventListener и removeEventListener, точно так же, как элемент DOM, поэтому, если мы передадим его в OrbitControls, он должен работать.

    -

    ElementProxyReceiver обрабатывает 1 элемент. В нашем случае нам нужен только один, но лучше думать головой, так что давайте заставим менеджера управлять более чем одним из них.

    -
    class ProxyManager {
    -  constructor() {
    -    this.targets = {};
    -    this.handleEvent = this.handleEvent.bind(this);
    -  }
    -  makeProxy(data) {
    -    const {id} = data;
    -    const proxy = new ElementProxyReceiver();
    -    this.targets[id] = proxy;
    -  }
    -  getProxy(id) {
    -    return this.targets[id];
    -  }
    -  handleEvent(data) {
    -    this.targets[data.id].handleEvent(data.data);
    -  }
    -}
    -
    -

    Мы можем создать экземпляр ProxyManager и вызвать его метод makeProxy с идентификатором, который создаст ElementProxyReceiver, который будет отвечать на сообщения с этим идентификатором.

    -

    Давайте подключим его к обработчику сообщений нашего воркера.

    -
    const proxyManager = new ProxyManager();
    -
    -function start(data) {
    -  const proxy = proxyManager.getProxy(data.canvasId);
    -  init({
    -    canvas: data.canvas,
    -    inputElement: proxy,
    -  });
    -}
    -
    -function makeProxy(data) {
    -  proxyManager.makeProxy(data);
    -}
    -
    -...
    -
    -const handlers = {
    --  init,
    --  mouse,
    -+  start,
    -+  makeProxy,
    -+  event: proxyManager.handleEvent,
    -   size,
    -};
    -
    -self.onmessage = function(e) {
    -  const fn = handlers[e.data.type];
    -  if (typeof fn !== 'function') {
    -    throw new Error('no handler for type: ' + e.data.type);
    -  }
    -  fn(e.data);
    -};
    -
    -

    Нам также нужно добавить OrbitControls в начало скрипта.

    -
    import * as THREE from 'three';
    -+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -
    -export function init(data) {
    --  const {canvas} = data;
    -+  const {canvas, inputElement} = data;
    -  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -
    -+  const controls = new OrbitControls(camera, inputElement);
    -+  controls.target.set(0, 0, 0);
    -+  controls.update();
    -
    -

    Обратите внимание, что мы передаем OrbitControls нашему прокси через inputElement вместо передачи холста, как в других примерах, отличных от OffscreenCanvas.

    -

    Затем мы можем переместить весь код события выбора из файла HTML в общий код three.js, а также изменить canvas на inputElement.

    -
    function getCanvasRelativePosition(event) {
    --  const rect = canvas.getBoundingClientRect();
    -+  const rect = inputElement.getBoundingClientRect();
    -  return {
    -    x: event.clientX - rect.left,
    -    y: event.clientY - rect.top,
    -  };
    -}
    -
    -function setPickPosition(event) {
    -  const pos = getCanvasRelativePosition(event);
    --  sendMouse(
    --      (pos.x / canvas.clientWidth ) *  2 - 1,
    --      (pos.y / canvas.clientHeight) * -2 + 1);  // note we flip Y
    -+  pickPosition.x = (pos.x / inputElement.clientWidth ) *  2 - 1;
    -+  pickPosition.y = (pos.y / inputElement.clientHeight) * -2 + 1;  // note we flip Y
    -}
    -
    -function clearPickPosition() {
    -  // unlike the mouse which always has a position
    -  // if the user stops touching the screen we want
    -  // to stop picking. For now we just pick a value
    -  // unlikely to pick something
    --  sendMouse(-100000, -100000);
    -+  pickPosition.x = -100000;
    -+  pickPosition.y = -100000;
    -}
    -
    -*inputElement.addEventListener('mousemove', setPickPosition);
    -*inputElement.addEventListener('mouseout', clearPickPosition);
    -*inputElement.addEventListener('mouseleave', clearPickPosition);
    -
    -*inputElement.addEventListener('touchstart', (event) => {
    -  // prevent the window from scrolling
    -  event.preventDefault();
    -  setPickPosition(event.touches[0]);
    -}, {passive: false});
    -
    -*inputElement.addEventListener('touchmove', (event) => {
    -  setPickPosition(event.touches[0]);
    -});
    -
    -*inputElement.addEventListener('touchend', clearPickPosition);
    -
    -

    Вернувшись на главную страницу, нам нужен код для отправки сообщений для всех событий, которые мы перечислили выше.

    -
    let nextProxyId = 0;
    -class ElementProxy {
    -  constructor(element, worker, eventHandlers) {
    -    this.id = nextProxyId++;
    -    this.worker = worker;
    -    const sendEvent = (data) => {
    -      this.worker.postMessage({
    -        type: 'event',
    -        id: this.id,
    -        data,
    -      });
    -    };
    -
    -    // register an id
    -    worker.postMessage({
    -      type: 'makeProxy',
    -      id: this.id,
    -    });
    -    for (const [eventName, handler] of Object.entries(eventHandlers)) {
    -      element.addEventListener(eventName, function(event) {
    -        handler(event, sendEvent);
    -      });
    -    }
    -  }
    -}
    -
    -

    ElementProxy берет элемент, события которого мы хотим проксировать. Затем он регистрирует идентификатор у воркера, выбирая его и отправляя через сообщение makeProxy, которое мы настроили ранее. Рабочий создаст ElementProxyReceiver и зарегистрирует его для этого идентификатора.

    -

    Затем у нас есть объект обработчиков событий для регистрации. Таким образом, мы можем передавать обработчики только тех событий, которые мы хотим переслать воркеру.

    -

    Когда мы запускаем воркер, мы сначала создаем прокси и передаем наши обработчики событий.

    -
    function startWorker(canvas) {
    -  const offscreen = canvas.transferControlToOffscreen();
    -  const worker = new Worker('offscreencanvas-worker-orbitcontrols.js', {type: 'module'});
    -
    -+  const eventHandlers = {
    -+    contextmenu: preventDefaultHandler,
    -+    mousedown: mouseEventHandler,
    -+    mousemove: mouseEventHandler,
    -+    mouseup: mouseEventHandler,
    -+    pointerdown: mouseEventHandler,
    -+    pointermove: mouseEventHandler,
    -+    pointerup: mouseEventHandler,
    -+    touchstart: touchEventHandler,
    -+    touchmove: touchEventHandler,
    -+    touchend: touchEventHandler,
    -+    wheel: wheelEventHandler,
    -+    keydown: filteredKeydownEventHandler,
    -+  };
    -+  const proxy = new ElementProxy(canvas, worker, eventHandlers);
    -  worker.postMessage({
    -    type: 'start',
    -    canvas: offscreen,
    -+    canvasId: proxy.id,
    -  }, [offscreen]);
    -  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
    -}
    -
    -

    А вот и обработчики событий. Все, что они делают, - это копируют список свойств из полученного события. Им передается функция sendEvent, в которую они передают созданные данные. Эта функция добавит правильный идентификатор и отправит его воркеру.

    -
    const mouseEventHandler = makeSendPropertiesHandler([
    -  'ctrlKey',
    -  'metaKey',
    -  'shiftKey',
    -  'button',
    -  'pointerType',
    -  'clientX',
    -  'clientY',
    -  'pointerId',
    -  'pageX',
    -  'pageY',
    -]);
    -const wheelEventHandlerImpl = makeSendPropertiesHandler([
    -  'deltaX',
    -  'deltaY',
    -]);
    -const keydownEventHandler = makeSendPropertiesHandler([
    -  'ctrlKey',
    -  'metaKey',
    -  'shiftKey',
    -  'keyCode',
    -]);
    -
    -function wheelEventHandler(event, sendFn) {
    -  event.preventDefault();
    -  wheelEventHandlerImpl(event, sendFn);
    -}
    -
    -function preventDefaultHandler(event) {
    -  event.preventDefault();
    -}
    -
    -function copyProperties(src, properties, dst) {
    -  for (const name of properties) {
    -      dst[name] = src[name];
    -  }
    -}
    -
    -function makeSendPropertiesHandler(properties) {
    -  return function sendProperties(event, sendFn) {
    -    const data = {type: event.type};
    -    copyProperties(event, properties, data);
    -    sendFn(data);
    -  };
    -}
    -
    -function touchEventHandler(event, sendFn) {
    -  // preventDefault() fixes mousemove, mouseup and mousedown 
    -  // firing when doing a simple touchup touchdown
    -  // Happens only at offscreen canvas
    -  event.preventDefault(); 
    -  const touches = [];
    -  const data = {type: event.type, touches};
    -  for (let i = 0; i < event.touches.length; ++i) {
    -    const touch = event.touches[i];
    -    touches.push({
    -      pageX: touch.pageX,
    -      pageY: touch.pageY,
    -      clientX: touch.clientX,
    -      clientY: touch.clientY,
    -    });
    -  }
    -  sendFn(data);
    -}
    -
    -// The four arrow keys
    -const orbitKeys = {
    -  '37': true,  // left
    -  '38': true,  // up
    -  '39': true,  // right
    -  '40': true,  // down
    -};
    -function filteredKeydownEventHandler(event, sendFn) {
    -  const {keyCode} = event;
    -  if (orbitKeys[keyCode]) {
    -    event.preventDefault();
    -    keydownEventHandler(event, sendFn);
    -  }
    -}
    -
    -

    Это кажется близким к запуску, но если мы действительно попробуем, то увидим, что OrbitControls нужно еще кое-что.

    -

    Один из них - element.focus. Нам не нужно, чтобы это происходило в воркере, поэтому давайте просто добавим заглушку.

    -
    class ElementProxyReceiver extends THREE.EventDispatcher {
    -  constructor() {
    -    super();
    -  }
    -  handleEvent(data) {
    -    this.dispatchEvent(data);
    -  }
    -+  focus() {
    -+    // no-op
    -+  }
    -}
    -
    -

    Другой - они вызывают event.preventDefault и event.stopPropagation. Мы уже обрабатываем это на главной странице, так что это тоже может быть пустышкой.

    -
    +function noop() {
    -+}
    -
    -class ElementProxyReceiver extends THREE.EventDispatcher {
    -  constructor() {
    -    super();
    -  }
    -  handleEvent(data) {
    -+    data.preventDefault = noop;
    -+    data.stopPropagation = noop;
    -    this.dispatchEvent(data);
    -  }
    -  focus() {
    -    // no-op
    -  }
    -}
    -
    -

    Другой - они смотрят на clientWidth и clientHeight. Раньше мы передавали размер, но мы можем обновить пару прокси, чтобы передать его.

    -

    В воркере...

    -
    class ElementProxyReceiver extends THREE.EventDispatcher {
    -  constructor() {
    -    super();
    -  }
    -+  get clientWidth() {
    -+    return this.width;
    -+  }
    -+  get clientHeight() {
    -+    return this.height;
    -+  }
    -+  getBoundingClientRect() {
    -+    return {
    -+      left: this.left,
    -+      top: this.top,
    -+      width: this.width,
    -+      height: this.height,
    -+      right: this.left + this.width,
    -+      bottom: this.top + this.height,
    -+    };
    -+  }
    -  handleEvent(data) {
    -+    if (data.type === 'size') {
    -+      this.left = data.left;
    -+      this.top = data.top;
    -+      this.width = data.width;
    -+      this.height = data.height;
    -+      return;
    -+    }
    -    data.preventDefault = noop;
    -    data.stopPropagation = noop;
    -    this.dispatchEvent(data);
    -  }
    -  focus() {
    -    // no-op
    -  }
    -}
    -
    -

    обратно на главную страницу нам нужно отправить размер, а также левую и верхнюю позиции. Обратите внимание, что мы не обрабатываем перемещение холста, только если оно меняет размер. Если вы хотите обрабатывать перемещение, вам нужно будет вызывать sendSize каждый раз, когда что-то перемещает холст.

    -
    class ElementProxy {
    -  constructor(element, worker, eventHandlers) {
    -    this.id = nextProxyId++;
    -    this.worker = worker;
    -    const sendEvent = (data) => {
    -      this.worker.postMessage({
    -        type: 'event',
    -        id: this.id,
    -        data,
    -      });
    -    };
    -
    -    // register an id
    -    worker.postMessage({
    -      type: 'makeProxy',
    -      id: this.id,
    -    });
    -+    sendSize();
    -    for (const [eventName, handler] of Object.entries(eventHandlers)) {
    -      element.addEventListener(eventName, function(event) {
    -        handler(event, sendEvent);
    -      });
    -    }
    -
    -+    function sendSize() {
    -+      const rect = element.getBoundingClientRect();
    -+      sendEvent({
    -+        type: 'size',
    -+        left: rect.left,
    -+        top: rect.top,
    -+        width: element.clientWidth,
    -+        height: element.clientHeight,
    -+      });
    -+    }
    -+
    -+    window.addEventListener('resize', sendSize);
    -  }
    -}
    -
    -

    и в нашем общем коде three.js нам больше не нужно state

    -
    -export const state = {
    --  width: 300,   // canvas default
    --  height: 150,  // canvas default
    --};
    -
    -...
    -
    -function resizeRendererToDisplaySize(renderer) {
    -  const canvas = renderer.domElement;
    --  const width = state.width;
    --  const height = state.height;
    -+  const width = inputElement.clientWidth;
    -+  const height = inputElement.clientHeight;
    -  const needResize = canvas.width !== width || canvas.height !== height;
    -  if (needResize) {
    -    renderer.setSize(width, height, false);
    -  }
    -  return needResize;
    -}
    -
    -function render(time) {
    -  time *= 0.001;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    --    camera.aspect = state.width / state.height;
    -+    camera.aspect = inputElement.clientWidth / inputElement.clientHeight;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -  ...
    -
    -

    Еще несколько приемов. OrbitControls добавляют события pointermove и pointerup в ownerDocument элемента для обработки захвата мыши (когда мышь выходит за пределы окна).

    -

    Далее код ссылается на глобальный document, но в воркере нет глобального документа.

    -

    Мы можем решить все это с помощью 2 быстрых приемов. В нашем рабочем коде мы повторно используем прокси для обеих задач

    -
    function start(data) {
    -  const proxy = proxyManager.getProxy(data.canvasId);
    -+  proxy.ownerDocument = proxy; // HACK!
    -+  self.document = {} // HACK!
    -  init({
    -    canvas: data.canvas,
    -    inputElement: proxy,
    -  });
    -}
    -
    -

    Это даст OrbitControls возможность проверить, что соответствует их ожиданиям.

    -

    Я знаю, что это было довольно сложно. Краткая версия:ElementProxy запускается на главной странице и пересылает события DOM в ElementProxyReceiver -в воркере, который маскируется под HTMLElement, который мы можем использовать как с OrbitControls, так и с нашим собственным кодом.

    -

    И последнее - это наш запасной вариант, когда мы не используем OffscreenCanvas. Все, что нам нужно сделать, это передать сам холст как наш inputElement.

    -
    function startMainPage(canvas) {
    --  init({canvas});
    -+  init({canvas, inputElement: canvas});
    -  console.log('using regular canvas');
    -}
    -
    -

    и теперь у нас должен быть OrbitControls, работающий с OffscreenCanvas

    -

    - -

    -

    Это, наверное, самый сложный пример на этом сайте. -Это немного сложно понять, потому что для каждого образца задействовано 3 файла. HTML-файл, рабочий файл, общий код three.js.

    -

    Я надеюсь, что это было не так уж сложно понять, и что он предоставил несколько полезных примеров работы с three.js, OffscreenCanvas и веб-воркерами.

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/optimize-lots-of-objects-animated.html b/manual/ru/optimize-lots-of-objects-animated.html deleted file mode 100644 index b36f2884ee1ecd..00000000000000 --- a/manual/ru/optimize-lots-of-objects-animated.html +++ /dev/null @@ -1,472 +0,0 @@ - - - Оптимизация большого количества анимированных объектов - - - - - - - - - - - - - -
    -
    -

    Оптимизация большого количества анимированных объектов

    -
    -
    -
    -

    -

    Эта статья является продолжением статьи об оптимизации множества объектов -. Если вы еще не прочитали это, пожалуйста, прочитайте его, прежде чем продолжить.

    -

    В предыдущей статье мы объединили около 19000 кубов в одну геометрию. Это имело преимущество, заключающееся в том, -что оно оптимизировало наш рисунок из 19000 кубов, но имело тот недостаток, что затрудняло перемещение любого отдельного куба.

    -

    В зависимости от того, чего мы пытаемся достичь, существуют разные решения. В этом случае давайте наметим несколько наборов данных и анимируем между наборами.

    -

    Первое, что нам нужно сделать, это получить несколько наборов данных. -В идеале мы бы, вероятно, предварительно обрабатывали данные в автономном режиме, -но в этом случае давайте загрузим 2 набора данных и сгенерируем еще 2

    -

    Вот наш старый код загрузки

    -
    loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
    -  .then(parseData)
    -  .then(addBoxes)
    -  .then(render);
    -
    -

    Давайте изменим это на что-то вроде этого

    -
    async function loadData(info) {
    -  const text = await loadFile(info.url);
    -  info.file = parseData(text);
    -}
    -
    -async function loadAll() {
    -  const fileInfos = [
    -    {name: 'men',   hueRange: [0.7, 0.3], url: 'resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc' },
    -    {name: 'women', hueRange: [0.9, 1.1], url: 'resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014ft_2010_cntm_1_deg.asc' },
    -  ];
    -
    -  await Promise.all(fileInfos.map(loadData));
    -
    -  ...
    -}
    -loadAll();
    -
    -

    Приведенный выше код загрузит все файлы в fileInfos, и после этого каждый объект в fileInfos -будет иметь свойство file с загруженным файлом. name и hueRange мы будем использовать позже. -name будет для поля пользовательского интерфейса. hueRange будет использоваться для выбора диапазона оттенков для отображения.

    -

    Два файла выше, по-видимому, представляют собой количество мужчин на область и число женщин на область по состоянию на 2010 год. Обратите внимание, -я не знаю, верны ли эти данные, но на самом деле это не важно. Важная часть показывает разные наборы данных.

    -

    Давайте сгенерируем еще 2 набора данных. Одним из них являются места, -где число мужчин превышает число женщин, и наоборот, места, где число женщин превышает число мужчин.

    -

    Первым делом давайте напишем функцию, которая с помощью двухмерного массива массивов, -как мы делали раньше, отобразит ее, чтобы сгенерировать новый двумерный массив массивов.

    -
    function mapValues(data, fn) {
    -  return data.map((row, rowNdx) => {
    -    return row.map((value, colNdx) => {
    -      return fn(value, rowNdx, colNdx);
    -    });
    -  });
    -}
    -
    -

    Как и обычная функция Array.map, функция mapValues вызывает функцию fn для каждого значения в массиве массивов. -Он передает ему значение, а также индексы строки и столбца.

    -

    Теперь давайте создадим некоторый код для генерации нового файла, который сравнивает 2 файла.

    -
    function makeDiffFile(baseFile, otherFile, compareFn) {
    -  let min;
    -  let max;
    -  const baseData = baseFile.data;
    -  const otherData = otherFile.data;
    -  const data = mapValues(baseData, (base, rowNdx, colNdx) => {
    -    const other = otherData[rowNdx][colNdx];
    -      if (base === undefined || other === undefined) {
    -        return undefined;
    -      }
    -      const value = compareFn(base, other);
    -      min = Math.min(min === undefined ? value : min, value);
    -      max = Math.max(max === undefined ? value : max, value);
    -      return value;
    -  });
    -  // make a copy of baseFile and replace min, max, and data
    -  // with the new data
    -  return {...baseFile, min, max, data};
    -}
    -
    -

    Приведенный выше код использует mapValues для генерации нового набора данных, -который представляет собой сравнение на основе переданной функции CompareFn. -Он также отслеживает минимальные и максимальные результаты сравнения. Наконец, -он создает новый файл со всеми теми же свойствами, что и baseFile, за исключением новых min, max и data.

    -

    Тогда давайте использовать это, чтобы сделать 2 новых набора данных

    -
    {
    -  const menInfo = fileInfos[0];
    -  const womenInfo = fileInfos[1];
    -  const menFile = menInfo.file;
    -  const womenFile = womenInfo.file;
    -
    -  function amountGreaterThan(a, b) {
    -    return Math.max(a - b, 0);
    -  }
    -  fileInfos.push({
    -    name: '>50%men',
    -    hueRange: [0.6, 1.1],
    -    file: makeDiffFile(menFile, womenFile, (men, women) => {
    -      return amountGreaterThan(men, women);
    -    }),
    -  });
    -  fileInfos.push({
    -    name: '>50% women',
    -    hueRange: [0.0, 0.4],
    -    file: makeDiffFile(womenFile, menFile, (women, men) => {
    -      return amountGreaterThan(women, men);
    -    }),
    -  });
    -}
    -
    -

    Теперь давайте сгенерируем пользовательский интерфейс для выбора между этими наборами данных. Для начала нам нужен HTML-интерфейс

    -
    <body>
    -  <canvas id="c"></canvas>
    -+  <div id="ui"></div>
    -</body>
    -
    -

    и немного CSS, чтобы он появился в верхней левой области

    -
    #ui {
    -  position: absolute;
    -  left: 1em;
    -  top: 1em;
    -}
    -#ui>div {
    -  font-size: 20pt;
    -  padding: 1em;
    -  display: inline-block;
    -}
    -#ui>div.selected {
    -  color: red;
    -}
    -
    -

    Затем мы можем просмотреть каждый файл и сгенерировать набор объединенных блоков -для набора данных и элемент, который при наведении курсора отобразит этот набор и скроет все остальные.

    -
    // show the selected data, hide the rest
    -function showFileInfo(fileInfos, fileInfo) {
    -  fileInfos.forEach((info) => {
    -    const visible = fileInfo === info;
    -    info.root.visible = visible;
    -    info.elem.className = visible ? 'selected' : '';
    -  });
    -  requestRenderIfNotRequested();
    -}
    -
    -const uiElem = document.querySelector('#ui');
    -fileInfos.forEach((info) => {
    -  const boxes = addBoxes(info.file, info.hueRange);
    -  info.root = boxes;
    -  const div = document.createElement('div');
    -  info.elem = div;
    -  div.textContent = info.name;
    -  uiElem.appendChild(div);
    -  div.addEventListener('mouseover', () => {
    -    showFileInfo(fileInfos, info);
    -  });
    -});
    -// show the first set of data
    -showFileInfo(fileInfos, fileInfos[0]);
    -
    -

    Еще одно изменение, которое нам нужно из предыдущего примера, заключается в том, что мы должны заставить addBoxes принимать hueRange

    -
    -function addBoxes(file) {
    -+function addBoxes(file, hueRange) {
    -
    -  ...
    -
    -    // compute a color
    --    const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
    -+    const hue = THREE.MathUtils.lerp(...hueRange, amount);
    -
    -  ...
    -
    -

    и с этим мы должны быть в состоянии показать 4 набора данных. Наведите указатель мыши на ярлыки или коснитесь их, чтобы переключать наборы

    -

    - -

    -

    Обратите внимание, что есть несколько странных точек данных, которые действительно выделяются. -Интересно, что с ними? ??! В любом случае, как мы анимируем между этими 4 наборами данных.

    -

    Много идей.

    -
      -
    • Просто исчезните между ними, используя Material.opacity

      -

      Проблема с этим решением заключается в том, что кубы полностью перекрываются, -что означает, что возникнут проблемы z-борьбы. Возможно, мы могли бы исправить это, -изменив функцию глубины и используя смешивание. Вероятно, мы должны изучить это.

      -
    • -
    -
      -
    • Увеличьте набор, который мы хотим видеть, и уменьшите другие наборы.

      -

      Поскольку все коробки имеют свое происхождение в центре планеты, если мы масштабируем их ниже 1,0, они погрузятся в планету. -Сначала это звучит как хорошая идея, но проблема в том, что все поля низкой высоты исчезнут почти сразу и не будут заменены, -пока новый набор данных не масштабируется до 1,0. -Это делает переход не очень приятным. Мы могли бы исправить это с помощью необычного шейдера.

      -
    • -
    • Используйте Morphtargets

      -

      Morphtargets - это способ, которым мы предоставляем несколько значений -для каждой вершины в геометрии и морф или lerp (линейная интерполяция) -между ними. Morphtargets чаще всего используются для лицевой анимации 3D персонажей, но это не единственное их использование.

      -
    • -
    -

    Давайте попробуем morphtargets.

    -

    Мы по-прежнему создадим геометрию для каждого набора данных, -но затем мы извлечем атрибут позиции из каждого из них и будем использовать их как морфтинги.

    -

    Сначала давайте изменим addBoxes, чтобы просто создать и вернуть объединенную геометрию.

    -
    -function addBoxes(file, hueRange) {
    -+function makeBoxes(file, hueRange) {
    -  const {min, max, data} = file;
    -  const range = max - min;
    -
    -  ...
    -
    --  const mergedGeometry = BufferGeometryUtils.mergeGeometries(
    --      geometries, false);
    --  const material = new THREE.MeshBasicMaterial({
    --    vertexColors: true,
    --  });
    --  const mesh = new THREE.Mesh(mergedGeometry, material);
    --  scene.add(mesh);
    --  return mesh;
    -+  return BufferGeometryUtils.mergeGeometries(
    -+     geometries, false);
    -}
    -
    -

    Здесь есть еще одна вещь, которую нам нужно сделать. Morphtargets требуются, чтобы у всех было точно одинаковое количество вершин. -Вершина # 123 в одной цели должна иметь соответствующую вершину # 123 во всех других целях. Но, поскольку сейчас -разные наборы данных могут иметь некоторые точки данных без данных, поэтому для этой точки не будет сгенерировано ни одного блока, -что означало бы отсутствие соответствующих вершин для другого набора. Итак, нам нужно проверить все наборы данных и либо всегда генерировать что-либо, если -в каком-либо наборе есть данные, либо ничего не генерировать, если в каком-либо наборе отсутствуют данные. Давайте сделаем последнее.

    -
    +function dataMissingInAnySet(fileInfos, latNdx, lonNdx) {
    -+  for (const fileInfo of fileInfos) {
    -+    if (fileInfo.file.data[latNdx][lonNdx] === undefined) {
    -+      return true;
    -+    }
    -+  }
    -+  return false;
    -+}
    -
    --function makeBoxes(file, hueRange) {
    -+function makeBoxes(file, hueRange, fileInfos) {
    -  const {min, max, data} = file;
    -  const range = max - min;
    -
    -  ...
    -
    -  const geometries = [];
    -  data.forEach((row, latNdx) => {
    -    row.forEach((value, lonNdx) => {
    -+      if (dataMissingInAnySet(fileInfos, latNdx, lonNdx)) {
    -+        return;
    -+      }
    -      const amount = (value - min) / range;
    -
    -  ...
    -
    -

    Теперь мы изменим код, который вызывал addBoxes, для использования makeBoxes и установки morphtargets.

    -
    +// make geometry for each data set
    -+const geometries = fileInfos.map((info) => {
    -+  return makeBoxes(info.file, info.hueRange, fileInfos);
    -+});
    -+
    -+// use the first geometry as the base
    -+// and add all the geometries as morphtargets
    -+const baseGeometry = geometries[0];
    -+baseGeometry.morphAttributes.position = geometries.map((geometry, ndx) => {
    -+  const attribute = geometry.getAttribute('position');
    -+  const name = `target${ndx}`;
    -+  attribute.name = name;
    -+  return attribute;
    -+});
    -+baseGeometry.morphAttributes.color = geometries.map((geometry, ndx) => {
    -+  const attribute = geometry.getAttribute('color');
    -+  const name = `target${ndx}`;
    -+  attribute.name = name;
    -+  return attribute;
    -+});
    -+const material = new THREE.MeshBasicMaterial({
    -+  vertexColors: true,
    -+});
    -+const mesh = new THREE.Mesh(baseGeometry, material);
    -+scene.add(mesh);
    -
    -const uiElem = document.querySelector('#ui');
    -fileInfos.forEach((info) => {
    --  const boxes = addBoxes(info.file, info.hueRange);
    --  info.root = boxes;
    -  const div = document.createElement('div');
    -  info.elem = div;
    -  div.textContent = info.name;
    -  uiElem.appendChild(div);
    -  function show() {
    -    showFileInfo(fileInfos, info);
    -  }
    -  div.addEventListener('mouseover', show);
    -  div.addEventListener('touchstart', show);
    -});
    -// show the first set of data
    -showFileInfo(fileInfos, fileInfos[0]);
    -
    -

    Выше мы создаем геометрию для каждого набора данных, -используем первый в качестве базы, затем получаем атрибут -позиции из каждой геометрии и добавляем его в качестве морфтинга к базовой геометрии для позиции.

    -

    Теперь нам нужно изменить способ отображения и скрытия различных наборов данных. -Вместо того, чтобы показывать или скрывать меш, нам нужно изменить влияние морфтинга. Для набора данных, -который мы хотим видеть, нам нужно иметь влияние 1, а для всех тех, которые мы не хотим видеть, нам нужно иметь влияние 0.

    -

    Мы могли бы просто установить их в 0 или 1 напрямую, но если бы мы это сделали, мы бы не увидели никакой анимации, -она просто щелкала бы, что не отличалось бы от того, что у нас уже есть. Мы также могли бы написать некоторый пользовательский анимационный код, который был бы легок, -но поскольку оригинальный глобус webgl использует -библиотеку анимации давайте используем тот же самый здесь.

    -

    Нам нужно включить библиотеку

    -
    import * as THREE from 'three';
    -import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
    -import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -+import TWEEN from 'three/addons/libs/tween.module.js';
    -
    -

    А затем создайте Tween чтобы оживить влияние.

    -
    // show the selected data, hide the rest
    -function showFileInfo(fileInfos, fileInfo) {
    -  fileInfos.forEach((info) => {
    -    const visible = fileInfo === info;
    --    info.root.visible = visible;
    -    info.elem.className = visible ? 'selected' : '';
    -+    const targets = {};
    -+    fileInfos.forEach((info, i) => {
    -+      targets[i] = info === fileInfo ? 1 : 0;
    -+    });
    -+    const durationInMs = 1000;
    -+    new TWEEN.Tween(mesh.morphTargetInfluences)
    -+      .to(targets, durationInMs)
    -+      .start();
    -  });
    -  requestRenderIfNotRequested();
    -}
    -
    -

    Мы также предполагаем вызывать TWEEN.update каждый кадр в нашем цикле рендеринга, но это указывает на проблему. -"tween.js" предназначен для непрерывного рендеринга, но мы делаем рендеринг по требованию . -Мы могли бы переключиться на непрерывный рендеринг, но иногда приятно рендерить только по требованию, так как он перестает использовать -силу пользователя, когда ничего не происходит, поэтому давайте посмотрим, сможем ли мы сделать его анимированным по запросу.

    -

    Мы сделаем TweenManager, чтобы помочь. Мы будем использовать его для создания -Tweens и отслеживания их. Он будет иметь метод update, который будет -возвращать true, если нам нужно будет вызвать его снова, и false, если все анимации завершены.

    -
    class TweenManger {
    -  constructor() {
    -    this.numTweensRunning = 0;
    -  }
    -  _handleComplete() {
    -    --this.numTweensRunning;
    -    console.assert(this.numTweensRunning >= 0);
    -  }
    -  createTween(targetObject) {
    -    const self = this;
    -    ++this.numTweensRunning;
    -    let userCompleteFn = () => {};
    -    // create a new tween and install our own onComplete callback
    -    const tween = new TWEEN.Tween(targetObject).onComplete(function(...args) {
    -      self._handleComplete();
    -      userCompleteFn.call(this, ...args);
    -    });
    -    // replace the tween's onComplete function with our own
    -    // so we can call the user's callback if they supply one.
    -    tween.onComplete = (fn) => {
    -      userCompleteFn = fn;
    -      return tween;
    -    };
    -    return tween;
    -  }
    -  update() {
    -    TWEEN.update();
    -    return this.numTweensRunning > 0;
    -  }
    -}
    -
    -

    Чтобы использовать его, мы создадим один

    -
    function main() {
    -  const canvas = document.querySelector('#c');
    -  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -+  const tweenManager = new TweenManger();
    -
    -  ...
    -
    -

    Мы будем использовать его для создания наших Tweens.

    -
    // show the selected data, hide the rest
    -function showFileInfo(fileInfos, fileInfo) {
    -  fileInfos.forEach((info) => {
    -    const visible = fileInfo === info;
    -    info.elem.className = visible ? 'selected' : '';
    -    const targets = {};
    -    fileInfos.forEach((info, i) => {
    -      targets[i] = info === fileInfo ? 1 : 0;
    -    });
    -    const durationInMs = 1000;
    --    new TWEEN.Tween(mesh.morphTargetInfluences)
    -+    tweenManager.createTween(mesh.morphTargetInfluences)
    -      .to(targets, durationInMs)
    -      .start();
    -  });
    -  requestRenderIfNotRequested();
    -}
    -
    -

    Затем мы обновим наш цикл рендеринга, чтобы обновить анимацию и продолжать рендеринг, если анимация все еще выполняется.

    -
    function render() {
    -  renderRequested = false;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -+  if (tweenManager.update()) {
    -+    requestRenderIfNotRequested();
    -+  }
    -
    -  controls.update();
    -  renderer.render(scene, camera);
    -}
    -render();
    -
    -

    И с этим мы должны анимировать между наборами данных.

    -

    - -

    -

    Я надеюсь, что пройти через это было полезно. Использование morphtargets либо через сервисы, -которые предоставляет three.js, либо путем написания пользовательских шейдеров - -это распространенная техника для перемещения большого количества объектов. -В качестве примера мы могли бы дать каждому кубу случайное место в другой цели и -превратить его в свои первые позиции на земном шаре. Это может быть крутой способ представить миру.

    -

    Далее вас может заинтересовать добавление ярлыков к глобусу, который описан в разделе. - «Выравнивание элементов HTML в 3D».

    -

    Примечание: мы могли бы попытаться просто изобразить процент мужчин -или женщин или общую разницу, но основываясь на том, как мы отображаем -информацию, кубы, которые растут с поверхности земли, мы бы предпочли, -чтобы большинство кубов были низкими. Если бы мы использовали одно из -этих других сравнений, то большинство кубов имели бы примерно половину -их максимальной высоты, что не давало бы хорошей визуализации. -Не стесняйтесь изменить количество GreaterThan -с Math.max (a - b, 0) на что-то вроде (a - b) «сырой разницы» или a / (a ​​+ b) «процентов», и вы поймете, что я имею в виду.

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/optimize-lots-of-objects.html b/manual/ru/optimize-lots-of-objects.html deleted file mode 100644 index 2bebd7cb1b571e..00000000000000 --- a/manual/ru/optimize-lots-of-objects.html +++ /dev/null @@ -1,488 +0,0 @@ - - - Оптимизация большого количества объектов - - - - - - - - - - - - - -
    -
    -

    Оптимизация большого количества объектов

    -
    -
    -
    -

    Эта статья является частью серии статей о three.js. Первая статья - основы Three.js. -Если вы еще не читали это, и вы новичок в three.js, вы можете начать там.

    -

    Есть много способов оптимизировать вещи для three.js. Один из способов часто называют геометрией слияния. -Каждая созданная вами сетка и three.js представляют 1 или более запросов системы на что-то нарисовать. -Рисование 2 вещей имеет больше затрат, чем рисование 1, -даже если результаты одинаковы, поэтому одним из способов оптимизации является объединение сеток.

    -

    Давайте посмотрим пример, когда это хорошее решение для проблемы. Давайте заново создадим WebGL Globe.

    -

    Первое, что нам нужно сделать, это получить данные. WebGL Globe сказал, что данные, которые они используют, взяты из SEDAC. -Проверяя сайт, я увидел демографические данные в виде сетки. -Я загрузил данные с 60-минутным разрешением. Затем я посмотрел на данные

    -

    Это выглядит так

    -
     ncols         360
    - nrows         145
    - xllcorner     -180
    - yllcorner     -60
    - cellsize      0.99999999999994
    - NODATA_value  -9999
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - 9.241768 8.790958 2.095345 -9999 0.05114867 -9999 -9999 -9999 -9999 -999...
    - 1.287993 0.4395509 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    -
    -

    Есть несколько строк, похожих на пары ключ / значение, за которыми следуют строки со значением для каждой точки сетки, по одной строке для каждой строки точек данных.

    -

    Чтобы убедиться, что мы понимаем данные, давайте попробуем построить их в 2D.

    -

    Сначала немного кода для загрузки текстового файла

    -
    async function loadFile(url) {
    -  const res = await fetch(url);
    -  return res.text();
    -}
    -
    -

    Приведенный выше код возвращает Promise с содержимым файла по адресу url;

    -

    Тогда нам нужен код для разбора файла

    -
    function parseData(text) {
    -  const data = [];
    -  const settings = {data};
    -  let max;
    -  let min;
    -  // split into lines
    -  text.split('\n').forEach((line) => {
    -    // split the line by whitespace
    -    const parts = line.trim().split(/\s+/);
    -    if (parts.length === 2) {
    -      // only 2 parts, must be a key/value pair
    -      settings[parts[0]] = parseFloat(parts[1]);
    -    } else if (parts.length > 2) {
    -      // more than 2 parts, must be data
    -      const values = parts.map((v) => {
    -        const value = parseFloat(v);
    -        if (value === settings.NODATA_value) {
    -          return undefined;
    -        }
    -        max = Math.max(max === undefined ? value : max, value);
    -        min = Math.min(min === undefined ? value : min, value);
    -        return value;
    -      });
    -      data.push(values);
    -    }
    -  });
    -  return Object.assign(settings, {min, max});
    -}
    -
    -

    Приведенный выше код возвращает объект со всеми парами ключ / значение из файла, -а также свойство data со всеми данными в одном большом массиве и значениями min и max, найденными в данных.

    -

    Тогда нам нужен код для рисования этих данных

    -
    function drawData(file) {
    -  const {min, max, data} = file;
    -  const range = max - min;
    -  const ctx = document.querySelector('canvas').getContext('2d');
    -  // make the canvas the same size as the data
    -  ctx.canvas.width = ncols;
    -  ctx.canvas.height = nrows;
    -  // but display it double size so it's not too small
    -  ctx.canvas.style.width = px(ncols * 2);
    -  ctx.canvas.style.height = px(nrows * 2);
    -  // fill the canvas to dark gray
    -  ctx.fillStyle = '#444';
    -  ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    -  // draw each data point
    -  data.forEach((row, latNdx) => {
    -    row.forEach((value, lonNdx) => {
    -      if (value === undefined) {
    -        return;
    -      }
    -      const amount = (value - min) / range;
    -      const hue = 1;
    -      const saturation = 1;
    -      const lightness = amount;
    -      ctx.fillStyle = hsl(hue, saturation, lightness);
    -      ctx.fillRect(lonNdx, latNdx, 1, 1);
    -    });
    -  });
    -}
    -
    -function px(v) {
    -  return `${v | 0}px`;
    -}
    -
    -function hsl(h, s, l) {
    -  return `hsl(${h * 360 | 0},${s * 100 | 0}%,${l * 100 | 0}%)`;
    -}
    -
    -

    И, наконец, склеив все это

    -
    loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
    -  .then(parseData)
    -  .then(drawData);
    -
    -

    Дает нам этот результат

    -

    - -

    -

    Так что, кажется, это работает.

    -

    Давайте попробуем это в 3D. Начиная с кода от рендеринга по требованию мы сделаем один блок на данные в файле.

    -

    Сначала давайте сделаем простую сферу с текстурой мира. Вот текстура

    -
    - -

    И код для его настройки.

    -
    {
    -  const loader = new THREE.TextureLoader();
    -  const texture = loader.load('resources/images/world.jpg', render);
    -  const geometry = new THREE.SphereGeometry(1, 64, 32);
    -  const material = new THREE.MeshBasicMaterial({map: texture});
    -  scene.add(new THREE.Mesh(geometry, material));
    -}
    -
    -

    Обратите внимание на вызов для рендеринга после завершения загрузки текстуры. Нам это нужно, потому что мы выполняем рендеринг по требованию -а не постоянно, поэтому нам нужно рендерить один раз при загрузке текстуры.

    -

    Затем нам нужно изменить код, который рисует точку на точку данных выше, чтобы вместо этого создать прямоугольник для точки данных.

    -
    function addBoxes(file) {
    -  const {min, max, data} = file;
    -  const range = max - min;
    -
    -  // make one box geometry
    -  const boxWidth = 1;
    -  const boxHeight = 1;
    -  const boxDepth = 1;
    -  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    -  // make it so it scales away from the positive Z axis
    -  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));
    -
    -  // these helpers will make it easy to position the boxes
    -  // We can rotate the lon helper on its Y axis to the longitude
    -  const lonHelper = new THREE.Object3D();
    -  scene.add(lonHelper);
    -  // We rotate the latHelper on its X axis to the latitude
    -  const latHelper = new THREE.Object3D();
    -  lonHelper.add(latHelper);
    -  // The position helper moves the object to the edge of the sphere
    -  const positionHelper = new THREE.Object3D();
    -  positionHelper.position.z = 1;
    -  latHelper.add(positionHelper);
    -
    -  const lonFudge = Math.PI * .5;
    -  const latFudge = Math.PI * -0.135;
    -  data.forEach((row, latNdx) => {
    -    row.forEach((value, lonNdx) => {
    -      if (value === undefined) {
    -        return;
    -      }
    -      const amount = (value - min) / range;
    -      const material = new THREE.MeshBasicMaterial();
    -      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
    -      const saturation = 1;
    -      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
    -      material.color.setHSL(hue, saturation, lightness);
    -      const mesh = new THREE.Mesh(geometry, material);
    -      scene.add(mesh);
    -
    -      // adjust the helpers to point to the latitude and longitude
    -      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
    -      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
    -
    -      // use the world matrix of the position helper to
    -      // position this mesh.
    -      positionHelper.updateWorldMatrix(true, false);
    -      mesh.applyMatrix4(positionHelper.matrixWorld);
    -
    -      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
    -    });
    -  });
    -}
    -
    -

    Код в основном прямо из нашего тестового кода рисования.

    -

    Мы делаем одну коробку и корректируем ее центр так, чтобы она масштабировалась от положительного Z. -Если бы мы этого не делали, она бы масштабировалась от центра, но мы хотим, чтобы они росли от начала координат.

    -
    -
    -
    -
    default
    -
    -
    -
    -
    adjusted
    -
    -
    - -

    Конечно, мы могли бы также решить эту проблему, добавив в родительский блок больше объектов -THREE.Object3D, как мы покрывали в графах сцены -но чем больше узлов мы добавляем в граф сцены, тем медленнее он становится.

    -

    Мы также настраиваем эту небольшую иерархию узлов lonHelper, latHelper и positionHelper. -Мы используем эти объекты, чтобы вычислить положение вокруг сферы, чтобы разместить коробку.

    -
    -
    -
    - -

    Над зелёной полосой изображен lonHelper и используется для поворота в направлении долготы на экваторе. -Синяя полоса обозначает latHelper который используется для поворота на широту выше или ниже экватора. -Красная сфера представляет смещение, которое обеспечивает этот positionHelper.

    -

    Мы могли бы сделать всю математику вручную, чтобы выяснить позиции на земном шаре, но, делая это таким образом, -мы оставляем большую часть математики самой библиотеке, поэтому нам не нужно иметь с ней дело.

    -

    Для каждой точки данных мы создаем MeshBasicMaterial и Mesh, а затем запрашиваем мировую матрицу -positionHelper и применяем ее к новой Mesh. Наконец мы масштабируем сетку в новой позиции.

    -

    Как и выше, мы могли бы также создать latHelper, lonHelper и positionHelper для каждого нового окна, но это было бы еще медленнее.

    -

    Мы собираемся создать до 360х145 коробок. Это до 52000 коробок. -Поскольку некоторые точки данных помечены как «NO_DATA», фактическое -количество блоков, которые мы собираемся создать, составляет около 19000. -Если бы мы добавили 3 дополнительных вспомогательных объекта в блок, -это было бы почти 80000 узлов графа сцены, которые THREE.js должен был бы -вычислить. позиции для. Вместо этого, используя один набор помощников, -чтобы просто расположить сетки, мы экономим около 60000 операций.

    -

    Примечание о lonFudge и latFudge. lonFudge равен π / 2, что составляет четверть оборота. -В этом есть смысл. Это просто означает, что текстура или координаты текстуры начинаются -с другого смещения по всему земному шару. latFudge с другой стороны, я понятия не имею, -почему он должен быть π * -0,135, это просто количество, которое выровняло боксы с текстурой.

    -

    Последнее, что нам нужно сделать, это вызвать нашего загрузчика

    -
    loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
    -  .then(parseData)
    --  .then(drawData)
    -+  .then(addBoxes)
    -+  .then(render);
    -

    После того, как данные закончили загрузку и анализ, нам нужно выполнить рендеринг хотя бы один раз, поскольку мы выполняем - рендеринг по требованию.

    -

    - -

    -

    Если вы попытаетесь повернуть приведенный выше пример, перетащив образец, вы, вероятно, заметите, что он медленный.

    -

    Мы можем проверить частоту кадров, открыв devtools и включив индикатор частоты кадров браузера.

    -
    - -

    На моей машине я вижу частоту кадров ниже 20 кадров в секунду.

    -
    - -

    Это не очень хорошо для меня, и я подозреваю, что многие люди имеют более медленные машины, которые сделали бы это еще хуже. Нам лучше взглянуть на оптимизацию.

    -

    Для этой конкретной задачи мы можем объединить все блоки в одну геометрию. -В настоящее время мы рисуем около 19000 коробок. Объединяя их в одну геометрию, мы удалили 18999 операций.

    -

    Вот новый код для объединения блоков в одну геометрию.

    -
    function addBoxes(file) {
    -  const {min, max, data} = file;
    -  const range = max - min;
    -
    --  // make one box geometry
    --  const boxWidth = 1;
    --  const boxHeight = 1;
    --  const boxDepth = 1;
    --  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    --  // make it so it scales away from the positive Z axis
    --  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));
    -
    -  // these helpers will make it easy to position the boxes
    -  // We can rotate the lon helper on its Y axis to the longitude
    -  const lonHelper = new THREE.Object3D();
    -  scene.add(lonHelper);
    -  // We rotate the latHelper on its X axis to the latitude
    -  const latHelper = new THREE.Object3D();
    -  lonHelper.add(latHelper);
    -  // The position helper moves the object to the edge of the sphere
    -  const positionHelper = new THREE.Object3D();
    -  positionHelper.position.z = 1;
    -  latHelper.add(positionHelper);
    -+  // Used to move the center of the box so it scales from the position Z axis
    -+  const originHelper = new THREE.Object3D();
    -+  originHelper.position.z = 0.5;
    -+  positionHelper.add(originHelper);
    -
    -  const lonFudge = Math.PI * .5;
    -  const latFudge = Math.PI * -0.135;
    -+  const geometries = [];
    -  data.forEach((row, latNdx) => {
    -    row.forEach((value, lonNdx) => {
    -      if (value === undefined) {
    -        return;
    -      }
    -      const amount = (value - min) / range;
    -
    --      const material = new THREE.MeshBasicMaterial();
    --      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
    --      const saturation = 1;
    --      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
    --      material.color.setHSL(hue, saturation, lightness);
    --      const mesh = new THREE.Mesh(geometry, material);
    --      scene.add(mesh);
    -
    -+      const boxWidth = 1;
    -+      const boxHeight = 1;
    -+      const boxDepth = 1;
    -+      const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    -
    -      // adjust the helpers to point to the latitude and longitude
    -      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
    -      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
    -
    --      // use the world matrix of the position helper to
    --      // position this mesh.
    --      positionHelper.updateWorldMatrix(true, false);
    --      mesh.applyMatrix4(positionHelper.matrixWorld);
    --
    --      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
    -
    -+      // use the world matrix of the origin helper to
    -+      // position this geometry
    -+      positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
    -+      originHelper.updateWorldMatrix(true, false);
    -+      geometry.applyMatrix4(originHelper.matrixWorld);
    -+
    -+      geometries.push(geometry);
    -    });
    -  });
    -
    -+  const mergedGeometry = BufferGeometryUtils.mergeGeometries(
    -+      geometries, false);
    -+  const material = new THREE.MeshBasicMaterial({color:'red'});
    -+  const mesh = new THREE.Mesh(mergedGeometry, material);
    -+  scene.add(mesh);
    -
    -}
    -
    -

    Выше мы удалили код, который изменял центральную точку геометрии бокса, -и вместо этого делаем это, добавляя originHelper. Раньше мы использовали -одну и ту же геометрию 19000 раз. На этот раз мы создаем новую геометрию -для каждого отдельного блока, и, поскольку мы будем использовать applyMatrix -для перемещения вершин каждой геометрии блока, мы могли бы сделать это один раз, а не два.

    -

    В конце мы передаем массив всех геометрий в BufferGeometryUtils.mergeGeometries, который объединит их все в одну сетку.

    -

    Нам также нужно включить BufferGeometryUtils

    -
    import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
    -
    -

    И теперь, по крайней мере на моей машине, я получаю 60 кадров в секунду

    -

    - -

    -

    Так что это сработало, но поскольку это одна сетка, мы получаем только один материал, -что означает, что мы получаем только один цвет, тогда как раньше у нас был другой цвет -на каждой коробке. Мы можем исправить это, используя цвета вершин.

    -

    Цвета вершин добавляют цвет для каждой вершины. Установив все цвета каждой -вершины каждого блока на определенные цвета, каждый блок будет иметь другой цвет.

    -
    +const color = new THREE.Color();
    -
    -const lonFudge = Math.PI * .5;
    -const latFudge = Math.PI * -0.135;
    -const geometries = [];
    -data.forEach((row, latNdx) => {
    -  row.forEach((value, lonNdx) => {
    -    if (value === undefined) {
    -      return;
    -    }
    -    const amount = (value - min) / range;
    -
    -    const boxWidth = 1;
    -    const boxHeight = 1;
    -    const boxDepth = 1;
    -    const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    -
    -    // adjust the helpers to point to the latitude and longitude
    -    lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
    -    latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
    -
    -    // use the world matrix of the origin helper to
    -    // position this geometry
    -    positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
    -    originHelper.updateWorldMatrix(true, false);
    -    geometry.applyMatrix4(originHelper.matrixWorld);
    -
    -+    // compute a color
    -+    const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
    -+    const saturation = 1;
    -+    const lightness = THREE.MathUtils.lerp(0.4, 1.0, amount);
    -+    color.setHSL(hue, saturation, lightness);
    -+    // get the colors as an array of values from 0 to 255
    -+    const rgb = color.toArray().map(v => v * 255);
    -+
    -+    // make an array to store colors for each vertex
    -+    const numVerts = geometry.getAttribute('position').count;
    -+    const itemSize = 3;  // r, g, b
    -+    const colors = new Uint8Array(itemSize * numVerts);
    -+
    -+    // copy the color into the colors array for each vertex
    -+    colors.forEach((v, ndx) => {
    -+      colors[ndx] = rgb[ndx % 3];
    -+    });
    -+
    -+    const normalized = true;
    -+    const colorAttrib = new THREE.BufferAttribute(colors, itemSize, normalized);
    -+    geometry.setAttribute('color', colorAttrib);
    -
    -    geometries.push(geometry);
    -  });
    -});
    -
    -

    Приведенный выше код ищет количество или вершины, -необходимые для получения атрибута position из геометрии. -Затем мы создаем Uint8Array для размещения цветов. -Затем он добавляет это как атрибут, вызывая geometry.setAttribute.

    -

    Наконец, нам нужно указать three.js использовать цвета вершин.

    -
    const mergedGeometry = BufferGeometryUtils.mergeGeometries(
    -    geometries, false);
    --const material = new THREE.MeshBasicMaterial({color:'red'});
    -+const material = new THREE.MeshBasicMaterial({
    -+  vertexColors: true,
    -+});
    -const mesh = new THREE.Mesh(mergedGeometry, material);
    -scene.add(mesh);
    -
    -

    И с этим мы получаем наши цвета обратно

    -

    - -

    -

    Объединение геометрии является распространенным методом оптимизации. -Например, вместо 100 деревьев вы можете объединить деревья в одну геометрию, -кучу отдельных камней в одну геометрию камней, частокол из отдельных пикетов в одну сетку. -Другой пример в Minecraft - он не рисует каждый куб по отдельности, а создает -группы объединенных кубов, а также выборочно удаляет грани, которые никогда не видны.

    -

    Проблема создания всего одного меша состоит в том, что больше не легко перемещать какие-либо части, которые были ранее разделены. -В зависимости от нашего варианта использования, хотя есть творческие решения. Мы рассмотрим одну в -другой статье.

    -

    - - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/picking.html b/manual/ru/picking.html deleted file mode 100644 index b3aef01f3d389a..00000000000000 --- a/manual/ru/picking.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Picking - - - - - - - - - - - - - -
    -
    -

    Picking

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/post-processing.html b/manual/ru/post-processing.html deleted file mode 100644 index 02f20b716c9fce..00000000000000 --- a/manual/ru/post-processing.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Post Processing - - - - - - - - - - - - - -
    -
    -

    Post Processing

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/prerequisites.html b/manual/ru/prerequisites.html deleted file mode 100644 index 4ed4d4ed1afe5a..00000000000000 --- a/manual/ru/prerequisites.html +++ /dev/null @@ -1,265 +0,0 @@ - - - Необходимые условия - - - - - - - - - - - - - -
    -
    -

    Необходимые условия

    -
    -
    -
    -

    Эти статьи предназначены для того, чтобы помочь вам научиться использовать three.js. Они предполагают, что вы знаете, как программировать на JavaScript. -Они предполагают, что вы знаете, что такое DOM, как писать HTML, а также создавать элементы DOM в JavaScript. -Они предполагают, что вы знаете, как использовать -es6 modules -через импорт и тэги <script type="module">. - Они предполагают, что вы знаете немного CSS и что вы знаете, что такое -CSS-селекторы. -Они также предполагают, что вы знаете ES5, ES6 и, возможно, немного ES7. Они предполагают, -что вы знаете, что браузер запускает JavaScript только через события и обратные вызовы. -Они предполагают, что вы знаете, что такое закрытие.

    -

    Вот несколько кратких обновлений и заметок

    -

    es6 modules

    -

    Модули es6 можно загружать с помощью ключевого слова import в сценарии или встроенного тега <script type="module">. Вот пример обоих

    -
    <script type="module">
    -import * as THREE from 'three';
    -
    -...
    -
    -</script>
    -
    -

    Пути должны быть абсолютными или относительными. -Относительные пути всегда начинаются с ./ или ../, что отличается от других тегов, таких как <img> и <a> и ссылок css.

    -

    Более подробная информация упоминается в нижней части этой статьи.

    -

    document.querySelector и document.querySelectorAll

    -

    Вы можете использовать document.querySelector, чтобы выбрать первый элемент, который соответствует селектору CSS. -document.querySelectorAll возвращает все элементы, которые соответствуют селектору CSS.

    -

    Вам не нужен onbody

    -

    Многие используют страницы HTML как

    -
    <body onload="somefunction()">
    -

    Этот стиль не рекомендуется. Разместите ваши сценарии внизу страницы.

    -
    <html>
    -  <head>
    -    ...
    -  </head>
    -  <body>
    -     ...
    -  </body>
    -  <script>
    -    // inline javascript
    -  </script>
    -</html>
    -
    -

    или используйте свойство defer.

    -

    Знать, как работают замыкания

    -
    function a(v) {
    -  const foo = v;
    -  return function() {
    -     return foo;
    -  };
    -}
    -
    -const f = a(123);
    -const g = a(456);
    -console.log(f());  // prints 123
    -console.log(g());  // prints 456
    -
    -

    В приведенном выше коде функция a создает новую функцию каждый раз, когда она вызывается. -Эта функция закрывает переменную foo.Вот больше информации.

    -

    Понимать как работает this

    -

    this не магия По сути, это переменная, которая автоматически передается функциям также, как аргумент передается функции. -Простое объяснение - когда вы вызываете функцию, например

    -
    somefunction(a, b, c);
    -

    this будет null (в строгом режиме или в модуле) как при вызове функции через оператор как при вызове функции через оператор . как это

    -
    someobject.somefunction(a, b, c);
    -

    this будет установлено для someobject.

    -

    Части, где люди путаются, это обратные вызовы.

    -
     const callback = someobject.somefunction;
    - loader.load(callback);
    -

    не работает, как может ожидать кто-то неопытный, потому что когда loader.load вызывает обратный вызов, он не вызывает его с -. оператор, так что по умолчанию this будет null (если загрузчик явно не устанавливает его в что-то). -Если вы хотите, чтобы во время обратного вызова this был someobject, вам нужно сообщить об этом JavaScript, привязав его к функции.

    -
     const callback = someobject.somefunction.bind(someobject);
    - loader.load(callback);
    -

    эта статья может помочь объяснить this.

    -

    ES5/ES6/ES7 материал

    -

    var устарела. Используйте const и/или let

    -

    Нет никакой причины использовать var КОГДА-НИБУДЬ и сейчас считается плохой практикой, чтобы использовать его вообще. -Используйте const, если переменная никогда не будет переназначена, что происходит большую часть времени. Используйте let в тех случаях, когда значение изменяется. -Это поможет избежать множества ошибок.

    -

    Используйте for(elem of collection) никогда for(elem in collection)

    -

    for of новое, for in устаревшее. for in имело проблемы, которые решаются for of

    -

    В качестве одного примера вы можете перебрать все пары ключ / значение объекта с

    -
    for (const [key, value] of Object.entries(someObject)) {
    -  console.log(key, value);
    -}
    -
    -

    Используйте forEach, map, и filter где это полезно

    -

    Массивы добавили функции forEach, -map, and -filter и довольно широко используются в современном JavaScript.

    -

    Используйте деструктуризацию

    -

    Предположим, что объект const dims = {width: 300, height: 150}

    -

    старый код

    -
     const width = dims.width;
    - const height = dims.height;
    -

    новый код

    -
     const {width, height} = dims;
    -

    Используйте сокращения объявления объекта

    -

    старый код

    -
     const width = 300;
    - const height = 150;
    - const obj = {
    -   width: width,
    -   height: height,
    -   area: function() {
    -     return this.width * this.height
    -   },
    - };
    -
    -

    новый код

    -
     const width = 300;
    - const height = 150;
    - const obj = {
    -   width,
    -   height,
    -   area() {
    -     return this.width * this.height;
    -   },
    - };
    -
    -

    Используйте оператор распространения ...

    -

    У оператора распространения есть масса применений. пример

    -
     function log(className, ...args) {
    -   const elem = document.createElement('div');
    -   elem.className = className;
    -   elem.textContent = [...args].join(' ');
    -   document.body.appendChild(elem);
    - }
    -
    -

    Другой пример

    -
    const position = [1, 2, 3];
    -somemesh.position.set(...position);
    -
    -

    Используйте class

    -

    Синтаксис создания классов, подобных объектам до ES5, был незнаком большинству программистов. Начиная с ES5, теперь вы можете использовать ключевое слово class -которое ближе к стилю C ++ / C # / Java.

    -

    Понимать Getters и Setters

    -

    Getters и -setters -распространены в большинстве современных языков. Синтаксис класса ES5 делает их намного проще, чем до ES5.

    -

    При необходимости используйте стрелочные функции

    -

    Это особенно полезно с обратными вызовами и обещаниями.

    -
    loader.load((texture) => {
    -  // use texture
    -});
    -
    -

    Стрелочные функции связывают this.

    -
    const foo = (args) => {/* code */};
    -
    -

    это короткий путь для

    -
    const foo = (function(args) {/* code */}).bind(this));
    -
    -

    Обещания, а также async / await

    -

    Обещания помогут с асинхронным кодом. Async/await поможет использовать обещания.

    -

    Это слишком большая тема для обсуждения, но вы можете прочитать об обещаниях здесь async / await здесь.

    -

    Использовать литералы шаблонов

    -

    Шаблонные литералы - это строки, в которых используются кавычки вместо кавычек.

    -
    const foo = `this is a template literal`;
    -

    Шаблонные литералы имеют в основном 2 функции. Во-первых, они могут быть многолинейными

    -
    const foo = `this
    -is
    -a
    -template
    -literal`;
    -const bar = "this\nis\na\ntemplate\nliteral";
    -
    -

    foo и bar выше одинаковы.

    -

    Другое - вы можете выйти из строкового режима и вставить фрагменты JavaScript, используя ${javascript-expression}. Это часть шаблона. Пример:

    -
    const r = 192;
    -const g = 255;
    -const b = 64;
    -const rgbCSSColor = `rgb(${r},${g},${b})`;
    -
    -

    или

    -
    const color = [192, 255, 64];
    -const rgbCSSColor = `rgb(${color.join(',')})`;
    -
    -

    или

    -
    const aWidth = 10;
    -const bWidth = 20;
    -someElement.style.width = `${aWidth + bWidth}px`;
    -
    -

    Изучите правила кодирования JavaScript.

    -

    Хотя вы можете форматировать код любым способом, который вы выбрали, существует по крайней мере одно соглашение, о котором вам следует знать. -Переменные, имена функций, имена методов в JavaScript - это lowerCasedCamelCase. Конструкторы, имена классов которых CapitalizedCamelCase. -Если вы будете следовать этому правилу, ваш код будет соответствовать большинству других JavaScript. Многие linters, -программы, которые проверяют очевидные ошибки в вашем коде, будут указывать на ошибки, -если вы используете неправильный регистр, поскольку, следуя приведенному выше соглашению, они знают, что они неправильные.

    -
    const v = new vector(); // clearly an error if all classes start with a capital letter
    -const v = Vector();     // clearly an error if all functions start with a lowercase latter.
    -
    -

    Рассмотрите возможность использования Visual Studio Code

    -

    Конечно, используйте любой редактор, который вам нужен, но если вы еще не пробовали его, -рассмотрите возможность использования Visual Studio Code для JavaScript и после его установки -настройте eslint. -Установка может занять несколько минут, но это поможет вам найти ошибки в вашем JavaScript.

    -

    Несколько примеров

    -

    Если вы включите правило no-undef то VSCode через ESLint предупредит вас о многих неопределенных переменных.

    -
    - -

    Вы можете видеть, что я неправильно написал doTheThing как doThing. -Под doThing есть красная "закорючка", и подсказка над ним говорит мне, что оно не определено. Одной ошибки удалось избежать.

    -

    Вы получите предупреждения, используя THREE, поэтому добавьте /* global THREE */ вверху ваших файлов JavaScript, чтобы сообщить eslint, что THREE существует.

    -
    - -

    Вы можете видеть, что eslint знает правило, что UpperCaseNames являются конструкторами, и поэтому вы должны использовать new. -Еще одну ошибку поймали и избежали. Это правило -new-cap.

    -

    Есть 100 правил, которые вы можете включить или выключить или настроить. -Например, выше я упомянул, что вы должны использовать const и пропустить var.

    -

    Здесь я использовал var, и он предупредил меня, что я должен использовать let или const

    -
    - -

    Здесь я использовал let, но он увидел, что я никогда не меняю значение, поэтому предложил использовать const.

    -
    - -

    Конечно, если вы предпочитаете продолжать использовать var, вы можете просто отключить это правило. -Как я уже говорил выше, я предпочитаю использовать const и let вместо var, так как они работают лучше и предотвращают ошибки.

    -

    В тех случаях, когда вам действительно необходимо переопределить правило вы можете добавить комментарии, чтобы отключить их -для отдельной строки или раздела кода.

    -

    Если вам действительно нужно поддерживать устаревшие браузеры, используйте transpiler

    -

    Большинство современных браузеров обновляются автоматически, поэтому использование всех этих функций поможет вам повысить производительность и избежать ошибок. -Тем не менее, если вы находитесь в проекте, который обязательно должен поддерживать старые браузеры, есть -инструменты, которые возьмут ваш код ES5 / ES6 / ES7 и перенесут код обратно в предварительно ES5 Javascript..

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/primitives.html b/manual/ru/primitives.html deleted file mode 100644 index b6b2f6d875a6f2..00000000000000 --- a/manual/ru/primitives.html +++ /dev/null @@ -1,294 +0,0 @@ - - - Примитивы - - - - - - - - - - - - - -
    -
    -

    Примитивы

    -
    -
    -
    -

    Эта статья является частью серии статей о three.js. -Первая была об основах. -Если вы её еще не читали, советую вам сделать это.

    -

    Three.js имеет большое количество примитивов. Примитивы, как правило, -представляют собой трехмерные фигуры, которые генерируются во время -выполнения с помощью набора параметров.

    -

    Примитивы используются для таких вещей, как сфера для глобуса или куча -прямоугольников для рисования трехмерного графика. Особенно часто -используются примитивы для экспериментов и начала работы с 3D. -Для большинства 3D-приложений художник чаще всего создает 3D-модели -в программе 3D-моделирования. Позже в этой серии мы рассмотрим -создание и загрузку данных из нескольких программ 3D-моделирования. -А сейчас давайте рассмотрим некоторые из доступных примитивов.

    -
    Прямоугольный параллелепипед
    -
    Круг
    -
    Конус
    -
    Цилиндр
    -
    Додекаэдр (12 граней)
    -
    Выдавленная 2d фигура с скругленными краями. -Здесь мы выдавливаем форму сердца. Обратите внимание, это основа -для TextGeometry и TextGeometry соответственно.
    -
    Икосаэдр (20 граней)
    -
    Форма, созданная вращением линии. Например, лампы, кегли для боулинга, свечи, подсвечники, бокалы для вина, стаканы для питья и т. Д. Вы указываете 2-мерный силуэт в виде серии точек, а затем указываете three.js , сколько секций нужно сделать, когда он вращает силуэт вокруг оси.
    -
    Октаэдр (8 граней)
    -
    Поверхность, созданная путем предоставления функции, которая берет 2d точку из сетки и возвращает соответствующую 3d точку.
    -
    2D плоскость
    -
    Берет набор треугольников с центром вокруг точки и проецирует их на сферу
    -
    2D диск с отверстием в центре
    -
    2D контур, который строится из треугольников
    -
    Сфера
    -
    Тераэдр (4 грани)
    -
    3D-текст, сгенерированный из 3D-шрифта и строки
    -
    Тор (пончик)
    -
    Торический узел
    -
    Труба - круг проходящий путь
    -
    Вспомогательный объект, который принимает другую геометрию в качестве входных данных и генерирует ребра, только если угол между гранями больше некоторого порога. Например, если вы посмотрите на прямоугольник сверху, он показывает линию, проходящую через каждую грань, показывая каждый треугольник, из которого состоит прямоугольник. Используя EdgesGeometry, вместо этого удаляются средние линии.
    -
    Создает геометрию, которая содержит один отрезок (2 точки) на ребро в заданной геометрии. Без этого вы часто теряете ребра или получаете дополнительные ребра, поскольку WebGL обычно требует 2 точки на отрезок. Например, если бы у вас был только один треугольник, было бы только 3 очка. Если вы попытаетесь нарисовать его, используя материал с wireframe: true вы получите только одну линию. А передача этой triangle geometry в WireframeGeometry создаст новую геометрию, которая имеет 3 отрезка линий, используя 6 точек..
    - -

    Мы рассмотрим создание пользовательской геометрии в другой статье. -А пока давайте создадим пример создания каждого типа примитива. -Начнем с примеров из предыдущей статьи.

    -

    Близ вершины давайте установим цвет фона в светло-серый

    -
    const scene = new THREE.Scene();
    -+scene.background = new THREE.Color(0xAAAAAA);
    -

    Камера должна изменить положение, чтобы мы могли видеть все объекты.

    -
    -const fov = 75;
    -+const fov = 40;
    -const aspect = 2;  // the canvas default
    -const near = 0.1;
    --const far = 5;
    -+const far = 1000;
    -const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    --camera.position.z = 2;
    -+camera.position.z = 120;
    -

    Давайте добавим функцию, addObject, которая добавляет -объект Object3D на сцену в позицию x, y.

    -
    const objects = [];
    -const spread = 15;
    -
    -function addObject(x, y, obj) {
    -  obj.position.x = x * spread;
    -  obj.position.y = y * spread;
    -
    -  scene.add(obj);
    -  objects.push(obj);
    -}
    -

    Давайте также сделаем функцию для создания случайно раскрашенного материала. -Мы будем использовать особенность Color которая позволяет вам установить цвет -на основе оттенка, насыщенности и яркости (hue, saturation, luminance).

    -

    hue идет от 0 до 1 вокруг цветового круга с красным на 0, -зеленым на .33 и синим на .66. saturation -изменяется от 0 до 1, где 0 не имеет цвета, а 1 наиболее насыщен. -luminance изменяется от 0 до 1, где 0 - черный, 1 - белый, -а 0.5 максимальное количество цвета. Другими словами, -luminance от 0,0 до 0,5 цвет будет изменяться с черного на hue. -От 0,5 до 1,0 цвет изменится hue на белый.

    -
    function createMaterial() {
    -  const material = new THREE.MeshPhongMaterial({
    -    side: THREE.DoubleSide,
    -  });
    -
    -  const hue = Math.random();
    -  const saturation = 1;
    -  const luminance = .5;
    -  material.color.setHSL(hue, saturation, luminance);
    -
    -  return material;
    -}
    -

    Мы также передаем материалу side: THREE.DoubleSide. -Это говорит three нарисовать обе стороны треугольников, -которые составляют форму. Для сплошной (solid) формы, -такой как сфера или куб, обычно нет причин рисовать -задние стороны треугольников, поскольку все они обращены -внутрь фигуры. В нашем случае мы рисуем несколько вещей, -таких как PlaneGeometry и ShapeGeometry -которые являются двухмерными и поэтому не имеют внутренней -части. Без установки side: THREE.DoubleSide они исчезнут, -при взгляде на их задние стороны.

    -

    Я должен отметить, что отрисовка быстрее, когда не установлено -side: THREE.DoubleSide в реальной работе, мы бы устанавливали -его только для материалов, которые действительно в этом нуждаются, -но в этом случае мы не рисуем слишком много, поэтому нет -причин для беспокойства.

    -

    Давайте создадим функцию addSolidGeometry, которой мы передадим -геометрию, и она создаст случайно раскрашенный материал -createMaterial и addObject добавит его в сцену.

    -
    function addSolidGeometry(x, y, geometry) {
    -  const mesh = new THREE.Mesh(geometry, createMaterial());
    -  addObject(x, y, mesh);
    -}
    -

    Теперь мы можем использовать это для большинства примитивов, -которые мы создаем. Например, создание прямоугольного параллелепипеда

    -
    {
    -  const width = 8;
    -  const height = 8;
    -  const depth = 8;
    -  addSolidGeometry(-2, -2, new THREE.BoxGeometry(width, height, depth));
    -}
    -

    Если вы посмотрите на код ниже, вы увидите похожую часть для -каждого типа геометрии.

    -

    Вот результат:

    -

    - -

    -

    Есть несколько заметных исключений из шаблона выше. -Самым большим, вероятно, является TextGeometry. Он должен -загрузить данные 3D шрифта, прежде чем он сможет сгенерировать -сетку для текста. Эти данные загружаются асинхронно, поэтому -нам нужно дождаться их загрузки, прежде чем пытаться создать -геометрию. Вы можете увидеть ниже, мы создаем FontLoader -и передаем его URL нашему шрифту и обратному вызову (callback). -Обратный вызов срабатывает после загрузки шрифта. -В обратном вызове мы создаем геометрию и вызываем addObject, -чтобы добавить к ней сцену.

    -
    {
    -  const loader = new FontLoader();
    -  loader.load('../resources/threejs/fonts/helvetiker_regular.typeface.json', (font) => {
    -    const geometry = new TextGeometry('three.js', {
    -      font: font,
    -      size: 3.0,
    -      height: .2,
    -      curveSegments: 12,
    -      bevelEnabled: true,
    -      bevelThickness: 0.15,
    -      bevelSize: .3,
    -      bevelSegments: 5,
    -    });
    -    const mesh = new THREE.Mesh(geometry, createMaterial());
    -    geometry.computeBoundingBox();
    -    geometry.boundingBox.getCenter(mesh.position).multiplyScalar(-1);
    -
    -    const parent = new THREE.Object3D();
    -    parent.add(mesh);
    -
    -    addObject(-1, 1, parent);
    -  });
    -}
    -

    Есть еще одно отличие. Мы хотим вращать текст вокруг его центра, -но по умолчанию three.js создает текст таким образом, чтобы его -центр вращения находился на левом краю. Чтобы обойти это, мы -можем попросить three.js вычислить ограничивающую -рамку (bounding box) геометрии. Затем мы можем вызвать getCenter -метод bounding box и передать ему объект позиции нашей полигональной -сетки (mesh). getCenter копирует центр коробки в указанное положение. -Он также возвращает объект position, поэтому мы можем вызвать -multiplyScaler(-1) для позиционирования всего объекта таким образом, -чтобы его центр вращения находился в центре объекта.

    -

    Если бы мы тогда просто вызвали addSolidGeometry как в предыдущих -примерах, это снова установило бы позицию, что не годится. -Итак, в этом случае мы создаем Object3D стандартный узел для графа -сцены three.js. Mesh так же наследуется от Object3D. Мы рассмотрим, -как работает график сцены, в другой статье. -На данный момент достаточно знать, что, как и DOM-узлы, дети рисуются -относительно своего родителя. Сделав Object3D и сделав нашу сетку -дочерней по отношению к этому, мы можем расположить Object3D в то -место, где мы хотим, и при этом сохранить смещение центра, -которое мы установили раньше.

    -

    Если бы мы этого не делали, текст был бы оторван от центра.

    -

    - -

    -

    Обратите внимание, что то что слева не вращается вокруг своего центра, -как то, что справа.

    -

    Другие исключения - это 2-строчные примеры для EdgesGeometryWireframeGeometry. Вместо того, чтобы вызвать addSolidGeometry -они вызвают addLineGeometry который выглядит так

    -
    function addLineGeometry(x, y, geometry) {
    -  const material = new THREE.LineBasicMaterial({color: 0x000000});
    -  const mesh = new THREE.LineSegments(geometry, material);
    -  addObject(x, y, mesh);
    -}
    -

    Он создает черный цвет LineBasicMaterial, а затем создает LineSegments, -который является оберткой Mesh, который помогает three знать, что вы -отрисовываете отрезки линии (2 точки на отрезок).

    -

    Каждый из примитивов имеет несколько параметров, которые вы можете -передать при создании, и лучше всего посмотреть в документации -по всем из них, а не повторять их здесь. Вы также можете нажать на ссылку выше рядом с -каждой фигурой, чтобы перейти непосредственно к документам для этой фигурой.

    -

    Еще одна важная вещь - это то, что почти все фигуры имеют различные настройки того, -как их разделить на полигоны. Хорошим примером может служить геометрия сферы. -Сферы берут параметры для количества делений вокруг и количества делений сверху вниз. -Например

    -
    -
    -
    -
    -
    - -

    Первая сфера имеет 5 сегментов вокруг и 3 высоты, что составляет 15 сегментов -или 30 треугольников. Вторая сфера имеет 24 сегмента на 10. Это 240 сегментов -или 480 треугольников. Последний имеет 50 на 50, что составляет 2500 сегментов -или 5000 треугольников.

    -

    Вам решать, сколько сегментов вам нужно. Может показаться, что вам нужно -большое количество сегментов, но удалите линии и плоскую штриховку, -и мы получим это

    -
    -
    -
    -
    -
    - -

    Сейчас не очень понятно, что тот, который справа с 5000 треугольниками, -полностью лучше, чем тот, что в середине с 480. Если вы рисуете только -несколько сфер, как, например, один глобус для карты земли, то одна -сфера из 10000 треугольников - неплохой выбор. Если, с другой стороны, -вы пытаетесь нарисовать 1000 сфер, то 1000 сфер на 10000 треугольников -каждый - это 10 миллионов треугольников. Для плавной анимации вам нужно, -чтобы браузер рисовал со скоростью 60 кадров в секунду, поэтому вы должны -просить браузер рисовать 600 миллионов треугольников в секунду. Это много -вычислений.

    -

    Иногда выбрать легко. Например, вы можете выбрать разделение для плоскости.

    -
    -
    -
    -
    - -

    Плоскость слева - это 2 треугольника. Плоскость справа - это 200 треугольников. -В отличие от сферы, в большинстве случаев использования плоскости действительно -нет компромисса в качестве. Скорее всего, вы подразделяете плоскость только в -том случае, если вы хотите изменить или деформировать её каким-либо образом. -Для Box аналогично.

    -

    Итак, выберите то, что подходит для вашей ситуации. Чем меньше разбиений -вы выберете, тем более вероятно, что все будет работать гладко и тем меньше -памяти они будут занимать. Вы должны решить для себя, каков правильный -компромисс для вашей конкретной ситуации.

    -

    Далее давайте рассмотрим как работает граф сцены и как его использовать.

    -

    - - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/rendering-on-demand.html b/manual/ru/rendering-on-demand.html deleted file mode 100644 index d343bba5f9a8cd..00000000000000 --- a/manual/ru/rendering-on-demand.html +++ /dev/null @@ -1,219 +0,0 @@ - - - Рендеринг по требованию - - - - - - - - - - - - - -
    -
    -

    Рендеринг по требованию

    -
    -
    -
    -

    Эта тема может быть очевидна для многих людей, но на всякий случай ... большинство примеров Three.js отображаются непрерывно. Другими словами, они устанавливают цикл -requestAnimationFrame или "цикл RAF" примерно так

    -
    function render() {
    -  ...
    -  requestAnimationFrame(render);
    -}
    -requestAnimationFrame(render);
    -
    -

    Для чего-то, что анимируется, это имеет смысл, но как насчет чего-то, что не анимируется? В этом случае непрерывный рендеринг -является пустой тратой энергии устройств, а если пользователь находится на портативном устройстве, он расходует батарею пользователя.

    -

    Самый очевидный способ решить эту проблему - рендерить один раз в начале, а затем рендерить только тогда, когда что-то меняется. -Изменения включают в себя окончательную загрузку текстур или моделей, -данные, поступающие из какого-либо внешнего источника, пользователь, изменяющий настройку или камеру, или другой соответствующий ввод.

    -

    Давайте возьмем пример из статьи об отзывчивости -и изменим его для отображения по требованию.

    -

    Сначала мы добавим в OrbitControls, чтобы можно было что-то изменить, что мы можем сделать в ответ.

    -
    import * as THREE from 'three';
    -+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -
    -

    и настроить их

    -
    const fov = 75;
    -const aspect = 2;  // the canvas default
    -const near = 0.1;
    -const far = 5;
    -const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -camera.position.z = 2;
    -
    -+const controls = new OrbitControls(camera, canvas);
    -+controls.target.set(0, 0, 0);
    -+controls.update();
    -
    -

    Поскольку мы больше не будем анимировать кубы, нам больше не нужно отслеживать их

    -
    -const cubes = [
    --  makeInstance(geometry, 0x44aa88,  0),
    --  makeInstance(geometry, 0x8844aa, -2),
    --  makeInstance(geometry, 0xaa8844,  2),
    --];
    -+makeInstance(geometry, 0x44aa88,  0);
    -+makeInstance(geometry, 0x8844aa, -2);
    -+makeInstance(geometry, 0xaa8844,  2);
    -
    -

    Мы можем удалить код для анимации кубов и вызовы requestAnimationFrame

    -
    -function render(time) {
    --  time *= 0.001;
    -+function render() {
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -  }
    -
    --  cubes.forEach((cube, ndx) => {
    --    const speed = 1 + ndx * .1;
    --    const rot = time * speed;
    --    cube.rotation.x = rot;
    --    cube.rotation.y = rot;
    --  });
    -
    -  renderer.render(scene, camera);
    -
    --  requestAnimationFrame(render);
    -}
    -
    --requestAnimationFrame(render);
    -
    -

    тогда нам нужно отрендерить один раз

    -
    render();
    -
    -

    Нам нужно рендерить каждый раз, когда OrbitControls меняет настройки камеры. К счастью, OrbitControls отправляет событие change каждый раз, когда что-то меняется.

    -
    controls.addEventListener('change', render);
    -
    -

    Нам также нужно обработать случай, когда пользователь изменяет размер окна. -Раньше это было обработано автоматически, так как мы рендерили непрерывно, -но теперь нам это не нужно, нужно рендерить, когда окно меняет размер.

    -
    window.addEventListener('resize', render);
    -
    -

    И с этим мы получаем что-то, что рендерит по требованию.

    -

    - -

    -

    У OrbitControls есть опции для добавления некоторой инерции, чтобы они чувствовали себя менее интенсивными. -Мы можем включить это, установив для свойства enableDamping значение true.

    -
    controls.enableDamping = true;
    -
    -

    С включенной функцией enableDamping нам нужно вызвать controls.update в нашей функции рендеринга, -чтобы OrbitControls продолжал предоставлять нам новые настройки камеры, поскольку они сглаживают движение. -Но это означает, что мы не можем вызвать render напрямую из события change, потому что мы окажемся в бесконечном цикле. -Элементы управления отправят нам событие change и вызовут render, render вызовет controls.update. -controls.update отправит еще одно событие change.

    -

    Мы можем исправить это, используя requestAnimationFrame для вызова render, но нам нужно убедиться, что мы запрашиваем новый кадр, -только если он еще не был запрошен, что мы можем сделать, сохраняя переменную, которая отслеживает, если мы уже запросили кадр.

    -
    +let renderRequested = false;
    -
    -function render() {
    -+  renderRequested = false;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -  renderer.render(scene, camera);
    -}
    -render();
    -
    -+function requestRenderIfNotRequested() {
    -+  if (!renderRequested) {
    -+    renderRequested = true;
    -+    requestAnimationFrame(render);
    -+  }
    -+}
    -
    --controls.addEventListener('change', render);
    -+controls.addEventListener('change', requestRenderIfNotRequested);
    -
    -

    Возможно, нам также следует использовать requestRenderIfNotRequested для изменения размера

    -
    -window.addEventListener('resize', render);
    -+window.addEventListener('resize', requestRenderIfNotRequested);
    -
    -

    Может быть трудно увидеть разницу. Попробуйте нажать на приведенный ниже пример и использовать -клавиши со стрелками для перемещения или перетаскивать для вращения. -Затем попробуйте нажать на приведенный выше пример и сделайте то же самое, и вы сможете увидеть разницу. -В приведенном примере при нажатии клавиши со стрелкой или перетаскивании мышью, кубики проскальзывают.

    -

    - -

    -

    Давайте также добавим простой графический интерфейс lil-gui и внесем его изменения по запросу.

    -
    import * as THREE from 'three';
    -import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -+import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
    -
    -

    Давайте позволим установить цвет и шкалу х каждого куба. Чтобы установить цвет, мы будем использовать ColorGUIHelper, который мы создали в статье о светах.

    -

    Сначала нам нужно создать графический интерфейс

    -
    const gui = new GUI();
    -
    -

    а затем для каждого куба мы создадим папку и добавим 2 элемента управления, -один для material.color и другой для cube.scale.x.

    -
    function makeInstance(geometry, color, x) {
    -  const material = new THREE.MeshPhongMaterial({color});
    -
    -  const cube = new THREE.Mesh(geometry, material);
    -  scene.add(cube);
    -
    -  cube.position.x = x;
    -
    -+  const folder = gui.addFolder(`Cube${x}`);
    -+  folder.addColor(new ColorGUIHelper(material, 'color'), 'value')
    -+      .name('color')
    -+      .onChange(requestRenderIfNotRequested);
    -+  folder.add(cube.scale, 'x', .1, 1.5)
    -+      .name('scale x')
    -+      .onChange(requestRenderIfNotRequested);
    -+  folder.open();
    -
    -  return cube;
    -}
    -
    -

    Вы можете видеть выше элементы управления lil-gui имеют метод onChange, -который вы можете передать обратный вызов для вызова, когда графический интерфейс изменяет значение. -В нашем случае нам просто нужно вызвать requestRenderIfNotRequested. При вызове folder.open папка запускается расширенной.

    -

    - -

    -

    Я надеюсь, что это дает некоторое представление о том, как сделать three.js визуализированным -по требованию, а не непрерывно. Приложения / страницы, которые отображают Three.js по требованию, -не так часто встречаются, так как большинство страниц, использующих Three.js, являются либо играми, -либо 3D-анимацией, но примеры страниц, которые могут быть лучше прорисованы по требованию, - это, -скажем, просмотрщик карт, 3D-редактор, генератор трехмерных графиков, каталог продуктов и т. д.

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/rendertargets.html b/manual/ru/rendertargets.html deleted file mode 100644 index a4fae0e2f4dba3..00000000000000 --- a/manual/ru/rendertargets.html +++ /dev/null @@ -1,163 +0,0 @@ - - - Цели рендеринга - - - - - - - - - - - - - -
    -
    -

    Цели рендеринга

    -
    -
    -
    -

    Цель рендеринга в three.js - это в основном текстура, которую вы можете рендерить. После рендеринга вы можете использовать эту текстуру как любую другую текстуру.

    -

    Давайте сделаем простой пример. Начнем с примера статьи об отзывчивости.

    -

    Рендеринг к цели рендеринга практически идентичен обычному рендерингу. Сначала мы создаем WebGLRenderTarget.

    -
    const rtWidth = 512;
    -const rtHeight = 512;
    -const renderTarget = new THREE.WebGLRenderTarget(rtWidth, rtHeight);
    -
    -

    Затем нам нужна Camera и Scene

    -
    const rtFov = 75;
    -const rtAspect = rtWidth / rtHeight;
    -const rtNear = 0.1;
    -const rtFar = 5;
    -const rtCamera = new THREE.PerspectiveCamera(rtFov, rtAspect, rtNear, rtFar);
    -rtCamera.position.z = 2;
    -
    -const rtScene = new THREE.Scene();
    -rtScene.background = new THREE.Color('red');
    -
    -

    Обратите внимание, что мы устанавливаем aspect для aspect рендеринга, а не для canvas. Правильный aspect зависит от того, для чего мы выполняем рендеринг. -В этом случае мы будем использовать текстуру цели рендеринга на стороне куба. Поскольку грани куба являются квадратными, мы хотим, чтобы aspect = 1.0.

    -

    Мы заполняем сцену материалом. В этом случае мы используем свет и 3 куба из предыдущей статьи.

    -
    {
    -  const color = 0xFFFFFF;
    -  const intensity = 1;
    -  const light = new THREE.DirectionalLight(color, intensity);
    -  light.position.set(-1, 2, 4);
    -*  rtScene.add(light);
    -}
    -
    -const boxWidth = 1;
    -const boxHeight = 1;
    -const boxDepth = 1;
    -const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    -
    -function makeInstance(geometry, color, x) {
    -  const material = new THREE.MeshPhongMaterial({color});
    -
    -  const cube = new THREE.Mesh(geometry, material);
    -*  rtScene.add(cube);
    -
    -  cube.position.x = x;
    -
    -  return cube;
    -}
    -
    -*const rtCubes = [
    -  makeInstance(geometry, 0x44aa88,  0),
    -  makeInstance(geometry, 0x8844aa, -2),
    -  makeInstance(geometry, 0xaa8844,  2),
    -];
    -
    -

    Scene и Camera из предыдущей статьи все еще там. Мы будем использовать их для рендеринга на холст. Нам просто нужно добавить материал для рендеринга.

    -

    Давайте добавим куб, который использует текстуру цели рендеринга.

    -
    const material = new THREE.MeshPhongMaterial({
    -  map: renderTarget.texture,
    -});
    -const cube = new THREE.Mesh(geometry, material);
    -scene.add(cube);
    -
    -

    Теперь во время рендеринга сначала мы рендерим целевую сцену рендеринга в цель рендеринга.

    -
    function render(time) {
    -  time *= 0.001;
    -
    -  ...
    -
    -  // rotate all the cubes in the render target scene
    -  rtCubes.forEach((cube, ndx) => {
    -    const speed = 1 + ndx * .1;
    -    const rot = time * speed;
    -    cube.rotation.x = rot;
    -    cube.rotation.y = rot;
    -  });
    -
    -  // draw render target scene to render target
    -  renderer.setRenderTarget(renderTarget);
    -  renderer.render(rtScene, rtCamera);
    -  renderer.setRenderTarget(null);
    -
    -

    Затем мы визуализируем сцену с помощью одного куба, который использует текстуру цели рендеринга для холста.

    -
      // rotate the cube in the scene
    -  cube.rotation.x = time;
    -  cube.rotation.y = time * 1.1;
    -
    -  // render the scene to the canvas
    -  renderer.render(scene, camera);
    -
    -

    И вуаля

    -

    - -

    -

    Куб красный, потому что мы установили background rtScene на красный, чтобы текстура цели рендеринга была очищена до красного.

    -

    Цели рендеринга используются для всех видов вещей. Тени используют цели рендеринга. -Сбор может использовать цель рендеринга. Различные виды -эффектов постобработки требуют целей рендеринга. -Для рендеринга зеркала заднего вида в автомобиле или просмотра в реальном времени на мониторе в 3D-сцене может использоваться цель рендеринга.

    -

    Несколько замечаний по использованию WebGLRenderTarget.

    -
      -
    • По умолчанию WebGLRenderTarget создает 2 текстуры. Цветная текстура и текстура глубины / трафарета. Если вам не нужны текстуры глубины или трафарета, вы можете попросить не создавать их, передав параметры. Пример:

      -
        const rt = new THREE.WebGLRenderTarget(width, height, {
      -    depthBuffer: false,
      -    stencilBuffer: false,
      -  });
      -
      -
    • -
    • Возможно, вам придется изменить размер цели рендеринга

      -

      В приведенном выше примере мы делаем цель рендеринга фиксированного размера, 512x512. Для таких вещей, как постобработка, вам обычно нужно сделать цель рендеринга того же размера, что и ваш холст. В нашем коде это означало бы, что при изменении размера холста мы также обновляли бы и размер цели рендеринга, и камеру, которую мы используем при рендеринге, цели рендеринга. Пример:

      -
      function render(time) {
      -  time *= 0.001;
      -
      -  if (resizeRendererToDisplaySize(renderer)) {
      -    const canvas = renderer.domElement;
      -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
      -    camera.updateProjectionMatrix();
      -
      -+    renderTarget.setSize(canvas.width, canvas.height);
      -+    rtCamera.aspect = camera.aspect;
      -+    rtCamera.updateProjectionMatrix();
      -}
      -
    • -
    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/responsive.html b/manual/ru/responsive.html deleted file mode 100644 index b84622d37b2c45..00000000000000 --- a/manual/ru/responsive.html +++ /dev/null @@ -1,264 +0,0 @@ - - - Oтзывчивый Дизайн - - - - - - - - - - - - - -
    -
    -

    Oтзывчивый Дизайн

    -
    -
    -
    -

    Это вторая статья в серии статей о three.js. -Первая была об основах. -Если вы её еще не читали, советую вам сделать это.

    -

    Эта статья о том, как заставить ваше приложение three.js -реагировать на любую ситуацию. Создание адаптивной веб-страницы -обычно означает, что страница хорошо отображается на экранах -разных размеров - от настольных компьютеров до планшетов и телефонов.

    -

    Для three.js нужно рассмотреть еще больше ситуаций. Например, -3D-редактор с элементами управления слева, справа, сверху или -снизу - это то, что мы можем захотеть обработать. Динамическая диаграмма -в середине документа является еще одним примером.

    -

    В последнем примере мы использовали простой холст без указания CSS и размера

    -
    <canvas id="c"></canvas>
    -

    Этот холст по умолчанию имеет размер 300x150 CSS пикселей.

    -

    В вебе рекомендуемый способ установить размер чего-либо - использовать CSS.

    -

    Прим. переводчика: Далее идет слишком подробное описание банальностей.

    -

    Давайте растянем холст на всю страницу:

    -
    <style>
    -html, body {
    -   margin: 0;
    -   height: 100%;
    -}
    -#c {
    -   width: 100%;
    -   height: 100%;
    -   display: block;
    -}
    -</style>
    -

    HTML body по умолчанию имеет margin в 5 пикселей, поэтому установка в 0 удаляет margin. -Установка высоты html и body на 100% заставляет их заполнить окно. В противном случае -они будут такими же большими, как контент, который их наполняет.

    -

    Далее мы говорим, что id=c элемент будет занимать 100% его контейнера, -который в данном случае является телом документа.

    -

    Наконец, мы установили его display режим на block. Режим отображения canvas -по умолчанию inline. Inline элементы могут в конечном итоге добавить пробел к тому, -что отображается. Устанавливая canvas'у display: block эта проблема исчезает.

    -

    Вот результат

    -

    - -

    -

    Вы можете видеть, что холст сейчас заполняет страницу, но есть 2 проблемы.

    -
      -
    1. Кубы растягиваются. Они не кубики, они больше похожи на прямоугольные параллелепипеды. -Слишком высокие или слишком широкие. Откройте пример в его отдельном окне и измените -его размер. Вы увидите, как кубы растягиваются то вширь, то ввысь.
    2. -
    -

    -
      -
    1. Они выглядят пиксельно и размыто. -Растяните окно больше, и вы действительно увидите проблему.
    2. -
    -

    -

    Давайте сначала исправим проблему растяжения. Для этого нам нужно установить соотношение -сторон (aspect) камеры в соответствии с размером холста. -Мы можем сделать это, задав холсту свойства -clientWidth и clientHeight.

    -

    Мы обновим наш цикл отрисовки следующим образом

    -
    function render(time) {
    -  time *= 0.001;
    -
    -+  const canvas = renderer.domElement;
    -+  camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -+  camera.updateProjectionMatrix();
    -
    -  ...
    -

    Теперь кубики должны перестать быть искаженными.

    -

    - -

    -

    Откройте пример в отдельном окне и измените размер окна, и вы увидите, что кубы больше -не растянуты по высоте или ширине. Они остаются правильными, независимо от размера окна.

    -

    -

    Теперь давайте исправим пиксильность.

    -

    Canvas элемент имеет 2 размера. Один размер - это размер холста, -отображаемый на странице. Это то, что мы устанавливаем с помощью CSS. -Другой размер - это количество пикселей на холсте. -Это ничем не отличается от изображения. Например, у нас может быть -изображение размером 128x64 пикселей, а с помощью css мы можем отобразить -как 400x200 пикселей.

    -
    <img src="some128x64image.jpg" style="width:400px; height:200px">
    -

    Внутренний размер холста, его разрешение часто называют размером рисованного -буфера (drawingbuffer size). В three.js мы можем установить размер буфера -рисования холста, вызывая renderer.setSize. Какой размер мы должны выбрать? -Самый очевидный ответ - "тот же размер, что отображается на холсте". -Опять же, чтобы сделать это, мы можем посмотреть на clientWidth и clientHeight -свойства.

    -

    Давайте напишем функцию, которая проверяет, совпадает ли размер рисованного буфера -с размером, на котором он отображается, и, если это так, зададим холсту этот размер.

    -
    function resizeRendererToDisplaySize(renderer) {
    -  const canvas = renderer.domElement;
    -  const width = canvas.clientWidth;
    -  const height = canvas.clientHeight;
    -  const needResize = canvas.width !== width || canvas.height !== height;
    -  if (needResize) {
    -    renderer.setSize(width, height, false);
    -  }
    -  return needResize;
    -}
    -

    Обратите внимание, что мы проверяем, нужно ли изменять размер холста. -Изменение размера холста - интересная часть спецификации холста, -и лучше не устанавливать тот же размер, если он уже соответствует желаемому.

    -

    Как только мы узнаем, нужно ли нам изменить размер или нет, мы вызываем -renderer.setSize и передаем новую ширину и высоту. Важно передать false в конце. -renderer.setSize по умолчанию устанавливает размер CSS холста, но это не то, -что нам нужно. Мы хотим, чтобы браузер продолжал работать так же, как и -для всех других элементов, то есть использовать CSS для определения размера -отображения элемента. Мы не хотим, чтобы холсты, используемые three.js, -отличались от других элементов.

    -

    Обратите внимание, что наша функция возвращает true, если размер холста был изменен. -Мы можем использовать это, чтобы проверить, есть ли другие вещи, которые мы -должны обновить. Давайте изменим наш цикл отрисовки, чтобы использовать -новую функцию.

    -
    function render(time) {
    -  time *= 0.001;
    -
    -+  if (resizeRendererToDisplaySize(renderer)) {
    -+    const canvas = renderer.domElement;
    -+    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -+    camera.updateProjectionMatrix();
    -+  }
    -
    -  ...
    -

    Поскольку apsect будет меняться только в случае изменения размера холста, мы -устанавливаем соотношение сторон камеры, только если resizeRendererToDisplaySize -вернёт true.

    -

    - -

    -

    Теперь он должен отображаться с разрешением, -соответствующим размеру изображения на холсте.

    -

    Чтобы сделать так, чтобы CSS позволял обрабатывать изменение размера, -давайте возьмем наш код и поместим его в отдельный .js файл. -Вот еще несколько примеров, где мы позволяем CSS выбирать размер, без написания кода.

    -

    Давайте поместим наши кубики в середине абзаца текста.

    -

    - -

    -

    и вот наш тот же код, используемый в макете стиля редактора, -где область управления справа может менять размер.

    -

    - -

    -

    Важная часть, на которую следует обратить внимание - отсутствие изменений прежнего кода. -Только HTML и CSS изменились.

    -

    Обработка дисплеев HD-DPI

    -

    HD-DPI - дисплеи с высокой плотностью точек на дюйм. Сейчас они у большинства -компьютеров Mac, многих компьютеров с Windows, а также почти всех смартфонов.

    -

    То, как это работает в браузере, заключается в том, что они используют -пиксели CSS для установки размеров, которые должны быть одинаковыми, -независимо от того, насколько высоким является разрешение дисплея. -Браузер будет просто отображать текст с большей детализацией, но с -таким же физическим размером.

    -

    Существуют различные способы обработки HD-DPI с помощью three.js.

    -

    Первый - просто не делать ничего особенного. Это, пожалуй, -самый распространенный. Рендеринг 3D-графики занимает много -вычислительной мощности графического процессора. Мобильные -графические процессоры имеют меньшую мощность, чем настольные -компьютеры, по крайней мере на 2018 год, и все же мобильные -телефоны часто имеют дисплеи с очень высоким разрешением. -Нынешние топовые телефоны имеют соотношение HD-DPI 3x, означающее, -что для каждого пикселя с дисплея без HD-DPI эти телефоны имеют 9 -пикселей. Это означает, что они должны сделать 9-кратный рендеринг.

    -

    Вычисление 9x пикселей - большая работа, поэтому, если мы просто -оставим код таким, какой он есть, мы вычислим 1x пикселей, -а браузер просто нарисует его в 3x размере (3x на 3x = 9x пикселей).

    -

    Для любого тяжелого приложения three.js это, вероятно, то, что вам -нужно, иначе вы, вероятно, получите медленную частоту кадров (FPS).

    -

    Тем не менее, если вы действительно хотите рендерить с разрешением -устройства, есть три способа сделать это в three.js.

    -

    Один из них заключается в том, чтобы сообщить Three.js множитель -разрешения, используя renderer.setPixelRatio. -Вы спрашиваете браузер, каков множитель пикселей CSS -для пикселей устройства, и передаете его в three.js.

    -
     renderer.setPixelRatio(window.devicePixelRatio);
    -

    После этого любые вызовы renderer.setSize будут магически -использовать размер, который вы запрашиваете, -умноженный на любое количество пикселей, которое вы передали.

    -

    Другой способ - сделать это самостоятельно, когда вы измените размер холста.

    -
        function resizeRendererToDisplaySize(renderer) {
    -      const canvas = renderer.domElement;
    -      const pixelRatio = window.devicePixelRatio;
    -      const width = Math.floor( canvas.clientWidth * pixelRatio );
    -      const height = Math.floor( canvas.clientHeight * pixelRatio );
    -      const needResize = canvas.width !== width || canvas.height !== height;
    -      if (needResize) {
    -        renderer.setSize(width, height, false);
    -      }
    -      return needResize;
    -    }
    -

    Я предпочитаю этот второй способ. Зачем? Потому что это означает, что я -получаю то, что я прошу. При использовании three.js существует много случаев, -когда нам нужно знать фактический размер canvas's drawingBuffer. Например, -при создании фильтра пост-обработки, или если мы создаем шейдер, -который получает доступ gl_FragCoord и прочее... Делая это самостоятельно, -мы всегда знаем, что используемый размер - это размер, -который мы запрашивали. Не существует особого случая, -когда магия происходит за кулисами.

    -

    Вот пример использования кода выше.

    -

    - -

    -

    Может быть трудно увидеть разницу, но если у вас есть дисплей HD-DPI -и вы сравниваете этот образец с приведенными выше, -вы должны заметить, что края более четкие.

    -

    Эта статья охватывает очень основную, но фундаментальную тему. Далее давайте быстро -пройдемся по основным примитивам, которые предоставляет three.js.

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/scenegraph.html b/manual/ru/scenegraph.html deleted file mode 100644 index 4941572c1db71c..00000000000000 --- a/manual/ru/scenegraph.html +++ /dev/null @@ -1,382 +0,0 @@ - - - Граф сцены - - - - - - - - - - - - - -
    -
    -

    Граф сцены

    -
    -
    -
    -

    Эта статья является частью серии статей о three.js. -Первая статья - основы Three.js. -Если вы её еще не читали, советую вам сделать это.

    -

    Ядром Three.js, возможно, является граф сцены. Граф сцены в трехмерном -движке - это иерархия узлов в графе, где каждый узел представляет -локальное пространство.

    -

    -

    Это своего рода абстракция, поэтому давайте попробуем привести несколько примеров.

    -

    Одним из примеров может быть солнечная система, солнце, земля, луна.

    -

    -

    Земля вращается вокруг Солнца. Луна вращается вокруг Земли. Луна движется по -кругу вокруг Земли. С точки зрения Луны она вращается в «локальном пространстве» -Земли. Хотя его движение относительно Солнца с точки зрения Луны представляет -собой какой-то сумасшедший спирографический изгиб, ему просто нужно заниматься -вращением вокруг локального пространства Земли.

    -

    - -
    - -

    -

    Чтобы думать об этом иначе, вы, живущие на Земле, не должны думать о вращении -Земли вокруг своей оси или о вращении вокруг Солнца. Вы просто идете или едете, -или плаваете, или бежите, как будто Земля вообще не движется и не вращается. -Вы идете, ездите, плаваете, бегаете и живете в «локальном пространстве» Земли, -хотя относительно Солнца вы вращаетесь вокруг Земли со скоростью около -1000 миль в час, а вокруг Солнца - около 67 000 миль в час. Ваше положение -в Солнечной системе похоже на положение Луны наверху, но вам не нужно -беспокоиться о себе. Вы просто переживаете за свое положение относительно -земли, ее "локального пространства".

    -

    Давайте сделаем это один шаг за один раз. Представьте, что мы хотим сделать -диаграмму солнца, земли и луны. Мы начнем с солнца, просто сделав сферу -и поместив ее в начало координат. Примечание: мы используем солнце, -землю, луну в качестве демонстрации того, как использовать граф сцены. -Конечно, настоящее Солнце, Земля и Луна используют физику, но для наших -целей мы подделаем это с помощью графа сцены.

    -
    // массив объектов, направление которых обновляется
    -const objects = [];
    -
    -// использовать только одну сферу для всего
    -const radius = 1;
    -const widthSegments = 6;
    -const heightSegments = 6;
    -const sphereGeometry = new THREE.SphereGeometry(
    -    radius, widthSegments, heightSegments);
    -
    -const sunMaterial = new THREE.MeshPhongMaterial({emissive: 0xFFFF00});
    -const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
    -sunMesh.scale.set(5, 5, 5);  // сделать солнце большим
    -scene.add(sunMesh);
    -objects.push(sunMesh);
    -
    -

    Мы используем действительно низкополигональную сферу. -Всего 6 разделений вокруг его экватора. Это так легко увидеть вращение.

    -

    Мы собираемся повторно использовать одну и ту же сферу для всего, -поэтому мы установим масштаб для солнечной полигональной сетки (mesh) в 5x.

    -

    Мы также устанавливаем свойство материала "Затенение по Фонгу" emissive желтым. -Излучающее (emissive) свойство материала Phong - это цвет, который будет рисоваться -без попадания света на поверхность. Свет добавляется к этому цвету.

    -

    Давайте также поместим один точечный источник света в центр сцены. Мы рассмотрим -более подробно о точечных источниках света позже, но пока простая версия -представляет собой точечный источник света.

    -
    {
    -  const color = 0xFFFFFF;
    -  const intensity = 500;
    -  const light = new THREE.PointLight(color, intensity);
    -  scene.add(light);
    -}
    -
    -

    Чтобы было легче увидеть, мы поместим камеру прямо над источником, смотря вниз. -Самый простой способ сделать это - использовать lookAt. lookAt -Функция будет ориентировать камеру из своего положения в "смотриНа -точку переданную lookAt. Перед тем, как сделать это, мы должны сказать -камере, в какую сторону направлена верхняя часть камеры или, скорее, -в какой стороне "верх" для камеры. Для большинства ситуаций положительный -Y - это достаточно хорошо, но, так как мы смотрим прямо вниз, -мы должны сказать камере, что положительный Z - вверхy.

    -
    const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -camera.position.set(0, 50, 0);
    -camera.up.set(0, 0, 1);
    -camera.lookAt(0, 0, 0);
    -
    -

    В цикле отрисовки, переделанном из предыдущих примеров, -мы вращаем все объекты в нашем массиве objects с помощью этого кода.

    -
    objects.forEach((obj) => {
    -  obj.rotation.y = time;
    -});
    -
    -

    Так как мы добавили sunMesh в массив objects он будет вращаться.

    -

    - -

    -

    Теперь давайте добавим землю.

    -
    const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
    -const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
    -earthMesh.position.x = 10;
    -scene.add(earthMesh);
    -objects.push(earthMesh);
    -
    -

    Мы создаем материал синего цвета, но мы дали ему небольшое количество излучающего -синего цвета, чтобы он отображался на черном фоне.

    -

    Мы используем ту же sphereGeometry с нашим новым синим earthMaterial чтобы сделать -earthMesh. Мы размещаем эти 10 единиц слева от солнца и добавляем их в сцену. -Поскольку мы добавили его в наш массив objects, он тоже будет вращаться.

    -

    - -

    -

    Вы можете видеть, что Солнце и Земля вращаются, но Земля не вращается вокруг Солнца. -Давайте сделаем землю дитя солнца

    -
    -scene.add(earthMesh);
    -+sunMesh.add(earthMesh);
    -
    -

    а также...

    -

    - -

    -

    Что случилось? Почему Земля такого же размера, как Солнце, и почему она так далеко? -На самом деле мне пришлось передвинуть камеру с 50 единиц сверху до 150 единиц -сверху, чтобы увидеть Землю.

    -

    Мы сделали earthMesh ребенком sunMesh. sunMesh масштабирован на 5x -из-за sunMesh.scale.set(5, 5, 5). Это означает, что локальное пространство -sunMesh в 5 раз больше. Все, что помещено в это пространство, будет умножено на 5. -Это означает, что Земля теперь в 5 раз больше и расстояние от Солнца -(earthMesh.position.x = 10) также в 5 раз.

    -

    Наш граф сцены в настоящее время выглядит следующим образом

    -

    -

    Чтобы это исправить, давайте добавим пустой узел графа сцены. -Мы будем связывать солнце и землю с этим узлом.

    -
    +const solarSystem = new THREE.Object3D();
    -+scene.add(solarSystem);
    -+objects.push(solarSystem);
    -
    -const sunMaterial = new THREE.MeshPhongMaterial({emissive: 0xFFFF00});
    -const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
    -sunMesh.scale.set(5, 5, 5);
    --scene.add(sunMesh);
    -+solarSystem.add(sunMesh);
    -objects.push(sunMesh);
    -
    -const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
    -const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
    -earthMesh.position.x = 10;
    --sunMesh.add(earthMesh);
    -+solarSystem.add(earthMesh);
    -objects.push(earthMesh);
    -
    -

    Здесь мы сделали Object3D. Как и Mesh он также является узлом в графе сцены, -но в отличие от Mesh он не имеет материала или геометрии. Это просто -представляет локальное пространство.

    -

    Наш новый граф сцены выглядит следующим образом

    -

    -

    И sunMesh и earthMesh дети solarSystem. Все 3 вращаются, и теперь, -поскольку они не являются потомками earthMesh, sunMesh больше не -масштабируются в 5 раз.

    -

    - -

    -

    Намного лучше. Земля меньше Солнца, и она вращается вокруг Солнца и вращается сама.

    -

    Продолжая ту же самую модель, давайте добавим луну.

    -
    +const earthOrbit = new THREE.Object3D();
    -+earthOrbit.position.x = 10;
    -+solarSystem.add(earthOrbit);
    -+objects.push(earthOrbit);
    -
    -const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
    -const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
    --solarSystem.add(earthMesh);
    -+earthOrbit.add(earthMesh);
    -objects.push(earthMesh);
    -
    -+const moonOrbit = new THREE.Object3D();
    -+moonOrbit.position.x = 2;
    -+earthOrbit.add(moonOrbit);
    -
    -+const moonMaterial = new THREE.MeshPhongMaterial({color: 0x888888, emissive: 0x222222});
    -+const moonMesh = new THREE.Mesh(sphereGeometry, moonMaterial);
    -+moonMesh.scale.set(.5, .5, .5);
    -+moonOrbit.add(moonMesh);
    -+objects.push(moonMesh);
    -
    -

    Снова мы добавили еще один невидимый узел графа сцены Object3D под названием earthOrbit -и добавили earthMesh и moonMesh к нему. Новый граф сцены выглядит следующим образом.

    -

    -

    и вот что

    -

    - -

    -

    Вы можете видеть, что луна следует шаблону спирографа, показанному в верхней части этой -статьи, но нам не пришлось вычислять ее вручную. Мы просто настраиваем наш -граф сцены, чтобы он сделал это за нас.

    -

    Часто полезно рисовать что-то для визуализации узлов в графе сцены. -Three.js имеет несколько полезных ... ммм, помощников ... помогающих с этим.

    -

    Один называется AxesHelper. Он рисует 3 линии, представляющие локальные оси -X, -Y, и -Z Давайте добавим по одному к каждому узлу, -который мы создали.

    -
    // добавляем AxesHelper к каждому узлу
    -objects.forEach((node) => {
    -  const axes = new THREE.AxesHelper();
    -  axes.material.depthTest = false;
    -  axes.renderOrder = 1;
    -  node.add(axes);
    -});
    -
    -

    В нашем случае мы хотим, чтобы оси появлялись, даже если они находятся внутри сфер. -Чтобы сделать это, мы устанавливаем для их материала depthTest значение false, -что означает, что они не будут проверять, что они рисуются за чем-то другим. -Мы также устанавливаем их renderOrder в 1 (по умолчанию 0), чтобы они -рисовались после всех сфер. В противном случае сфера может накрыть их и закрыть их.

    -

    - -

    -

    Мы можем видеть оси -x (красная) и -z (синяя) Поскольку мы смотрим прямо вниз, и каждый -из наших объектов вращается только вокруг своей оси y, мы не видим большую часть -осей y (зеленая).

    -

    Может быть трудно увидеть некоторые из них, так как есть две пары перекрывающихся осей. -И sunMesh и solarSystem находятся в одинаковом положении. Точно так же earthMesh и -earthOrbitнаходятся в той же позиции. Давайте добавим несколько простых элементов управления, -чтобы мы могли включать и выключать их для каждого узла. Пока мы делаем это, -давайте также добавим еще одного помощника под названием GridHelper. -Создающего двумерную сетку на плоскости X, Z. По умолчанию сетка составляет -10х10 единиц.

    -

    Мы также собираемся использовать lil-gui -библиотеку пользовательского интерфейса, которая очень популярна в проектах Three.js. -lil-gui принимает объект и имя свойства для этого объекта и в зависимости от типа -свойства автоматически создает пользовательский интерфейс для управления этим свойством.

    -

    Мы хотим сделать GridHelper и AxesHelper для каждого узла. Нам нужна метка для каждого -узла, поэтому мы избавимся от старого цикла и переключимся на вызов некоторой -функции, чтобы добавить помощники для каждого узла

    -
    -// добавляем AxesHelper к каждому узлу
    --objects.forEach((node) => {
    --  const axes = new THREE.AxesHelper();
    --  axes.material.depthTest = false;
    --  axes.renderOrder = 1;
    --  node.add(axes);
    --});
    -
    -+function makeAxisGrid(node, label, units) {
    -+  const helper = new AxisGridHelper(node, units);
    -+  gui.add(helper, 'visible').name(label);
    -+}
    -+
    -+makeAxisGrid(solarSystem, 'solarSystem', 25);
    -+makeAxisGrid(sunMesh, 'sunMesh');
    -+makeAxisGrid(earthOrbit, 'earthOrbit');
    -+makeAxisGrid(earthMesh, 'earthMesh');
    -+makeAxisGrid(moonMesh, 'moonMesh');
    -
    -

    makeAxisGrid делает AxisGridHelper класс, который мы создадим, -чтобы сделать lil-gui счастливым. Как сказано выше, lil-gui автоматически -создаст пользовательский интерфейс, который манипулирует именованным свойством -некоторого объекта. Это создаст другой пользовательский интерфейс в зависимости -от типа свойства. Мы хотим, чтобы он создал флажок, поэтому нам нужно указать -bool свойство. Но мы хотим, чтобы и оси, и сетка появлялись / исчезали на основе -одного свойства, поэтому мы создадим класс, который имеет метод получения и -установки для свойства. Таким образом, мы можем позволить lil-gui думать, -что он манипулирует одним свойством, но внутри мы можем установить видимое -свойство AxesHelper и GridHelper для узла.

    -
    // Для включения и выключения видимых осей и сетки
    -// lil-gui требуется свойство, которое возвращает bool
    -// это checkbox мы сделали сеттер и геттер
    -// чтобы получить значение для `visible` от lil-gui
    -class AxisGridHelper {
    -  constructor(node, units = 10) {
    -    const axes = new THREE.AxesHelper();
    -    axes.material.depthTest = false;
    -    axes.renderOrder = 2;  // после сетки
    -    node.add(axes);
    -
    -    const grid = new THREE.GridHelper(units, units);
    -    grid.material.depthTest = false;
    -    grid.renderOrder = 1;
    -    node.add(grid);
    -
    -    this.grid = grid;
    -    this.axes = axes;
    -    this.visible = false;
    -  }
    -  get visible() {
    -    return this._visible;
    -  }
    -  set visible(v) {
    -    this._visible = v;
    -    this.grid.visible = v;
    -    this.axes.visible = v;
    -  }
    -}
    -
    -

    Мы устанавливаем renderOrder в AxesHelper -равным 2, а для GridHelper равным 1 так, что оси втянуться после появления сетки. -В противном случае сетка может перезаписать оси.

    -

    - -

    -

    Включите solarSystem и вы увидите, что Земля находится точно в 10 единицах от центра, -как мы установили выше. Вы можете увидеть , как земля находится в -локальном пространстве solarSystem. Включите earthOrbit и вы увидите, -как луна ровно на 2 единицы от центра локального пространства earthOrbit.

    -

    Еще несколько примеров графов сцены. Автомобиль в простом игровом мире -может иметь такой граф сцены

    -

    -

    Если вы двигаете кузов автомобиля, все колеса будут двигаться вместе с ним. -Если вы хотите, чтобы кузов отскакивал отдельно от колес, вы можете привязать -тело и колеса к "рамному" узлу, который представляет раму автомобиля.

    -

    Другой пример - человек в игровом мире.

    -

    -

    Вы можете видеть, что график сцены становится довольно сложным для человека. -На самом деле этот граф сцены упрощен. Например, вы можете расширить его, -чтобы охватить каждый палец (по крайней мере, еще 28 узлов) и каждый палец -(еще 28 узлов), плюс для челюсти, глаз и, возможно, больше.

    -

    Я надеюсь, что это дает некоторое представление о том, как работает граф сцены -и как вы можете его использовать. Создание Object3D узлов и родительских -объектов для них - важный шаг к хорошему использованию трехмерного движка, -такого как three.js. Часто может показаться, что какая-то сложная математика -необходима, чтобы заставить что-то двигаться и вращаться так, как вы хотите. -Например, без графа сцены, вычисляющего движение луны или куда поставить -колеса автомобиля относительно его тела, было бы очень сложно, но с -помощью графа сцены это становится намного проще.

    -

    Далее мы пройдемся по материалам.

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/setup.html b/manual/ru/setup.html deleted file mode 100644 index 45ce0cad82f924..00000000000000 --- a/manual/ru/setup.html +++ /dev/null @@ -1,82 +0,0 @@ - - - Настройка окружения - - - - - - - - - - - - - -
    -
    -

    Настройка окружения

    -
    -
    -
    -

    Эта статья является частью серии статей о three.js. -Первая статья - основы Three.js. -Если вы её еще не читали, советую вам сделать это.

    -

    Прежде чем мы пойдем дальше, нам нужно поговорить о настройке вашего компьютера для разработки. -В частности, по соображениям безопасности WebGL не может напрямую использовать изображения с -вашего жесткого диска. Это означает, что для разработки вам необходимо использовать веб-сервер. -К счастью, веб-серверы разработки очень просты в настройке и использовании.

    -

    Прежде всего, если вы хотите, вы можете скачать весь этот сайт по этой ссылке. -После загрузки дважды щелкните по zip-файлу, чтобы распаковать файлы.

    -

    Затем загрузите один из этих простых веб-серверов или воспользуйтесь средствами вашего ide

    -

    Если вы предпочитаете веб-сервер с пользовательским интерфейсом, есть -Servez.

    -

    - -
    - -

    -

    Просто укажите на папку, в которую вы распаковали файлы, нажмите "Start", затем -перейдите в браузере на http://localhost:8080/ или, если -вы хотите просмотреть примеры, перейдите в http://localhost:8080/threejs.

    -

    Чтобы остановить сервер нажмите stop или выйдите из Servez.

    -

    Если вы предпочитаете командную строку (как я), еще один способ заключается -в использовании node.js. -Загрузите его, установите его, затем откройте окно командной строки / консоли / терминала. -Если вы работаете в Windows, установщик добавит специальную "Node Command Prompt", используйте ее.

    -

    Затем установите servez набрав в консоли

    -
    npm -g install servez
    -

    Если вы используете OSX

    -
    sudo npm -g install servez
    -

    Как только вы сделали это, напишите

    -
    servez path/to/folder/where/you/unzipped/files
    -

    Это должно вывести что-то вроде

    -

    - -
    - -

    -

    Затем в вашем браузере перейдите на http://localhost:8080/.

    -

    Если вы не укажете путь, тогда http-сервер будет хостить(размещать на сервере) текущую папку.

    -

    Если какой-либо из этих вариантов вам не по вкусу, -есть много других простых серверов на выбор.

    -

    Теперь, когда вы настроили сервер, мы можем перейти к текстурам.

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/shadertoy.html b/manual/ru/shadertoy.html deleted file mode 100644 index 61b0106fcdef36..00000000000000 --- a/manual/ru/shadertoy.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Three.js and Shadertoy - - - - - - - - - - - - - -
    -
    -

    Three.js and Shadertoy

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/shadows.html b/manual/ru/shadows.html deleted file mode 100644 index 0a86e380906e73..00000000000000 --- a/manual/ru/shadows.html +++ /dev/null @@ -1,420 +0,0 @@ - - - Тени - - - - - - - - - - - - - -
    -
    -

    Тени

    -
    -
    -
    -

    Эта статья является частью серии статей о three.js. Первая статья - основы Three.js. -Если вы еще не читали их, и вы новичок в three.js, возможно, вы захотите начать с них. -Предыдущая статья была о камерах ,которые важно прочитать, прежде чем читать эту статью, а также -статью, посвященную свету.

    -

    Тени на компьютерах могут быть сложной темой. Существуют различные решения, и у всех есть свои компромиссы, включая решения, доступные в three.js.

    -

    Three.js по умолчанию использует shadow maps. Shadow map работает так: для каждого источника света, отбрасывающего тени, все объекты, -помеченные для отбрасывания теней, визуализируются с точки зрения источника света. ПРОЧИТАЙТЕ ЭТО СНОВА! и это запомнится.

    -

    Другими словами, если у вас есть 20 объектов и 5 источников света, и все 20 объектов отбрасывают тени, -а все 5 источников отбрасывают тени, то вся ваша сцена будет нарисована 6 раз. -Все 20 объектов будут нарисованы для источника света № 1, затем все 20 объектов будут нарисованы для источника света № 2, -затем № 3 и т. д., И, наконец, фактическая сцена будет нарисована с использованием данных из первых 5 визуализаций.

    -

    Становится хуже, если у вас есть точечный источник света, отбрасывающий тени, сцена должна быть нарисована 6 раз только для этого света!

    -

    По этим причинам часто приходится искать другие решения, кроме множества источников света, генерирующих тени. Одно общее решение состоит в том, - чтобы иметь несколько источников света, но только один направленный источник света, генерирующий тени.

    -

    Еще одно решение заключается в использовании карт освещения и / или карт окклюзии(преграждений) окружающей среды -для предварительного расчета эффектов освещения в автономном режиме. -Это приводит к статическому освещению или подсказкам статического освещения, но, по крайней мере, это быстро. Мы рассмотрим оба из них в другой статье.

    -

    Другое решение заключается в использовании поддельных теней. Создайте плоскость, поместите текстуру в градациях серого в плоскость, -которая приближается к тени, нарисуйте ее над землей под вашим объектом.

    -

    Например, давайте использовать эту текстуру в качестве поддельной тени

    -
    - -

    Мы будем использовать часть кода из предыдущей статьи.

    -

    Давайте установим цвет фона на белый.

    -
    const scene = new THREE.Scene();
    -+scene.background = new THREE.Color('white');
    -
    -

    Затем мы установим ту же самую шахматную доску, но на этот раз она использует MeshBasicMaterial, так как нам не нужно освещение для земли.

    -
    +const loader = new THREE.TextureLoader();
    -
    -{
    -  const planeSize = 40;
    -
    --  const loader = new THREE.TextureLoader();
    -  const texture = loader.load('resources/images/checker.png');
    -  texture.wrapS = THREE.RepeatWrapping;
    -  texture.wrapT = THREE.RepeatWrapping;
    -  texture.magFilter = THREE.NearestFilter;
    -  const repeats = planeSize / 2;
    -  texture.repeat.set(repeats, repeats);
    -
    -  const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    -  const planeMat = new THREE.MeshBasicMaterial({
    -    map: texture,
    -    side: THREE.DoubleSide,
    -  });
    -+  planeMat.color.setRGB(1.5, 1.5, 1.5);
    -  const mesh = new THREE.Mesh(planeGeo, planeMat);
    -  mesh.rotation.x = Math.PI * -.5;
    -  scene.add(mesh);
    -}
    -
    -

    Обратите внимание, что мы устанавливаем цвет на 1,5, 1,5, 1,5. Это умножит цвета текстуры шахматной доски на 1,5, 1,5, 1,5. -Так как цвета текстуры 0x808080 и 0xC0C0C0, которые являются средне-серыми и светло-серыми, -умножение их на 1,5 даст нам белую и светло-серую шахматную доску.

    -

    Давайте загрузим текстуру тени

    -
    const shadowTexture = loader.load('resources/images/roundshadow.png');
    -
    -

    и сделаем массив для запоминания каждой сферы и связанных объектов.

    -
    const sphereShadowBases = [];
    -
    -

    Тогда мы сделаем геометрию сферы

    -
    const sphereRadius = 1;
    -const sphereWidthDivisions = 32;
    -const sphereHeightDivisions = 16;
    -const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
    -
    -

    И геометрия плоскости для поддельной тени

    -
    const planeSize = 1;
    -const shadowGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    -
    -

    Теперь мы сделаем кучу сфер. Для каждой сферы мы создадим base THREE.Object3D и сделаем сетку теневой плоскости и дочернюю сетку базовой сферы. -Таким образом, если мы переместим основание, то и сфера, и тень будут двигаться. Нам нужно поместить тень немного выше земли, чтобы предотвратить z-fighting. -Мы также устанавливаем значение deepWrite в false, чтобы тени не мешали друг другу. Мы рассмотрим оба эти вопроса в другой статье. -Тень - это MeshBasicMaterial, потому что ей не нужно освещение.

    -

    Мы делаем каждую сферу разным оттенком, а затем сохраняем ее вне основы, сетки сфер, сетки теней и начальной y-позиции каждой сферы.

    -
    const numSpheres = 15;
    -for (let i = 0; i < numSpheres; ++i) {
    -  // make a base for the shadow and the sphere
    -  // so they move together.
    -  const base = new THREE.Object3D();
    -  scene.add(base);
    -
    -  // add the shadow to the base
    -  // note: we make a new material for each sphere
    -  // so we can set that sphere's material transparency
    -  // separately.
    -  const shadowMat = new THREE.MeshBasicMaterial({
    -    map: shadowTexture,
    -    transparent: true,    // so we can see the ground
    -    depthWrite: false,    // so we don't have to sort
    -  });
    -  const shadowMesh = new THREE.Mesh(shadowGeo, shadowMat);
    -  shadowMesh.position.y = 0.001;  // so we're above the ground slightly
    -  shadowMesh.rotation.x = Math.PI * -.5;
    -  const shadowSize = sphereRadius * 4;
    -  shadowMesh.scale.set(shadowSize, shadowSize, shadowSize);
    -  base.add(shadowMesh);
    -
    -  // add the sphere to the base
    -  const u = i / numSpheres;   // goes from 0 to 1 as we iterate the spheres.
    -  const sphereMat = new THREE.MeshPhongMaterial();
    -  sphereMat.color.setHSL(u, 1, .75);
    -  const sphereMesh = new THREE.Mesh(sphereGeo, sphereMat);
    -  sphereMesh.position.set(0, sphereRadius + 2, 0);
    -  base.add(sphereMesh);
    -
    -  // remember all 3 plus the y position
    -  sphereShadowBases.push({base, sphereMesh, shadowMesh, y: sphereMesh.position.y});
    -}
    -
    -

    Мы установили 2 источника света. Одним из них является HemisphereLight с интенсивностью, установленной на 2, чтобы действительно осветлить вещи.

    -
    {
    -  const skyColor = 0xB1E1FF;  // light blue
    -  const groundColor = 0xB97A20;  // brownish orange
    -  const intensity = 2;
    -  const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
    -  scene.add(light);
    -}
    -
    -

    Другой - DirectionalLight, поэтому сферы получают некоторое определение.

    -
    {
    -  const color = 0xFFFFFF;
    -  const intensity = 1;
    -  const light = new THREE.DirectionalLight(color, intensity);
    -  light.position.set(0, 10, 5);
    -  light.target.position.set(-5, 0, 0);
    -  scene.add(light);
    -  scene.add(light.target);
    -}
    -
    -

    Он будет отображаться как есть, но давайте оживим сферы. -Для каждой сферы, тени, базового набора мы перемещаем базу в плоскости xz, мы перемещаем сферу вверх и вниз, -используя Math.abs (Math.sin (time)), который дает нам оживленную анимацию. -И мы также устанавливаем непрозрачность теневого материала таким образом, чтобы, когда каждая сфера поднималась выше, ее тень исчезала.

    -
    function render(time) {
    -  time *= 0.001;  // convert to seconds
    -
    -  ...
    -
    -  sphereShadowBases.forEach((sphereShadowBase, ndx) => {
    -    const {base, sphereMesh, shadowMesh, y} = sphereShadowBase;
    -
    -    // u is a value that goes from 0 to 1 as we iterate the spheres
    -    const u = ndx / sphereShadowBases.length;
    -
    -    // compute a position for the base. This will move
    -    // both the sphere and its shadow
    -    const speed = time * .2;
    -    const angle = speed + u * Math.PI * 2 * (ndx % 1 ? 1 : -1);
    -    const radius = Math.sin(speed - ndx) * 10;
    -    base.position.set(Math.cos(angle) * radius, 0, Math.sin(angle) * radius);
    -
    -    // yOff is a value that goes from 0 to 1
    -    const yOff = Math.abs(Math.sin(time * 2 + ndx));
    -    // move the sphere up and down
    -    sphereMesh.position.y = y + THREE.MathUtils.lerp(-2, 2, yOff);
    -    // fade the shadow as the sphere goes up
    -    shadowMesh.material.opacity = THREE.MathUtils.lerp(1, .25, yOff);
    -  });
    -
    -  ...
    -
    -

    И вот 15 видов прыгающих шаров.

    -

    - -

    -

    В некоторых приложениях обычно используют круглую или овальную тень для всего, но, конечно, -вы также можете использовать теневые текстуры различной формы. Вы также можете придать тени более жесткий край. -Хорошим примером использования этого типа тени является Animal Crossing Pocket Camp -де вы можете видеть, что у каждого персонажа есть простая круглая тень. Это эффективно и дешево. -Monument Valley - кажется, также использует этот вид тени для главного героя.

    -

    Итак, переходя к теневым картам, есть 3 источника света, которые могут отбрасывать тени. DirectionalLight, PointLight и SpotLight.

    -

    Давайте начнем с DirectionalLight c вспомогательного примера из статьи о светах.

    -

    Первое, что нам нужно сделать, это включить тени в рендерере.

    -
    const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -+renderer.shadowMap.enabled = true;
    -
    -

    Тогда мы также должны сказать свету отбрасывать тень

    -
    const light = new THREE.DirectionalLight(color, intensity);
    -+light.castShadow = true;
    -
    -

    Нам также нужно перейти к каждой сетке в сцене и решить, должна ли она отбрасывать тени и / или получать тени.

    -

    Давайте сделаем так, чтобы плоскость (земля) получала только тени, так как нам все равно, что происходит под ней.

    -
    const mesh = new THREE.Mesh(planeGeo, planeMat);
    -mesh.receiveShadow = true;
    -
    -

    Для куба и сферы давайте получим и отбросим тени

    -
    const mesh = new THREE.Mesh(cubeGeo, cubeMat);
    -mesh.castShadow = true;
    -mesh.receiveShadow = true;
    -
    -...
    -
    -const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    -mesh.castShadow = true;
    -mesh.receiveShadow = true;
    -
    -

    И тогда мы запустим это.

    -

    - -

    -

    Что произошло? Почему части теней отсутствуют?

    -

    Причина в том, что карты теней создаются путем рендеринга сцены с точки зрения света. -В этом случае в DirectionalLight есть камера, которая смотрит на свою цель. Точно так же, как камера, которую мы ранее покрывали, -камера тени определяет область, внутри которой рендерится тени. В приведенном выше примере эта область слишком мала.

    -

    Чтобы визуализировать эту область, мы можем получить теневую камеру и добавить CameraHelper к сцене.

    -
    const cameraHelper = new THREE.CameraHelper(light.shadow.camera);
    -scene.add(cameraHelper);
    -
    -

    И теперь вы можете видеть область, для которой отбрасываются и принимаются тени.

    -

    - -

    -

    Отрегулируйте целевое значение x назад и вперед, и должно быть довольно ясно, что только то, что находится внутри блока камеры тени, находится там, где рисуются тени.

    -

    Мы можем отрегулировать размер этой коробки, отрегулировав камеру освещения.

    -

    Давайте добавим некоторые настройки графического интерфейса, чтобы отрегулировать тень камеры освещения. -Поскольку DirectionalLight представляет свет, движущийся в параллельном направлении, -DirectionalLight использует OrthographicCamera для своей теневой камеры. Мы рассмотрели, как работает -OrthographicCamera в предыдущей статье о камерах.

    -

    Напомним, OrthographicCamera определяет свою рамку или вид усечения по своим свойствам left, right, top, bottom, near, far и zoom.

    -

    Снова давайте создадим вспомогательный класс для lil-gui. Мы сделаем DimensionGUIHelper, -который мы передадим объект и 2 свойства. Он представит одно свойство, которое lil-gui -может настроить, и в ответ установит два значения: положительное и отрицательное. -Мы можем использовать это, чтобы установить влево и вправо как ширину, а вверх и вниз как высоту.

    -
    class DimensionGUIHelper {
    -  constructor(obj, minProp, maxProp) {
    -    this.obj = obj;
    -    this.minProp = minProp;
    -    this.maxProp = maxProp;
    -  }
    -  get value() {
    -    return this.obj[this.maxProp] * 2;
    -  }
    -  set value(v) {
    -    this.obj[this.maxProp] = v /  2;
    -    this.obj[this.minProp] = v / -2;
    -  }
    -}
    -
    -

    Мы также будем использовать MinMaxGUIHelper, который мы создали в статье о камере, для настройки ближнего и дальнего.

    -
    const gui = new GUI();
    -gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
    -gui.add(light, 'intensity', 0, 2, 0.01);
    -+{
    -+  const folder = gui.addFolder('Shadow Camera');
    -+  folder.open();
    -+  folder.add(new DimensionGUIHelper(light.shadow.camera, 'left', 'right'), 'value', 1, 100)
    -+    .name('width')
    -+    .onChange(updateCamera);
    -+  folder.add(new DimensionGUIHelper(light.shadow.camera, 'bottom', 'top'), 'value', 1, 100)
    -+    .name('height')
    -+    .onChange(updateCamera);
    -+  const minMaxGUIHelper = new MinMaxGUIHelper(light.shadow.camera, 'near', 'far', 0.1);
    -+  folder.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
    -+  folder.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
    -+  folder.add(light.shadow.camera, 'zoom', 0.01, 1.5, 0.01).onChange(updateCamera);
    -+}
    -
    -

    Мы говорим GUI, чтобы мы вызывали нашу функцию updateCamera каждый раз, когда что-то меняется. -Давайте напишем эту функцию для обновления источника света, помощника источника света, -камеры тени источника света и помощника, отображающего камеру тени источника света.

    -
    function updateCamera() {
    -  // update the light target's matrixWorld because it's needed by the helper
    -  light.target.updateMatrixWorld();
    -  helper.update();
    -  // update the light's shadow camera's projection matrix
    -  light.shadow.camera.updateProjectionMatrix();
    -  // and now update the camera helper we're using to show the light's shadow camera
    -  cameraHelper.update();
    -}
    -updateCamera();
    -
    -

    И теперь, когда мы дали теневой камере световой интерфейс, мы можем играть со значениями.

    -

    - -

    -

    Установите ширину и высоту около 30, и вы увидите, что тени правильные и области, которые должны находиться в тени для этой сцены, полностью покрыты.

    -

    Но это поднимает вопрос, почему бы просто не установить ширину и высоту для некоторых гигантских чисел, -чтобы просто покрыть все? Установите ширину и высоту 100, и вы можете увидеть что-то вроде этого

    -
    - -

    Что происходит с этими тенями в низком разрешении ?!

    -

    Эта проблема - еще один параметр, связанный с тенями, о котором нужно знать. Карты теней - это текстуры, в которые затягиваются тени. -Эти текстуры имеют размер. Область теневой камеры, которую мы установили выше, -растянута на этот размер. Это означает, что чем больше область, которую вы устанавливаете, тем более блочными будут ваши тени.

    -

    Вы можете установить разрешение текстуры карты теней, установив light.shadow.mapSize.width и light.shadow.mapSize.height. - Они по умолчанию 512x512. Чем больше вы делаете их, тем больше памяти они отнимают - и тем медленнее они вычисляются, поэтому вы захотите установить их как можно меньше и при этом заставить вашу сцену работать. - То же самое относится и к области камеры теневого света. Меньшие тени означают - лучше выглядящие, - поэтому сделайте область как можно меньше и при этом охватывайте всю сцену. Имейте в виду, что на компьютере каждого пользователя установлен максимально допустимый - размер текстуры, который доступен в рендере как renderer.capabilities.maxTextureSize.

    - -

    При переключении на SpotLight теневая камера источника света становится PerspectiveCamera. -В отличие от теневой камеры DirectionalLight, где мы могли вручную установить большинство ее настроек, -теневая камера SpotLight управляется самим SpotLight. Поле зрения для теневой камеры напрямую связано с настройкой угла SpotLight. -aspect устанавливается автоматически в зависимости от размера карты теней.

    -
    -const light = new THREE.DirectionalLight(color, intensity);
    -+const light = new THREE.SpotLight(color, intensity);
    -
    -

    и мы добавили обратно в настройку полутени и угол из нашей статьи о светах.

    -

    - -

    - -

    И, наконец, есть тени с PointLight. Так как PointLight светит во всех направлениях, единственные релевантные настройки - ближние и дальние. -В противном случае тень PointLight фактически представляет собой 6 теней SpotLight, каждая из которых указывает на грань куба вокруг источника света. -Это означает, что тени PointLight намного медленнее, поскольку вся сцена должна быть нарисована 6 раз, по одному для каждого направления.

    -

    Давайте поместим рамку вокруг нашей сцены, чтобы мы могли видеть тени на стенах и потолке. -Мы установим свойство стороны материала в THREE.BackSide, чтобы отображать внутреннюю часть поля вместо внешней. Как и пол, мы установим его только для получения теней. -Также мы установим положение ящика так, чтобы его дно было немного ниже пола, чтобы пол и дно ящика не мешали друг другу.

    -
    {
    -  const cubeSize = 30;
    -  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
    -  const cubeMat = new THREE.MeshPhongMaterial({
    -    color: '#CCC',
    -    side: THREE.BackSide,
    -  });
    -  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
    -  mesh.receiveShadow = true;
    -  mesh.position.set(0, cubeSize / 2 - 0.1, 0);
    -  scene.add(mesh);
    -}
    -
    -

    И, конечно же, нам нужно переключить свет на PointLight.

    -
    -const light = new THREE.SpotLight(color, intensity);
    -+const light = new THREE.PointLight(color, intensity);
    -
    -....
    -
    -// so we can easily see where the point light is
    -+const helper = new THREE.PointLightHelper(light);
    -+scene.add(helper);
    -
    -

    - -

    -

    Используйте настройки GUI для перемещения света, и вы увидите, как тени падают на все стены. -Вы также можете настроить ближние и дальние настройки и видеть, как и другие тени, -когда объекты ближе - они больше не получают тень, когда объекты дальше - они всегда находятся в тени.

    - - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/textures.html b/manual/ru/textures.html deleted file mode 100644 index bdf061be205671..00000000000000 --- a/manual/ru/textures.html +++ /dev/null @@ -1,567 +0,0 @@ - - - Текстуры - - - - - - - - - - - - - -
    -
    -

    Текстуры

    -
    -
    -
    -

    Эта статья является частью серии статей о three.js. -Первая статья - основы Three.js. -Предыдущая статья была о настройках окружения для этой статьи. -Если вы их еще не читали, советую вам сделать это.

    -

    Текстуры - это своего рода большая тема в Three.js, и я не уверен на 100%, на каком -уровне их объяснить, но я постараюсь. По ним есть много тем, и многие из них взаимосвязаны, -поэтому трудно объяснить все сразу. Вот краткое содержание этой статьи.

    - - -

    Hello Texture

    -

    Текстуры, как правило представляют собой изображения, которые чаще всего создаются в -какой-либо сторонней программе, такой как Photoshop или GIMP. Например, -давайте поместим это изображение на куб.

    -
    - -
    - -

    Мы изменим один из наших первых примеров. Все, что нам нужно сделать, это создать TextureLoader. Вызовите -load метод с URL-адресом изображения и установите для -изображения и установите его возвращаемое значение для map свойства материала, вместо установки color.

    -
    +const loader = new THREE.TextureLoader();
    -+const texture = loader.load( 'resources/images/wall.jpg' );
    -+texture.colorSpace = THREE.SRGBColorSpace;
    -
    -const material = new THREE.MeshBasicMaterial({
    --  color: 0xFF8844,
    -+  map: texture,
    -});
    -
    -

    Обратите внимание, что мы используем MeshBasicMaterial поэтому не нужно никаких источников света.

    -

    - -

    -

    6 текстур, разные для каждой грани куба

    -

    Как насчет 6 текстур, по одной на каждой грани куба?

    -
    -
    - - - -
    -
    - - - -
    -
    - -

    Мы просто создадим 6 материалов и передаем их в виде массива при создании Mesh

    -
    const loader = new THREE.TextureLoader();
    --const texture = loader.load( 'resources/images/wall.jpg' );
    --texture.colorSpace = THREE.SRGBColorSpace;
    -
    --const material = new THREE.MeshBasicMaterial({
    --  map: texture,
    --});
    -+const materials = [
    -+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-1.jpg')}),
    -+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-2.jpg')}),
    -+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-3.jpg')}),
    -+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-4.jpg')}),
    -+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-5.jpg')}),
    -+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-6.jpg')}),
    -+];
    --const cube = new THREE.Mesh(geometry, material);
    -+const cube = new THREE.Mesh(geometry, materials);
    -
    -+function loadColorTexture( path ) {
    -+  const texture = loader.load( path );
    -+  texture.colorSpace = THREE.SRGBColorSpace;
    -+  return texture;
    -+}
    -
    -

    Оно работает!

    -

    - -

    -

    Однако следует отметить, что по умолчанию единственной геометрией, которая поддерживает -несколько материалов, является BoxGeometry и ConeGeometry. В других случаях вам -нужно будет создать или загрузить пользовательскую геометрию и/или изменить координаты -текстуры. Гораздо более распространенным является использование -Текстурного атласа -когда вы хотите разрешить несколько изображений для одной геометрии.

    -

    Что такое координаты текстуры? Это данные, добавленные к каждой вершине -геометрического фрагмента, которые определяют, какая часть текстуры -соответствует этой конкретной вершине. Мы рассмотрим их, когда -начнем создавать собственную геометрию.

    -

    Загрузка текстур

    -

    Легкий путь

    -

    Большая часть кода на этом сайте использует самый простой способ загрузки текстур. -Мы создаем TextureLoader и затем вызываем load метод. -Возвращающий объект Texture.

    -
    const texture = loader.load('../resources/images/flower-1.jpg');
    -
    -

    Важно отметить, что при использовании этого метода наша текстура будет прозрачной, -пока изображение не будет загружено асинхронно с помощью three.js, -после чего он обновит текстуру загруженным изображением.

    -

    Это имеет большое преимущество в том, что нам не нужно ждать загрузки текстуры, -и наша страница начнет отрисовку немедленно. Это, вероятно, хорошо для очень -многих случаев использования, но если мы хотим, мы можем попросить three.js -сообщить нам, когда текстура закончила загрузку.

    -

    Ожидание загрузки текстуры

    -

    Чтобы дождаться загрузки текстуры, load метод загрузчика текстуры принимает -обратный вызов, который будет вызван после завершения загрузки текстуры. -Возвращаясь к нашему верхнему примеру, мы можем дождаться загрузки текстуры, -прежде чем создавать нашу Mesh и добавлять ее в сцену следующим образом.

    -
    const loader = new THREE.TextureLoader();
    -loader.load('../resources/images/wall.jpg', (texture) => {
    -  const material = new THREE.MeshBasicMaterial({
    -    map: texture,
    -  });
    -  const cube = new THREE.Mesh(geometry, material);
    -  scene.add(cube);
    -  cubes.push(cube);  // добавляем в наш список кубиков для вращения
    -});
    -
    -

    Если вы не очистите кеш вашего браузера и у вас не будет медленного соединения, -вы вряд ли увидите разницу, но будьте уверены, что она ожидает загрузки текстуры.

    -

    - -

    -

    Ожидание загрузки нескольких текстур

    -

    Чтобы дождаться загрузки всех текстур, вы можете использовать LoadingManager. -Создайте его и передайте его в TextureLoader, а затем установите его onLoad -свойство для обратного вызова.

    -
    +const loadManager = new THREE.LoadingManager();
    -*const loader = new THREE.TextureLoader(loadManager);
    -
    -const materials = [
    -  new THREE.MeshBasicMaterial({map: loader.load('../resources/images/flower-1.jpg')}),
    -  new THREE.MeshBasicMaterial({map: loader.load('../resources/images/flower-2.jpg')}),
    -  new THREE.MeshBasicMaterial({map: loader.load('../resources/images/flower-3.jpg')}),
    -  new THREE.MeshBasicMaterial({map: loader.load('../resources/images/flower-4.jpg')}),
    -  new THREE.MeshBasicMaterial({map: loader.load('../resources/images/flower-5.jpg')}),
    -  new THREE.MeshBasicMaterial({map: loader.load('../resources/images/flower-6.jpg')}),
    -];
    -
    -+loadManager.onLoad = () => {
    -+  const cube = new THREE.Mesh(geometry, materials);
    -+  scene.add(cube);
    -+  cubes.push(cube);  // add to our list of cubes to rotate
    -+};
    -
    -

    LoadingManager также имеет onProgress свойство и его -можно установить в другой функции обратного вызова, чтобы показать индикатор прогресса.

    -

    Сначала мы добавим индикатор выполнения (progress bar) в HTML

    -
    <body>
    -  <canvas id="c"></canvas>
    -+  <div id="loading">
    -+    <div class="progress"><div class="progressbar"></div></div>
    -+  </div>
    -</body>
    -
    -

    и CSS для этого

    -
    #loading {
    -    position: fixed;
    -    top: 0;
    -    left: 0;
    -    width: 100%;
    -    height: 100%;
    -    display: flex;
    -    justify-content: center;
    -    align-items: center;
    -}
    -#loading .progress {
    -    margin: 1.5em;
    -    border: 1px solid white;
    -    width: 50vw;
    -}
    -#loading .progressbar {
    -    margin: 2px;
    -    background: white;
    -    height: 1em;
    -    transform-origin: top left;
    -    transform: scaleX(0);
    -}
    -
    -

    Затем в коде мы обновим масштаб (scale) progressbar в onProgress. -Он вызывается с URL-адресом последнего загруженного элемента, количества загруженных -элементов и общего количества загруженных элементов.

    -
    +const loadingElem = document.querySelector('#loading');
    -+const progressBarElem = loadingElem.querySelector('.progressbar');
    -
    -loadManager.onLoad = () => {
    -+  loadingElem.style.display = 'none';
    -  const cube = new THREE.Mesh(geometry, materials);
    -  scene.add(cube);
    -  cubes.push(cube);  // добавляем в наш список кубиков для вращения
    -};
    -
    -+loadManager.onProgress = (urlOfLastItemLoaded, itemsLoaded, itemsTotal) => {
    -+  const progress = itemsLoaded / itemsTotal;
    -+  progressBarElem.style.transform = `scaleX(${progress})`;
    -+};
    -
    -

    Если вы не очистите свой кеш и у вас медленное соединение, вы можете не увидеть полосу загрузки.

    -

    - -

    -

    Загрузка текстур из других источников. CROS

    -

    Чтобы использовать изображения с других серверов, эти сервера должны отправлять правильные заголовки. -Если этого не произойдет, вы не сможете использовать изображения в three.js и получите ошибку. -Если вы запускаете сервер, предоставляющий изображения, убедитесь, что он - отправляет правильные заголовки. -Если вы не управляете сервером, на котором размещены изображения, и он не отправляет заголовки разрешений, -вы не сможете использовать изображения с этого сервера.

    -

    Например imgur, flickr и -github - все заголовки отправки, позволяющие вам использовать изображения, -размещенные на их серверах в three.js. Большинство других сайтов этого не делают.

    -

    Использование памяти

    -

    Текстуры часто являются частью приложения three.js, которое использует больше всего памяти. -Важно понимать, что обычно, текстуры занимают width * height * 4 * 1.33 байт в памяти.

    -

    Обратите внимание, что никто не говорит о сжатии. Я могу сделать изображение в формате -.jpg и установить его компрессию очень высокой. Например, допустим, я делал сцену из дома. -Внутри дома есть стол, и я решил положить эту текстуру дерева на верхнюю поверхность стола.

    -
    - -

    Это изображение всего 157 Кб, поэтому оно будет загружаться относительно быстро, но на -самом деле оно имеет размер 3024 x 3761 пикселей. -Следуя приведенному выше уравнению, это

    -
    3024 * 3761 * 4 * 1.33 = 60505764.5
    -

    Это изображение займет 60 Мб ПАМЯТИ! в three.js. -Несколько таких текстур, и вам не хватит памяти.

    -

    Я поднял этот вопрос, потому что важно знать, что использование текстур имеет скрытую стоимость. -Для того чтобы three.js использовал текстуру, он должен передать ее в графический процессор, -а графический процессор обычно требует, чтобы данные текстуры были несжатыми.

    -

    Мораль этой истории: делайте ваши текстуры небольшими по размеру, а не просто -маленькими по размеру файла. Небольшой размер файла = быстрая загрузка. -Маленький в размер = занимает меньше памяти. -Насколько маленькими вы должны сделать их? -Настолько, насколько это возможно! И при этом выглядящими так хорошо, как вам нужно.

    -

    JPG против PNG

    -

    Это почти то же самое, что и обычный HTML, поскольку JPG-файлы имеют сжатие с потерями, -PNG-файлы имеют сжатие без потерь, поэтому PNG-файлы обычно загружаются медленнее. -Но PNG поддерживают прозрачность. PNG также, вероятно, является подходящим форматом -для данных без реалистичных изображений, таких как карты нормалей, и других видов карт без реалистичных изображений, -которые мы рассмотрим позже.

    -

    Важно помнить, что JPG не использует меньше памяти, чем PNG в WebGL. Смотри выше.

    -

    Фильтрация и Mips

    -

    Давайте применим эту текстуру 16x16

    -
    - -

    Это куб

    -
    - -

    Давайте нарисуем этот кубик действительно маленьким

    -
    - -

    Хммм, я думаю, это трудно увидеть. Давайте увеличим этот крошечный куб

    -
    - -

    Как GPU узнает, какие цвета нужно сделать для каждого пикселя, который он рисует для крошечного куба? -Что если куб был настолько мал, что его размер составлял всего 1 или 2 пикселя?

    -

    Вот что такое фильтрация.

    -

    Если бы это был Photoshop, Photoshop усреднил бы почти все пиксели вместе, -чтобы выяснить, какой цвет сделать эти 1 или 2 пикселя. -Это было бы очень медленной операцией. -Графические процессоры решают эту проблему с помощью mipmaps.

    -

    Mips - это копии текстуры, каждая из которых в два раза меньше ширины и в два раза меньше, чем предыдущий мип, -где пиксели были смешаны, чтобы сделать следующий меньший мип. -Мипы создаются до тех пор, пока мы не доберемся до 1 х 1 пикселя. -Поскольку изображение выше всех мипов в конечном итоге будет что-то вроде этого

    -
    - -

    Теперь, когда куб нарисован настолько маленьким, что его размер составляет всего 1 или 2 пикселя, -графический процессор может использовать только наименьший или почти минимальный уровень мипа, -чтобы решить, какой цвет создать крошечный куб.

    -

    В three вы можете выбрать, что будет происходить, когда текстура рисуется больше, -чем ее исходный размер, и что происходит, когда она рисуется меньше, чем ее исходный размер.

    -

    Для установки фильтра, когда текстура рисуется больше исходного размера, -вы устанавливаете texture.magFilter свойство либо на THREE.NearestFilter либо на -THREE.LinearFilter. NearestFilter просто выбрает один пиксель из оригинальной текстуры. -С текстурой низкого разрешения это дает вам очень пиксельный вид как Minecraft.

    -

    LinearFilter выбрает 4 пикселя из текстуры, которые находятся ближе всего к тому месту, -где мы должны выбирать цвет, и смешает их в соответствующих пропорциях относительно того, -как далеко фактическая точка находится от каждого из 4 пикселей.

    -
    -
    -
    -
    Nearest
    -
    -
    -
    -
    Linear
    -
    -
    - -

    Для настройки фильтра, когда текстура нарисована меньше исходного размера, -вы устанавливаете для свойства texture.minFilter одно из 6 значений.

    -
      -
    • THREE.NearestFilter

      -

      так же, как и выше. Выбирает ближайший пиксель в текстуре

      -
    • -
    • THREE.LinearFilter

      -

      Как и выше, выбирает 4 пикселя из текстуры и смешает их

      -
    • -
    • THREE.NearestMipmapNearestFilter

      -

      выбирает соответствующий mip, затем выбирает один пиксель.

      -
    • -
    • THREE.NearestMipmapLinearFilter

      -

      выбирает 2 mips, выбирает один пиксель из каждого, смешает 2 пикселя.

      -
    • -
    • THREE.LinearMipmapNearestFilter

      -

      выбирает подходящий mip, затем выбирает 4 пикселя и смешает их.

      -
    • -
    • THREE.LinearMipmapLinearFilter

      -

      выбирает 2 mips, выбирает 4 пикселя от каждого и смешает все 8 в 1 пиксель.

      -
    • -
    -

    Вот пример, показывающий все 6 настроек

    -
    -
    -
    -
    click to
    change
    texture
    -
    -
    nearest
    -
    linear
    -
    nearest
    mipmap
    nearest
    -
    nearest
    mipmap
    linear
    -
    linear
    mipmap
    nearest
    -
    linear
    mipmap
    linear
    -
    -
    - -

    Одна вещь, на которую нужно обратить внимание - это использование левого верхнего и верхнего среднего -NearestFilter и LinearFilter -не использует mips. Из-за этого они мерцают на расстоянии, потому что графический процессор выбирает -пиксели из исходной текстуры. Слева выбран только один пиксель, а в середине 4 выбраны и смешаны, -но этого недостаточно, чтобы придумать хороший представительный цвет. Другие 4 полоски лучше с нижним правым, -LinearMipmapLinearFilter лучший.

    -

    Если вы нажмете на картинку выше, она переключится между текстурой, которую мы использовали выше, -и текстурой, где каждый уровень мипа имеет свой цвет.

    -
    -
    -
    - -

    Это делает более понятным, что происходит. -Вы можете видеть в верхнем левом и верхнем середине первый мип, используемый на всем пути. -Справа вверху и внизу посередине видно, где используется другой мип.

    -

    Возвращаясь к исходной текстуре, вы можете видеть, что нижний правый угол является самым плавным -и с высочайшим качеством. Вы можете спросить, почему не всегда использовать этот режим. -Самая очевидная причина - иногда вы хотите, чтобы вещи были пикселированы в стиле ретро -или по какой-то другой причине. Следующая наиболее распространенная причина заключается в том, -что чтение 8 пикселей и их смешивание медленнее, чем чтение 1 пикселя и смешивание. -Хотя маловероятно, что для одной и той же текстуры будет видна разница в скорости по мере нашего -углубления в эти статьи, в конечном итоге у нас будут материалы, -которые используют 4 или 5 текстур одновременно. 4 текстуры * 8 пикселей на текстуру - -это поиск 32 пикселей для каждого пикселя. Это может быть особенно важно учитывать на мобильных устройствах.

    -

    Повторение, смещение, вращение, наложение текстуры

    -

    Текстуры имеют настройки для повторения, смещения и поворота текстуры.

    -

    По умолчанию текстуры в three.js не повторяются. Чтобы установить, повторяется или нет текстура, -есть 2 свойства: wrapS для горизонтального и -и wrapT вертикального повторения.

    -

    They can be set to one of:

    -
      -
    • THREE.ClampToEdgeWrapping

      -

      Последний пиксель на каждом ребре повторяется всегда

      -
    • -
    • THREE.RepeatWrapping

      -

      Текстура повторяется

      -
    • -
    • THREE.MirroredRepeatWrapping

      -

      Текстура зеркально повторяется.

      -
    • -
    -

    Например, чтобы включить повтор в обоих направлениях:

    -
    someTexture.wrapS = THREE.RepeatWrapping;
    -someTexture.wrapT = THREE.RepeatWrapping;
    -
    -

    Повторение устанавливается с помощью свойства [repeat].

    -
    const timesToRepeatHorizontally = 4;
    -const timesToRepeatVertically = 2;
    -someTexture.repeat.set(timesToRepeatHorizontally, timesToRepeatVertically);
    -
    -

    Смещение текстуры может быть сделано путем установки offset. -Текстуры смещены в единицах, где 1 единица = 1 размер текстуры. -Другими словами, 0 = без смещения и 1 = смещение на одну полную величину текстуры.

    -
    const xOffset = .5;   // cмещение на половину текстуры
    -const yOffset = .25;
    -someTexture.offset.set(xOffset, yOffset);`
    -
    -

    Вращение текстуры может быть установлено через свойство rotation в радианах, а также свойство -center для выбора центра вращения. По умолчанию используется значение 0,0, которое -вращается из нижнего левого угла. Подобно смещению, эти единицы имеют размер текстуры, -поэтому установка их .5, .5 будет вращаться вокруг центра текстуры.

    -
    someTexture.center.set(.5, .5);
    -someTexture.rotation = THREE.MathUtils.degToRad(45);
    -
    -

    Давайте изменим верхний пример выше, чтобы играть с этими значениями

    -

    Сначала мы сохраним ссылку на текстуру, чтобы мы могли манипулировать ею

    -
    +const texture = loader.load('../resources/images/wall.jpg');
    -const material = new THREE.MeshBasicMaterial({
    --  map: loader.load('../resources/images/wall.jpg');
    -+  map: texture,
    -});
    -
    -

    Затем мы снова будем использовать lil-gui -для обеспечения простого интерфейса.

    -
    import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
    -
    -

    Как мы делали в предыдущих примерах lil-gui, мы будем использовать простой класс, -чтобы дать lil-gui объект, которым он может манипулировать в градусах, -но установит свойство в радианах.

    -
    class DegRadHelper {
    -  constructor(obj, prop) {
    -    this.obj = obj;
    -    this.prop = prop;
    -  }
    -  get value() {
    -    return THREE.MathUtils.radToDeg(this.obj[this.prop]);
    -  }
    -  set value(v) {
    -    this.obj[this.prop] = THREE.MathUtils.degToRad(v);
    -  }
    -}
    -
    -

    Нам также нужен класс, который будет конвертировать из строки, например, "123" -в число 123, так как для Three.js требуются числа для настроек перечисления, -например, wrapS и wrapT, а lil-gui использует только строки для перечислений.

    -
    class StringToNumberHelper {
    -  constructor(obj, prop) {
    -    this.obj = obj;
    -    this.prop = prop;
    -  }
    -  get value() {
    -    return this.obj[this.prop];
    -  }
    -  set value(v) {
    -    this.obj[this.prop] = parseFloat(v);
    -  }
    -}
    -
    -

    Используя эти классы, мы можем настроить простой графический интерфейс для настроек выше

    -
    const wrapModes = {
    -  'ClampToEdgeWrapping': THREE.ClampToEdgeWrapping,
    -  'RepeatWrapping': THREE.RepeatWrapping,
    -  'MirroredRepeatWrapping': THREE.MirroredRepeatWrapping,
    -};
    -
    -function updateTexture() {
    -  texture.needsUpdate = true;
    -}
    -
    -const gui = new GUI();
    -gui.add(new StringToNumberHelper(texture, 'wrapS'), 'value', wrapModes)
    -  .name('texture.wrapS')
    -  .onChange(updateTexture);
    -gui.add(new StringToNumberHelper(texture, 'wrapT'), 'value', wrapModes)
    -  .name('texture.wrapT')
    -  .onChange(updateTexture);
    -gui.add(texture.repeat, 'x', 0, 5, .01).name('texture.repeat.x');
    -gui.add(texture.repeat, 'y', 0, 5, .01).name('texture.repeat.y');
    -gui.add(texture.offset, 'x', -2, 2, .01).name('texture.offset.x');
    -gui.add(texture.offset, 'y', -2, 2, .01).name('texture.offset.y');
    -gui.add(texture.center, 'x', -.5, 1.5, .01).name('texture.center.x');
    -gui.add(texture.center, 'y', -.5, 1.5, .01).name('texture.center.y');
    -gui.add(new DegRadHelper(texture, 'rotation'), 'value', -360, 360)
    -  .name('texture.rotation');
    -
    -

    Последнее, что следует отметить в этом примере, это то, что если вы измените wrapS или -wrapT на текстуре, вы также должны установить texture.needsUpdate -так, чтобы Three.js знал, чтобы применить эти настройки. Другие настройки применяются автоматически.

    -

    - -

    -

    Это только один шаг в тему текстур. В какой-то момент мы рассмотрим текстурные координаты, -а также 9 других типов текстур, которые можно применить к материалам.

    -

    А пока давайте перейдем к свету.

    - -

    - - -
    -
    -
    - - - - - - - - diff --git a/manual/ru/tips.html b/manual/ru/tips.html deleted file mode 100644 index 6fa8e78bbedcc4..00000000000000 --- a/manual/ru/tips.html +++ /dev/null @@ -1,358 +0,0 @@ - - - Советы - - - - - - - - - - - - - -
    -
    -

    Советы

    -
    -
    -
    -

    Эта статья представляет собой набор небольших проблем, с которыми вы можете столкнуться при использовании three.js, которые кажутся слишком маленькими, чтобы иметь собственную статью.

    -
    -

    -

    Делаем скриншот холста

    -

    В браузере фактически есть 2 функции, которые сделают скриншот. -Старая -canvas.toDataURL -и новая улучшенная -canvas.toBlob

    -

    Таким образом, вы думаете, что было бы легко сделать снимок экрана, просто добавив такой код, как

    -
    <canvas id="c"></canvas>
    -+<button id="screenshot" type="button">Save...</button>
    -
    -
    const elem = document.querySelector('#screenshot');
    -elem.addEventListener('click', () => {
    -  canvas.toBlob((blob) => {
    -    saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`);
    -  });
    -});
    -
    -const saveBlob = (function() {
    -  const a = document.createElement('a');
    -  document.body.appendChild(a);
    -  a.style.display = 'none';
    -  return function saveData(blob, fileName) {
    -     const url = window.URL.createObjectURL(blob);
    -     a.href = url;
    -     a.download = fileName;
    -     a.click();
    -  };
    -}());
    -
    -

    Вот пример из статьи об отзывчивости -с добавленным выше кодом и некоторым CSS для размещения кнопки

    -

    - -

    -

    Когда я попробовал это, я получил этот скриншот

    -
    - -

    Да, это просто черное изображение.

    -

    Возможно, это сработало для вас в зависимости от вашего браузера / ОС, но в целом это вряд ли сработает.

    -

    Проблема заключается в том, что по соображениям производительности и совместимости -браузер по умолчанию очищает буфер рисования WebGL-холста после его отрисовки.

    -

    Решение состоит в том, чтобы вызвать ваш код рендеринга непосредственно перед захватом.

    -

    В нашем коде нам нужно настроить несколько вещей. Сначала давайте выделим код рендеринга.

    -
    +const state = {
    -+  time: 0,
    -+};
    -
    --function render(time) {
    --  time *= 0.001;
    -+function render() {
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -  cubes.forEach((cube, ndx) => {
    -    const speed = 1 + ndx * .1;
    --    const rot = time * speed;
    -+    const rot = state.time * speed;
    -    cube.rotation.x = rot;
    -    cube.rotation.y = rot;
    -  });
    -
    -  renderer.render(scene, camera);
    -
    --  requestAnimationFrame(render);
    -}
    -
    -+function animate(time) {
    -+  state.time = time * 0.001;
    -+
    -+  render();
    -+
    -+  requestAnimationFrame(animate);
    -+}
    -+requestAnimationFrame(animate);
    -
    -

    Теперь этот render касается только фактического рендеринга, мы можем вызвать его непосредственно перед захватом холста.

    -
    const elem = document.querySelector('#screenshot');
    -elem.addEventListener('click', () => {
    -+  render();
    -  canvas.toBlob((blob) => {
    -    saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`);
    -  });
    -});
    -
    -

    И теперь это должно работать.

    -

    - -

    -

    Для другого решения см. Следующий пункт.

    -
    -

    -

    Предотвращение очистки холста

    -

    Допустим, вы хотели, чтобы пользователь рисовал анимированный объект. -Вам нужно передать preserveDrawingBuffer: true при создании WebGLRenderer. -Это мешает браузеру очистить холст. Вы также должны сказать three.js не очищать холст.

    -
    const canvas = document.querySelector('#c');
    --const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -+const renderer = new THREE.WebGLRenderer({
    -+  canvas,
    -+  preserveDrawingBuffer: true,
    -+  alpha: true,
    -+});
    -+renderer.autoClearColor = false;
    -
    -

    - -

    -

    Обратите внимание, что если вы серьезно относитесь к созданию программы для рисования, -это не будет решением, так как браузер будет очищать холст каждый раз, когда мы меняем его разрешение. -Мы меняем разрешение в зависимости от размера дисплея. Размер дисплея изменяется при изменении размера окна. -Это включает в себя, когда пользователь загружает файл, даже в другой вкладке, и браузер добавляет строку состояния. -Это также включает в себя, когда пользователь поворачивает свой телефон и браузер переключается с портретного на альбомный.

    -

    Если вы действительно хотите создать программу для рисования, вы должны . -визуализировать текстуру, используя цель визуализации.

    -
    -

    -

    Ввод с клавиатуры

    -

    В этих уроках мы часто прикрепляли слушателей событий canvas. -Хотя многие события работают, по умолчанию не работают события клавиатуры.

    -

    Чтобы получить события клавиатуры, установите для холста tabindex - значение 0 или более. Например.

    -
    <canvas tabindex="0"></canvas>
    -
    -

    Это в конечном итоге вызывает новую проблему, хотя. Все, что имеет установленный tabindex будет выделено, когда оно будет в фокусе. -Чтобы исправить это, установите фокус CSS outline:none.

    -
    canvas:focus {
    -  outline:none;
    -}
    -
    -

    Для демонстрации здесь представлены 3 холста

    -
    <canvas id="c1"></canvas>
    -<canvas id="c2" tabindex="0"></canvas>
    -<canvas id="c3" tabindex="1"></canvas>
    -
    -

    и немного CSS только для последнего холста

    -
    #c3:focus {
    -    outline: none;
    -}
    -
    -

    Давайте прикрепим к ним всех слушателей событий

    -
    document.querySelectorAll('canvas').forEach((canvas) => {
    -  const ctx = canvas.getContext('2d');
    -
    -  function draw(str) {
    -    ctx.clearRect(0, 0, canvas.width, canvas.height);
    -    ctx.textAlign = 'center';
    -    ctx.textBaseline = 'middle';
    -    ctx.fillText(str, canvas.width / 2, canvas.height / 2);
    -  }
    -  draw(canvas.id);
    -
    -  canvas.addEventListener('focus', () => {
    -    draw('has focus press a key');
    -  });
    -
    -  canvas.addEventListener('blur', () => {
    -    draw('lost focus');
    -  });
    -
    -  canvas.addEventListener('keydown', (e) => {
    -    draw(`keyCode: ${e.keyCode}`);
    -  });
    -});
    -
    -

    Обратите внимание, что вы не можете получить первый холст, -чтобы принять ввод с клавиатуры. Второе полотно вы можете, -но оно подсвечивается. На третьем холсте применяются оба решения.

    -

    - -

    -
    -

    -

    Делаем холст прозрачным

    -

    По умолчанию THREE.js делает холст непрозрачным. Если вы хотите, чтобы холст был прозрачным, -передайте alpha:true при создании WebGLRenderer

    -
    const canvas = document.querySelector('#c');
    --const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -+const renderer = new THREE.WebGLRenderer({
    -+  canvas,
    -+  alpha: true,
    -+});
    -
    -

    Вы, вероятно, также хотите сказать, что ваши результаты не используют предварительно умноженную альфа

    -
    const canvas = document.querySelector('#c');
    -const renderer = new THREE.WebGLRenderer({
    -  canvas,
    -  alpha: true,
    -+  premultipliedAlpha: false,
    -});
    -
    -

    Three.js по умолчанию использует холст с использованием -premultipliedAlpha: true но по умолчанию используется для материалов, которые выводят -premultipliedAlpha: false.

    -

    Если вы хотите лучше понять, когда и когда не следует использовать предварительно умноженную альфу, - вот хорошая статья об этом.

    -

    В любом случае давайте настроим простой пример с прозрачным холстом.

    -

    Мы применили настройки выше к примеру из статьи об отзывчивости. -Давайте также сделаем материалы более прозрачными

    -
    function makeInstance(geometry, color, x) {
    --  const material = new THREE.MeshPhongMaterial({color});
    -+  const material = new THREE.MeshPhongMaterial({
    -+    color,
    -+    opacity: 0.5,
    -+  });
    -
    -...
    -
    -

    И давайте добавим немного HTML-контента

    -
    <body>
    -  <canvas id="c"></canvas>
    -+  <div id="content">
    -+    <div>
    -+      <h1>Cubes-R-Us!</h1>
    -+      <p>We make the best cubes!</p>
    -+    </div>
    -+  </div>
    -</body>
    -
    -

    а также немного CSS, чтобы поставить холст впереди

    -
    body {
    -    margin: 0;
    -}
    -#c {
    -    width: 100%;
    -    height: 100%;
    -    display: block;
    -+    position: fixed;
    -+    left: 0;
    -+    top: 0;
    -+    z-index: 2;
    -+    pointer-events: none;
    -}
    -+#content {
    -+  font-size: 7vw;
    -+  font-family: sans-serif;
    -+  text-align: center;
    -+  width: 100%;
    -+  height: 100%;
    -+  display: flex;
    -+  justify-content: center;
    -+  align-items: center;
    -+}
    -
    -

    обратите внимание, что pointer-events: none делает холст невидимым для мыши и сенсорных событий, поэтому вы можете выделить текст под ним.

    -

    - -

    -
    -

    -

    Создание анимированного фона в three.js

    -

    Распространенный вопрос - как сделать анимацию three.js фоном веб-страницы.

    -

    Есть 2 очевидных способа.

    -
      -
    • Установите у холста CSS position fixed как в
    • -
    -
    #c {
    - position: fixed;
    - left: 0;
    - top: 0;
    - ...
    -}
    -
    -

    Вы можете в основном увидеть это точное решение на предыдущем примере. Просто установите z-index на -1, и кубы появятся за текстом.

    -

    Небольшим недостатком этого решения является то, что ваш JavaScript должен интегрироваться со страницей, -и если у вас сложная страница, вам нужно убедиться, что ни один из JavaScript в вашей визуализации -three.js не конфликтует с JavaScript, выполняющим другие действия на странице.

    -
      -
    • Используйте iframe
    • -
    -

    Это решение используется на -главной странице этого сайта .

    -

    На вашей веб-странице просто вставьте iframe, например

    -
    <iframe id="background" src="responsive.html">
    -<div>
    -  Your content goes here.
    -</div>
    -
    -

    Затем создайте стиль iframe, чтобы заполнить окно и оказаться в фоновом режиме, -который в основном является тем же кодом, который мы использовали выше для холста, -за исключением того, что нам также нужно установить значение none, поскольку -у iframes по умолчанию есть граница.

    -
    #background {
    -    position: fixed;
    -    width: 100%;
    -    height: 100%;
    -    left: 0;
    -    top: 0;
    -    z-index: -1;
    -    border: none;
    -    pointer-events: none;
    -}
    -

    - -

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/transparency.html b/manual/ru/transparency.html deleted file mode 100644 index 2ec392af679469..00000000000000 --- a/manual/ru/transparency.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Transparency - - - - - - - - - - - - - -
    -
    -

    Transparency

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/voxel-geometry.html b/manual/ru/voxel-geometry.html deleted file mode 100644 index ed3eba0cbd19aa..00000000000000 --- a/manual/ru/voxel-geometry.html +++ /dev/null @@ -1,42 +0,0 @@ - - - Voxel(Minecraft Like) Geometry - - - - - - - - - - - - - -
    -
    -

    Voxel(Minecraft Like) Geometry

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/webxr-basics.html b/manual/ru/webxr-basics.html deleted file mode 100644 index 782e13c805d8ed..00000000000000 --- a/manual/ru/webxr-basics.html +++ /dev/null @@ -1,42 +0,0 @@ - - - VR - - - - - - - - - - - - - -
    -
    -

    VR

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/webxr-look-to-select.html b/manual/ru/webxr-look-to-select.html deleted file mode 100644 index 23aa5278a516a5..00000000000000 --- a/manual/ru/webxr-look-to-select.html +++ /dev/null @@ -1,42 +0,0 @@ - - - VR - Look to Select - - - - - - - - - - - - - -
    -
    -

    VR - Look to Select

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/ru/webxr-point-to-select.html b/manual/ru/webxr-point-to-select.html deleted file mode 100644 index 0c6f9d5af92d1b..00000000000000 --- a/manual/ru/webxr-point-to-select.html +++ /dev/null @@ -1,42 +0,0 @@ - - - VR - 3DOF Point to Select - - - - - - - - - - - - - -
    -
    -

    VR - 3DOF Point to Select

    -
    -
    - -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/align-html-elements-to-3d.html b/manual/zh/align-html-elements-to-3d.html deleted file mode 100644 index d20d23a4728b3c..00000000000000 --- a/manual/zh/align-html-elements-to-3d.html +++ /dev/null @@ -1,681 +0,0 @@ - - - - - - 对齐HTML元素到3D对象 - - - - - - - - - - - - - - - - -
    -
    -

    对齐HTML元素到3D对象

    -
    -
    -
    -

    本文是THREE.js系列文章中的一部分。第一篇是 THREE.js 基础,如果你还没有读过或者你是THREE.js新手,你可能需要考虑从那开始。 -

    -

    有时你想在 3D 场景中显示一些文本,这有很多种选择,每一种都有各自的优缺点。

    -
      -
    • -

      使用 3D 文本

      -

      如果你看过 图元章节 你就会看到 TextGeometry 可以 - 生成3D文本,这可能对飞行类的Logo很有效,但对统计、信息、标记类不是很合适。

      -
    • -
    • -

      使用带2D文本的纹理图

      -

      这篇文章 使用Canvas作为纹理 提到Canvas可以作为物体的纹理绘制。你可以向Canvas中绘制文字并且 以Billboard的方式展示它。这种方法的优点是文本已被集成到3D场景中,像3D场景中的计算机终端,这可能是比较完美的。

      -
    • -
    • -

      使用HTML元素并定位它们以匹配3D场景

      -

      这种方法的好处是您可以使用所有的HTML能力。你的HTML中可以有多个元素,可以通过CSS设置样式,它也可以被用户选中因为它就是实际的文本内容。

      -
    • -
    -

    本文将介绍上述的最后一种方法。

    -

    让我们从简单的开始,我们将使用一些图元制作一个3D场景,然后为每个图元添加一个标签。我们会从这篇响应式开发中的一个例子开始。

    -

    我们会添加一个 OrbitControls 就像我们在 这篇光照的文章里做的一样。

    -
    -import * as THREE from 'three';
    -+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -
    -const controls = new OrbitControls(camera, canvas);
    -controls.target.set(0, 0, 0);
    -controls.update();
    -

    我们需要提供一个HTML元素来包含我们的标签元素。

    -
    -<body>
    --  <canvas id="c"></canvas>
    -+  <div id="container">
    -+    <canvas id="c"></canvas>
    -+    <div id="labels"></div>
    -+  </div>
    -</body>
    -

    通过将Canvas元素和 <div id="labels"> - 放在一个父元素里面,我们可以用这个CSS让它们重叠。

    -
    -#c {
    --    width: 100%;
    --    height: 100%;
    -+    width: 100%;  /* 让我们的容器决定尺寸 */
    -+    height: 100%;
    -		display: block;
    -}
    -+#container {
    -+  position: relative;  /* 作为子元素的相对定位元素 */
    -+  width: 100%;
    -+  height: 100%;
    -+  overflow: hidden;
    -+}
    -+#labels {
    -+  position: absolute;  /* 把Label定位在容器内 */
    -+  left: 0;             /* 默认定位在左上角 */
    -+  top: 0;
    -+  color: white;
    -+}
    -

    让我们也为Label本身添加一些CSS。

    -
    -#labels>div {
    -  position: absolute;  /* 让我们的容器决定尺寸 */
    -  left: 0;             /* 默认定位在左上角 */
    -  top: 0;
    -  cursor: pointer;     /* 当悬浮时,变为一个小手 */
    -  font-size: large;
    -  user-select: none;   /* 不允许文字被选中 */
    -  text-shadow:         /* 创造一个黑色阴影 */
    -    -1px -1px 0 #000,
    -    0   -1px 0 #000,
    -    1px -1px 0 #000,
    -    1px  0   0 #000,
    -    1px  1px 0 #000,
    -    0    1px 0 #000,
    -    -1px  1px 0 #000,
    -    -1px  0   0 #000;
    -}
    -#labels>div:hover {
    -  color: red;
    -}
    -

    现在进入我们的代码,我们不必添加太多,我们有一个函数makeInstance,可以用来生成立方体。我们现在让它同时添加一个Label元素。

    -
    -+const labelContainerElem = document.querySelector('#labels');
    -
    --function makeInstance(geometry, color, x) {
    -+function makeInstance(geometry, color, x, name) {
    -	const material = new THREE.MeshPhongMaterial({color});
    -
    -	const cube = new THREE.Mesh(geometry, material);
    -	scene.add(cube);
    -
    -	cube.position.x = x;
    -
    -+  const elem = document.createElement('div');
    -+  elem.textContent = name;
    -+  labelContainerElem.appendChild(elem);
    -
    --  return cube;
    -+  return {cube, elem};
    -}
    -

    你可以发现,我们正添加一个 <div> 到容器里, 每一个立方体各一个。 我们也返回一个对象,包含cube和Label元素elem。 -

    -

    为了调用它,我们需要为每一个立方体起个名字

    -
    -const cubes = [
    --  makeInstance(geometry, 0x44aa88,  0),
    --  makeInstance(geometry, 0x8844aa, -2),
    --  makeInstance(geometry, 0xaa8844,  2),
    -+  makeInstance(geometry, 0x44aa88,  0, 'Aqua'),
    -+  makeInstance(geometry, 0x8844aa, -2, 'Purple'),
    -+  makeInstance(geometry, 0xaa8844,  2, 'Gold'),
    -];
    -

    剩下的就是在渲染时定位Label元素。

    -
    const tempV = new THREE.Vector3();
    -...
    -
    --cubes.forEach((cube, ndx) => {
    -+cubes.forEach((cubeInfo, ndx) => {
    -+  const {cube, elem} = cubeInfo;
    -   const speed = 1 + ndx * .1;
    -   const rot = time * speed;
    -   cube.rotation.x = rot;
    -   cube.rotation.y = rot;
    -
    -+  // 获取立方体中心的位置
    -+  cube.updateWorldMatrix(true, false);
    -+  cube.getWorldPosition(tempV);
    -+
    -+  // 获取标准化屏幕坐标,x和y都会在-1和1区间
    -+  // x = -1 表示在最左侧
    -+  // y = -1 表示在最底部
    -+  tempV.project(camera);
    -+
    -+  // 将标准屏幕坐标转化为CSS坐标
    -+  const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
    -+  const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
    -+
    -+  // 将元素移动到此位置
    -+  elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
    -});
    -

    这样我们就有了与物体对齐的Label。

    -

    - -

    -

    这里有一些问题我们需要处理。

    -

    一个是我们旋转对象,一旦它们重叠了,那么它们对应的Label可能也会重叠。

    -
    -

    另外一个问题是,我们缩小了视野,物体移出了视锥体范围,Label还是在显示。

    -

    重叠对象的一种解决办法是 用这篇文章中的拾取方法,我们将传递对象在屏幕上的位置,然后调用RayCaster来告诉我们和哪些对象相交了。 - 如果我们的对象不是结果的第一个,说明我们并不在它最前面。 -

    -
    const tempV = new THREE.Vector3();
    -+const raycaster = new THREE.Raycaster();
    -
    -...
    -
    -cubes.forEach((cubeInfo, ndx) => {
    -  const {cube, elem} = cubeInfo;
    -  const speed = 1 + ndx * .1;
    -  const rot = time * speed;
    -  cube.rotation.x = rot;
    -  cube.rotation.y = rot;
    -
    -  // 获取立方体中心的位置
    -  cube.updateWorldMatrix(true, false);
    -  cube.getWorldPosition(tempV);
    -
    -  // 获取标准化屏幕坐标,x和y都会在-1和1区间
    -  // x = -1 表示在最左侧
    -  // y = -1 表示在最底部
    -  tempV.project(camera);
    -
    -+  // 调用Raycast获取所有相交的物体
    -+  // 以相机为起点,物体为终点
    -+  raycaster.setFromCamera(tempV, camera);
    -+  const intersectedObjects = raycaster.intersectObjects(scene.children);
    -+  // 如果第一个相交的是此物体,那么就是可见的
    -+  const show = intersectedObjects.length && cube === intersectedObjects[0].object;
    -+
    -+  if (!show) {
    -+    // 隐藏Label
    -+    elem.style.display = 'none';
    -+  } else {
    -+    // 显示Label
    -+    elem.style.display = '';
    -
    -    // 将标准屏幕坐标转化为CSS坐标
    -    const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
    -    const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
    -
    -    // 将元素移动到此位置
    -    elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
    -+  }
    -});
    -

    这解决了重叠问题。

    -

    为了处理超出视锥体不可见的问题,我们通过检查 tempV.z检查此对象的原点是否在截锥体之外。

    -
    -  if (!show) {
    -+  if (!show || Math.abs(tempV.z) > 1) {
    -    // 隐藏Label
    -    elem.style.display = 'none';
    -

    部分工作 有效是因为我们计算的标准化坐标包含一个z - 值,它从-1开始,也就是相机视锥体的 near 值, - +1结束,也就是相机视锥体的 far值。

    -

    -
    -
    -
    - 点击在新窗口打开 -

    -

    对于视锥体检查,上面的解决方案失败了。因为我们只检查对象的原点,对于一个大对象,它的原点可能会超出视锥体,但是对象仍然有一部分处于可视范围内。

    -

    更正确的解决方案是检查对象本身是否在视锥体中。不幸的是,检查很慢。对于3个立方体来说,这不是问题。但是其他情况不一定。

    -
    // 初始化
    -const frustum = new THREE.Frustum();
    -const viewProjection = new THREE.Matrix4();
    -
    -...
    -
    -// 在检查前
    -camera.updateMatrix();
    -camera.updateMatrixWorld();
    -camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
    -
    -...
    -
    -// 然后,对每一个Mesh
    -someMesh.updateMatrix();
    -someMesh.updateMatrixWorld();
    -
    -viewProjection.multiplyMatrices(
    -    camera.projectionMatrix, camera.matrixWorldInverse);
    -frustum.setFromProjectionMatrix(viewProjection);
    -const inFrustum = frustum.contains(someMesh));
    -

    我们当前的重叠解决方案有类似的问题,拾取很慢。我们可以使用基于GPU的拾取方案, 参考拾取章节,不过它也并非没有代价。使用哪个解决方案取决于你的需要。

    -

    另外一个问题是Label显示顺序,如果我们修改了代码以生成更长的Label

    -
    const cubes = [
    --  makeInstance(geometry, 0x44aa88,  0, 'Aqua'),
    --  makeInstance(geometry, 0x8844aa, -2, 'Purple'),
    --  makeInstance(geometry, 0xaa8844,  2, 'Gold'),
    -+  makeInstance(geometry, 0x44aa88,  0, 'Aqua Colored Box'),
    -+  makeInstance(geometry, 0x8844aa, -2, 'Purple Colored Box'),
    -+  makeInstance(geometry, 0xaa8844,  2, 'Gold Colored Box'),
    -];
    -

    然后设置CSS让它们不换行

    -
    #labels>div {
    -+  white-space: nowrap;
    -

    然后我们可能就会遇到这个问题

    -
    -
    -

    你可以看到紫色盒子在后面,但它的Label却在水蓝色盒子的前面。

    -

    我们可以修复这个问题,通过给每一个元素设置 zIndex。投影生成的位置有一个 z 值, - -1表示最前面,1表示最后面。 zIndex 却是一个整型,并且含义相反, - zIndex越大表示越靠前,所以下面的代码可能有用。 -

    - -
    // 将标准屏幕坐标转化为CSS坐标
    -const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
    -const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
    -
    -// 将元素移动到此位置
    -elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
    -
    -+// 设置排序用的zIndex
    -+elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
    -

    由于投影 z 值的取值限制,我们需要选择一个大数来分散这些值,否则许多Label将具有相同的值。为了保证Label不和页面其他的部分重叠,通过设置 z-index 给Label的容器,我们可以让浏览器创建一个新的 层叠上下文 -

    -
    #labels {
    -  position: absolute;  /* 把自己定位在容器内 */
    -+  z-index: 0;          /* 创建一个新的层叠上下文,这样子节点就不会和页面其他内容冲突 */
    -  left: 0;             /* 默认定位在左上角 */
    -  top: 0;
    -  color: white;
    -  z-index: 0;
    -}
    -

    现在Label应该总是按正确的顺序排列。

    -

    - -

    -

    我们在这里用一个例子说明更多的问题。让我们像谷歌地球一样画一个地球仪并标记国家。

    -

    我找到 这些数据, - 包含了各个国家的边界信息,用的协议是 - CC-BY-SA。 -

    - 加载这份数据, 可以生成国家的轮廓,大部分都带有国家的名称和定位。

    -
    -

    JSON数据是一个类似这样结构的数组

    -
    [
    -  {
    -    "name": "Algeria",
    -    "min": [
    -      -8.667223,
    -      18.976387
    -    ],
    -    "max": [
    -      11.986475,
    -      37.091385
    -    ],
    -    "area": 238174,
    -    "lat": 28.163,
    -    "lon": 2.632,
    -    "population": {
    -      "2005": 32854159
    -    }
    -  },
    -...
    -

    其中min,max,lat,lon都是经纬度信息。

    -

    开始加载它,这份代码是基于这篇优化大量对象,尽管我们没有绘制大量对象,但我们将使用 - 相同的解决办法,和 按需渲染 方案一样。

    -

    第一件事是创建一个球体,并且使用轮廓纹理。

    -
    {
    -  const loader = new THREE.TextureLoader();
    -  const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
    -  const geometry = new THREE.SphereGeometry(1, 64, 32);
    -  const material = new THREE.MeshBasicMaterial({map: texture});
    -  scene.add(new THREE.Mesh(geometry, material));
    -}
    -

    然后我们先创建一个loader,来加载JSON文件

    -
    async function loadJSON(url) {
    -  const req = await fetch(url);
    -  return req.json();
    -}
    -

    然后调用

    -
    let countryInfos;
    -async function loadCountryData() {
    -  countryInfos = await loadJSON('resources/data/world/country-info.json');
    -      ...
    -  }
    -  requestRenderIfNotRequested();
    -}
    -loadCountryData();
    - -

    现在让我们用这些数据来生成和放置Labels

    -

    在这一篇文章 优化大量对象, - 我们已经创建了一个小辅助对象,以便于计算地球上的经纬度位置,具体可以看看这篇文章是如何解释它们怎么工作的。

    -
    const lonFudge = Math.PI * 1.5;
    -const latFudge = Math.PI;
    -// 这些小工具会使得盒模型定位非常容易
    -// 我们可以旋转lonHelper Y轴上的分量到经度上
    -const lonHelper = new THREE.Object3D();
    -// 我们可以旋转latHelper X轴上的分量到纬度上
    -const latHelper = new THREE.Object3D();
    -lonHelper.add(latHelper);
    -// positionHelper将对象移动到球体的边缘
    -const positionHelper = new THREE.Object3D();
    -positionHelper.position.z = 1;
    -latHelper.add(positionHelper);
    -

    我们将使用它去计算每一个Label的位置

    -
    const labelParentElem = document.querySelector('#labels');
    -for (const countryInfo of countryInfos) {
    -  const {lat, lon, name} = countryInfo;
    -
    -  // 调整helper,旋转指向经纬度点的位置
    -  lonHelper.rotation.y = THREE.MathUtils.degToRad(lon) + lonFudge;
    -  latHelper.rotation.x = THREE.MathUtils.degToRad(lat) + latFudge;
    -
    -  // 获取经纬度位置
    -  positionHelper.updateWorldMatrix(true, false);
    -  const position = new THREE.Vector3();
    -  positionHelper.getWorldPosition(position);
    -  countryInfo.position = position;
    -
    -  // 给每一个国家添加一个Label
    -  const elem = document.createElement('div');
    -  elem.textContent = name;
    -  labelParentElem.appendChild(elem);
    -  countryInfo.elem = elem;
    -

    上面的代码看起来非常类似于我们为制作立方体Label而编写的代码,每个Label对应一个元素,完成后我们有一个数组 countryInfos, - 对于我们添加的每个国家/地区都有一个 elem - 属性代表Label元素 和一个 position 代表它的位置。

    -

    就像我们对立方体所做的那样,我们需要在渲染的时候先更新Label。

    - -
    const tempV = new THREE.Vector3();
    -function updateLabels() {
    -  // 如果JSON文件还没加载进来,就退出
    -  if (!countryInfos) {
    -    return;
    -  }
    -
    -  for (const countryInfo of countryInfos) {
    -    const {position, elem} = countryInfo;
    -
    -    // 获取标准化屏幕坐标,x和y都会在-1和1区间
    -    // x = -1 表示在最左侧
    -    // y = -1 表示在最底部
    -    tempV.copy(position);
    -    tempV.project(camera);
    -
    -    // 将标准屏幕坐标转化为CSS坐标
    -    const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
    -    const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
    -
    -    // 将元素移动到此位置
    -    elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
    -
    -    // 设置排序用的zIndex
    -    elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
    -  }
    -}
    -

    您可以看到上面的代码与之前的立方体示例基本类似,唯一的区别我们在初始化时预先计算了Label位置,我们可以这样做因为地球上的国家永远不会移动,只有我们的相机在移动。

    -

    然后我们需要在我们的渲染循环中调用 updateLabels

    -
    function render() {
    -  renderRequested = false;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -  controls.update();
    -
    -+  updateLabels();
    -
    -  renderer.render(scene, camera);
    -}
    -

    这就是我们得到的结果

    -

    - -

    -

    整出了密集恐惧症!

    -

    现在有两个问题:

    -
      -
    1. -

      出现了背对我们的Label

      -
    2. -
    3. -

      Label真的太多了

      -
    4. -
    -

    对于 问题#1 我们不能像上面那种方式使用 RayCaster ,因为除了地球以外没有什么可相交的。相反,我们可以 - 检查特定的国家是否远离我们,这是可行的,因为Label的位置围绕的是一个球体。事实上,我们使用的是一个半径1.0的单位球体,这意味着这些位置已经是单位向量,数学计算上比较简单。

    -
    const tempV = new THREE.Vector3();
    -+const cameraToPoint = new THREE.Vector3();
    -+const cameraPosition = new THREE.Vector3();
    -+const normalMatrix = new THREE.Matrix3();
    -
    -function updateLabels() {
    -  // 如果JSON文件还没加载进来,就退出
    -  if (!countryInfos) {
    -    return;
    -  }
    -
    -+  const minVisibleDot = 0.2;
    -+  // 获取表示相机相对方向的变换矩阵
    -+  normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
    -+  // 获取相机的世界坐标
    -+  camera.getWorldPosition(cameraPosition);
    -  for (const countryInfo of countryInfos) {
    -    const {position, elem} = countryInfo;
    -
    -+    // 根据相机的方向定位位置
    -+    // 由于球体在原点并且球体是半径为1.0的单位球体
    -+    // 这就能获取相对于相机的单位向量
    -+    tempV.copy(position);
    -+    tempV.applyMatrix3(normalMatrix);
    -+
    -+    // 计算从相机到这个位置的方向向量
    -+    cameraToPoint.copy(position);
    -+    cameraToPoint.applyMatrix4(camera.matrixWorldInverse).normalize();
    -+
    -+    // 求得相机方向 和相机连点方向 的点积.
    -+    // 1 = 正对相机
    -+    // 0 = 相对于相机而言,位于球体的边缘
    -+    // < 0 = 远离相机
    -+    const dot = tempV.dot(cameraToPoint);
    -+
    -+    // 如果方向不面向我们,隐藏它
    -+    if (dot < minVisibleDot) {
    -+      elem.style.display = 'none';
    -+      continue;
    -+    }
    -+
    -+    // 将元素恢复为其默认显示样式
    -+    elem.style.display = '';
    -
    -    // 获取标准化屏幕坐标,x和y都会在-1和1区间
    -    // x = -1 表示在最左侧
    -    // y = -1 表示在最底部
    -    tempV.copy(position);
    -    tempV.project(camera);
    -
    -    // 将标准屏幕坐标转化为CSS坐标
    -    const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
    -    const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
    -
    -    // 将元素移动到此位置
    -    countryInfo.elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
    -
    -    // 设置排序用的zIndex
    -    elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
    -  }
    -}
    -

    - 上面我们使用位置作为方向向量并获得相对于相机的位置,点乘得到向量之间的余弦值,这给了我们一个-1到1之间的值,其中-1表示正对相机,0表示相对于相机球体的边缘上,大于0表示处在后方。然后我们使用该值来显示或隐藏元素。 -

    -
    -
    -
    -
    -
    -

    - 在上图中,我们可以看到Label方向的点乘方向是从相机指向该位置的方向。如果你旋转角度,你会看到正对相机时点乘结果为-1.0,正好在球体相对相机的切线上时为0.0,或者换一种说法,两个向量互相垂直点乘结果为0,夹角大于90度时,Label在球体后面。 -

    -

    对于 问题#2,Label太多了,我们需要一些方法来决定显示哪些。一种方式是只显示大国的Label,我们加载的数据包含一个国家包含经纬度的最大和最小值,从中我们可以计算出一个区域,然后用它来判断是否显示国家。 -

    -

    开始的时候我们先计算区域面积

    -
    const labelParentElem = document.querySelector('#labels');
    -for (const countryInfo of countryInfos) {
    -  const {lat, lon, min, max, name} = countryInfo;
    -
    -  // 调整helper,旋转指向经纬度点的位置
    -  lonHelper.rotation.y = THREE.MathUtils.degToRad(lon) + lonFudge;
    -  latHelper.rotation.x = THREE.MathUtils.degToRad(lat) + latFudge;
    -
    -  // 获取经纬度位置
    -  positionHelper.updateWorldMatrix(true, false);
    -  const position = new THREE.Vector3();
    -  positionHelper.getWorldPosition(position);
    -  countryInfo.position = position;
    -
    -+  // 计算每个国家的面积
    -+  const width = max[0] - min[0];
    -+  const height = max[1] - min[1];
    -+  const area = width * height;
    -+  countryInfo.area = area;
    -
    -  // a给每一个国家添加一个Label
    -  const elem = document.createElement('div');
    -  elem.textContent = name;
    -  labelParentElem.appendChild(elem);
    -  countryInfo.elem = elem;
    -}
    -

    然后在渲染时让我们根据区域来决定是否显示Label

    -
    +const large = 20 * 20;
    -const maxVisibleDot = 0.2;
    -// 获取表示相机相对方向的变换矩阵
    -normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
    -//  获取相机的世界坐标
    -camera.getWorldPosition(cameraPosition);
    -for (const countryInfo of countryInfos) {
    --  const {position, elem} = countryInfo;
    -+  const {position, elem, area} = countryInfo;
    -+  // large enough?
    -+  if (area < large) {
    -+    elem.style.display = 'none';
    -+    continue;
    -+  }
    -
    -  ...
    -

    最后,由于我不确定这些值设多少好,于是添加一个GUI,就可以调试了

    -
    import * as THREE from 'three';
    -import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -+import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
    -
    +const settings = {
    -+  minArea: 20,
    -+  maxVisibleDot: -0.2,
    -+};
    -+const gui = new GUI({width: 300});
    -+gui.add(settings, 'minArea', 0, 50).onChange(requestRenderIfNotRequested);
    -+gui.add(settings, 'maxVisibleDot', -1, 1, 0.01).onChange(requestRenderIfNotRequested);
    -
    -function updateLabels() {
    -  if (!countryInfos) {
    -    return;
    -  }
    -
    --  const large = 20 * 20;
    --  const maxVisibleDot = -0.2;
    -+  const large = settings.minArea * settings.minArea;
    -  // 获取表示相机相对方向的变换矩阵
    -  normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
    -  //  获取相机的世界坐标
    -  camera.getWorldPosition(cameraPosition);
    -  for (const countryInfo of countryInfos) {
    -
    -    ...
    -
    -    // 如果方向不面向我们,隐藏它
    --    if (dot > maxVisibleDot) {
    -+    if (dot > settings.maxVisibleDot) {
    -      elem.style.display = 'none';
    -      continue;
    -    }
    -

    结果出来了

    -

    - -

    -

    你可以看到,随着你的旋转,后面地球的Label消失了。 - 调整 minVisibleDot 可以查看阈值的变化。 - 你也可以调整 minArea 可以看到更大或更小的国家出现。

    -
    -

    - 我在这方面做得越多,就越意识到谷歌地图做了多少工作。他们还必须决定使用哪些Label来显示。我很确定他们使用各种信息,例如你现在的位置、你的默认语言设置、你的帐户设置(如果你有的话),他们可能使用人口数量或人气程度,他们可能会优先考虑到视图中心的国家,等等……要考虑很多。 -

    -

    无论如何,我希望这些示例能让你了解如何用HTML对齐你的3D元素,我也或许会做出小小的贡献。

    -

    下一步我们来实现 拾取和高亮一个城市

    - -
    -
    -
    -

    - -

    - - - - - - - - - - \ No newline at end of file diff --git a/manual/zh/animation-system.html b/manual/zh/animation-system.html deleted file mode 100644 index 7cf9bb4555da6a..00000000000000 --- a/manual/zh/animation-system.html +++ /dev/null @@ -1,169 +0,0 @@ - - - 动画系统 - - - - - - - - - - - - - - -
    -
    -

    动画系统

    -
    -
    -
    - -

    概述

    - -

    - 在 three.js 的动画系统中,你可以为模型的多种属性制作动画: - 例如蒙皮绑定模型的骨骼、形态目标(morph targets)、不同材质属性 - (颜色、不透明度、布尔值)、可见性与变换。动画属性可以淡入、 - 淡出、交叉淡化(crossfade)和变速。即使是同一对象上的多个动画, - 或不同对象上的多个动画,也可以独立调整权重和时间缩放, - 并进行同步。

    - - 为了在一个统一系统中实现这些功能,three.js 动画系统在 - 2015 年[link:https://github.com/mrdoob/three.js/issues/6881 发生了彻底重构] - (注意甄别过时资料)。当前架构与 Unity / Unreal Engine 4 - 更接近。本页将简要介绍该系统的核心组件,以及它们如何协同工作。 - -

    - -

    动画片段(Animation Clips)

    - -

    - - 当你成功导入一个带动画的 3D 对象后(无论它使用骨骼、形态目标,或两者兼有), - 比如通过 [link:https://github.com/KhronosGroup/glTF-Blender-IO glTF Blender 导出器] - 从 Blender 导出,再使用 `GLTFLoader` 加载到 three.js 场景中, - 返回结果中通常会有一个名为 "animations" 的数组字段, - 其中包含该模型的动画片段(下文会列出支持此能力的加载器)。

    - - 每个 `AnimationClip` 一般表示对象的一种动作数据。以角色模型为例, - 可以有一个片段表示走路,第二个表示跳跃,第三个表示侧移,等等。 - -

    - -

    关键帧轨道(Keyframe Tracks)

    - -

    - - 在 `AnimationClip` 内部,每个被动画驱动的属性都会存储在独立的 - `KeyframeTrack` 中。假设角色有骨架,那么一条轨道可以记录前臂骨骼 - 随时间变化的位置数据,另一条记录同一骨骼的旋转变化,第三条记录 - 其他骨骼的位置、旋转或缩放,依此类推。也就是说, - 一个 AnimationClip 通常由大量此类轨道组成。

    - - 如果模型有形态目标(比如一个表示微笑,另一个表示愤怒), - 每条相关轨道会描述某个形态目标在该片段播放过程中, - 其影响权重如何随时间变化。 - -

    - -

    动画混合器(Animation Mixer)

    - -

    - - 这些存储的数据只是动画基础,真正的播放控制由 `AnimationMixer` 完成。 - 你可以把它理解成不只是一个“播放器”,更像一台真实的混音台: - 能够同时控制多个动画,并对它们进行混合与融合。 - -

    - -

    动画动作(Animation Actions)

    - -

    - - `AnimationMixer` 本身只有少量通用属性和方法, - 因为它主要通过动画动作来驱动。通过配置 `AnimationAction`, - 你可以决定某个 `AnimationClip` 在某个 mixer 上何时播放、暂停或停止, - 是否循环、循环次数、是否淡入淡出、是否进行时间缩放, - 以及更多高级控制(如交叉淡化与同步)。 - -

    - -

    动画对象组(Animation Object Groups)

    - -

    - - 如果你希望一组对象共享同一套动画状态, - 可以使用 `AnimationObjectGroup`。 - -

    - -

    支持的格式与加载器

    - -

    - 请注意,并非所有模型格式都包含动画(例如 OBJ 就不包含), - 而且只有部分 three.js 加载器支持 `AnimationClip` 序列。 - 下面这些加载器支持这种动画数据: -

    - -
      -
    • THREE.ObjectLoader
    • -
    • THREE.BVHLoader
    • -
    • THREE.ColladaLoader
    • -
    • THREE.FBXLoader
    • -
    • THREE.GLTFLoader
    • -
    - -

    - 另外,3ds Max 和 Maya 目前还不能直接将多个动画 - (即不在同一时间轴上的动画)导出到同一个文件中。 -

    - -

    示例

    - -
    -let mesh;
    -
    -// 创建 AnimationMixer,并获取 AnimationClip 列表
    -const mixer = new THREE.AnimationMixer( mesh );
    -const clips = mesh.animations;
    -
    -// 每帧更新 mixer
    -function update () {
    -  mixer.update( deltaSeconds );
    -}
    -
    -// 播放指定动画
    -const clip = THREE.AnimationClip.findByName( clips, 'dance' );
    -const action = mixer.clipAction( clip );
    -action.play();
    -
    -// 播放全部动画
    -clips.forEach( function ( clip ) {
    -  mixer.clipAction( clip ).play();
    -} );
    -
    - -
    -
    -
    - - - - - - - - - - - diff --git a/manual/zh/backgrounds.html b/manual/zh/backgrounds.html deleted file mode 100644 index 71047c0faaceba..00000000000000 --- a/manual/zh/backgrounds.html +++ /dev/null @@ -1,272 +0,0 @@ - - - - - - 背景与天空盒 - - - - - - - - - - - - - - - - -
    -
    -

    背景与天空盒

    -
    -
    -
    -

    这里大部分的文章都使用纯色作为背景。

    -

    添加静态背景就像添加CSS一样简单,参考来自这篇THREE.js响应式编程中的一个例子,我们只需要改变两个地方。 -

    -

    我们需要在Canvas上添加一些CSS,以将其背景设置为图像。

    -
    <style>
    -body {
    -    margin: 0;
    -}
    -#c {
    -    width: 100%;
    -    height: 100%;
    -    display: block;
    -+    background: url(resources/images/daikanyama.jpg) no-repeat center center;
    -+    background-size: cover;
    -}
    -</style>
    -

    然后我们需要告诉 WebGLRenderer 去使用 alpha,这样我们不绘制的地方都是透明的。 -

    -
    function main() {
    -  const canvas = document.querySelector('#c');
    --  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -+  const renderer = new THREE.WebGLRenderer({
    -+    antialias: true,
    -+    canvas,
    -+    alpha: true,
    -+  });
    -

    然后背景就有了。

    -

    - -

    -

    如果我们想让背景被 后处理效果影响,我们需要用THREE.js绘制背景。

    -

    THREE.js 让这变得非常简单。我们可以将场景的背景设置为一种纹理。

    -
    const loader = new THREE.TextureLoader();
    -const bgTexture = loader.load('resources/images/daikanyama.jpg');
    -bgTexture.colorSpace = THREE.SRGBColorSpace;
    -scene.background = bgTexture;
    -

    效果如下

    -

    - -

    -

    这产生了一个背景图,但它被拉伸以适应屏幕。

    -

    我们可以解决这个问题,通过设置纹理属性 repeatoffset 来显示图像的一部分。

    -
    function render(time) {
    -
    -  ...
    -
    -+  // 设置背景贴图的repeat和offset属性
    -+  // 来保证图片的比例是正确的
    -+  // 注意图片有可能还没加载完成
    -+  const canvasAspect = canvas.clientWidth / canvas.clientHeight;
    -+  const imageAspect = bgTexture.image ? bgTexture.image.width / bgTexture.image.height : 1;
    -+  const aspect = imageAspect / canvasAspect;
    -+
    -+  bgTexture.offset.x = aspect > 1 ? (1 - 1 / aspect) / 2 : 0;
    -+  bgTexture.repeat.x = aspect > 1 ? 1 / aspect : 1;
    -+
    -+  bgTexture.offset.y = aspect > 1 ? 0 : (1 - aspect) / 2;
    -+  bgTexture.repeat.y = aspect > 1 ? 1 : aspect;
    -
    -  ...
    -
    -  renderer.render(scene, camera);
    -
    -  requestAnimationFrame(render);
    -}
    -

    现在由 THREE.js 绘制背景。这个上面CSS的版本没有明显的区别,不过如果我们应用后处理效果,背景也会被影响。

    -

    -
    -
    -
    - 点击在新窗口打开 -

    -

    当然,静态背景通常不是我们在 3D 场景中想要的,相对的我们需要某种天空盒效果。就如字面意思,天空盒就是绘制了一片天空在盒子上。我们把相机放在盒子里,看起来里面有天空背景。

    -

    - 实现天空盒最常见的方法是制作一个立方体,应用纹理,绘制在它的内部。在立方体的每一面绘制一个纹理(使用纹理坐标),看起来像地平线的一些图像。通常使用带有纹理的天空球体或天空穹顶。你也许自己能想象得出来。只用一个立方体或球体, - 应用纹理, 标记它是 THREE.BackSide - ,这样我们渲染内部面而不是外部面,然后也把它直接放到场景里。或者使用两个场景,一个特殊的场景用于绘制天空盒/球体/圆顶,另一个画正常的一切。你可以用你正常的 PerspectiveCamera 去绘制,不需要OrthographicCamera。 -

    -

    另一个方法是使用立方体贴图(Cubemap). 立方体贴图是一种特殊的纹理,它有6个立方体面,它不使用标准的纹理坐标,使用从中心指向外部的方向来决定绘制的颜色。

    -

    这是来自加利福利亚,山景城的计算机历史博物馆的6张立方体图像。

    -
    - - - -
    -
    - - - -
    -

    为了使用它们,我们使用CubeTextureLoader 加载它们,然后将其用作场景的背景。

    -
    {
    -  const loader = new THREE.CubeTextureLoader();
    -  const texture = loader.load([
    -    'resources/images/cubemaps/computer-history-museum/pos-x.jpg',
    -    'resources/images/cubemaps/computer-history-museum/neg-x.jpg',
    -    'resources/images/cubemaps/computer-history-museum/pos-y.jpg',
    -    'resources/images/cubemaps/computer-history-museum/neg-y.jpg',
    -    'resources/images/cubemaps/computer-history-museum/pos-z.jpg',
    -    'resources/images/cubemaps/computer-history-museum/neg-z.jpg',
    -  ]);
    -  scene.background = texture;
    -}
    -

    在渲染时,我们不需要像上面那样调整纹理:

    -
    function render(time) {
    -
    -  ...
    -
    --  // 设置背景贴图的repeat和offset属性
    --  // 来保证图片的比例是正确的
    --  // 注意图片有可能还没加载完成
    --  const canvasAspect = canvas.clientWidth / canvas.clientHeight;
    --  const imageAspect = bgTexture.image ? bgTexture.image.width / bgTexture.image.height : 1;
    --  const aspect = imageAspect / canvasAspect;
    --
    --  bgTexture.offset.x = aspect > 1 ? (1 - 1 / aspect) / 2 : 0;
    --  bgTexture.repeat.x = aspect > 1 ? 1 / aspect : 1;
    --
    --  bgTexture.offset.y = aspect > 1 ? 0 : (1 - aspect) / 2;
    --  bgTexture.repeat.y = aspect > 1 ? 1 : aspect;
    -
    -  ...
    -
    -  renderer.render(scene, camera);
    -
    -  requestAnimationFrame(render);
    -}
    -

    让我们添加一些控件,以便我们可以旋转相机。

    -
    import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -
    const fov = 75;
    -const aspect = 2;  // Canvas默认值
    -const near = 0.1;
    --const far = 5;
    -+const far = 100;
    -const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    --camera.position.z = 2;
    -+camera.position.z = 3;
    -
    -+const controls = new OrbitControls(camera, canvas);
    -+controls.target.set(0, 0, 0);
    -+controls.update();
    -

    来尝试下,拖动以旋转示例里的相机,观察围绕着我们的立方体贴图。

    -

    - -

    -

    另一种选择是使用等距矩形贴图(Equirectangular map), 这是被 - 360 全景相机 拍摄的一种特殊类型的图片。 -

    -

    这是一张图片,我从 - 这个网站找到的。 -

    -
    -
    -

    这并不难,首先,我们将等距矩形图片加载为纹理, 我们可以调用 WebGLCubeRenderTarget.fromEquirectangularTexture - 这将为我们从等距矩形纹理中生成一个立方体贴图,我们传入预期的立方体贴图的大小给 WebGLCubeRenderTarget, - 使用等距矩形图片的高度似乎是一个不错的假设。

    -
    {
    --  const loader = new THREE.CubeTextureLoader();
    --  const texture = loader.load([
    --    'resources/images/cubemaps/computer-history-museum/pos-x.jpg',
    --    'resources/images/cubemaps/computer-history-museum/neg-x.jpg',
    --    'resources/images/cubemaps/computer-history-museum/pos-y.jpg',
    --    'resources/images/cubemaps/computer-history-museum/neg-y.jpg',
    --    'resources/images/cubemaps/computer-history-museum/pos-z.jpg',
    --    'resources/images/cubemaps/computer-history-museum/neg-z.jpg',
    --  ]);
    --  scene.background = texture;
    -+  const loader = new THREE.TextureLoader();
    -+  const texture = loader.load(
    -+    'resources/images/equirectangularmaps/tears_of_steel_bridge_2k.jpg',
    -+    () => {
    -+      texture.mapping = THREE.EquirectangularReflectionMapping;
    -+      texture.colorSpace = THREE.SRGBColorSpace;
    -+      scene.background = texture;
    -+    });
    -}
    -

    这就是全部要做的事。

    -

    - -

    -

    除了在加载时执行此操作,你还可以提前转换等距矩形到立方体贴图。这是一个可以为你做这件事的网站。 -

    -
    -
    -
    -
    - - - - - - - - - - \ No newline at end of file diff --git a/manual/zh/billboards.html b/manual/zh/billboards.html deleted file mode 100644 index 61cd9d09e39da1..00000000000000 --- a/manual/zh/billboards.html +++ /dev/null @@ -1,329 +0,0 @@ - - - - - - 广告牌(Billboards) - - - - - - - - - - - - - - - - -
    -
    -

    广告牌(Billboards)

    -
    -
    -
    -

    上一篇文章 我们使用了一个 CanvasTexture - 在人物上创作标签(Labels)和徽标(Badges)。有时我们想制作一些总是面对相机的东西。Three.js提供了 Sprite 和 - SpriteMaterial 来实现这个功能。 -

    - - -

    我们修改这个徽标的例子 使用Canvas作为纹理, - 应用 Sprite and - SpriteMaterial -

    - -
    function makePerson(x, labelWidth, size, name, color) {
    -  const canvas = makeLabelCanvas(labelWidth, size, name);
    -  const texture = new THREE.CanvasTexture(canvas);
    -  // 因为我们的Canvas的尺寸可能不是2的N次方
    -  // 在两个维度上适当地设置filter属性
    -  texture.minFilter = THREE.LinearFilter;
    -  texture.wrapS = THREE.ClampToEdgeWrapping;
    -  texture.wrapT = THREE.ClampToEdgeWrapping;
    -
    --  const labelMaterial = new THREE.MeshBasicMaterial({
    -+  const labelMaterial = new THREE.SpriteMaterial({
    -    map: texture,
    --    side: THREE.DoubleSide,
    -    transparent: true,
    -  });
    -
    -  const root = new THREE.Object3D();
    -  root.position.x = x;
    -
    -  const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
    -  root.add(body);
    -  body.position.y = bodyHeight / 2;
    -
    -  const head = new THREE.Mesh(headGeometry, bodyMaterial);
    -  root.add(head);
    -  head.position.y = bodyHeight + headRadius * 1.1;
    -
    --  const label = new THREE.Mesh(labelGeometry, labelMaterial);
    -+  const label = new THREE.Sprite(labelMaterial);
    -  root.add(label);
    -  label.position.y = bodyHeight * 4 / 5;
    -  label.position.z = bodyRadiusTop * 1.01;
    -

    现在标签始终是面向相机了。

    -

    - -

    -

    一个问题是,从某些角度来看的话,标签与人物重合了。

    -
    -
    -

    我们可以通过移动标签的位置来解决此问题。

    -
    -+// 如果单位是米,这里就用0.01
    -+// 也就是以厘米作为标签的单位
    -+const labelBaseScale = 0.01;
    -const label = new THREE.Sprite(labelMaterial);
    -root.add(label);
    --label.position.y = bodyHeight * 4 / 5;
    --label.position.z = bodyRadiusTop * 1.01;
    -+label.position.y = head.position.y + headRadius + size * labelBaseScale;
    -
    --// 如果单位是米,这里就用0.01
    --// 也就是以厘米作为标签的单位
    --const labelBaseScale = 0.01;
    -label.scale.x = canvas.width  * labelBaseScale;
    -label.scale.y = canvas.height * labelBaseScale;
    -

    - -

    -

    我们可以用Billboard做的另一件事是绘制立面(Facades)。

    -

    我们不绘制 3D 对象,而是使用图片绘制 2D 平面化的 3D 对象,这通常比绘制 3D 对象要快。

    -

    例如,我们用树木网络制作一个场景,我们让每一棵树的底部是圆柱体,顶部是圆锥体。

    -

    第一步,我们创建圆锥体和圆柱体的Geometry和Material,所有的树都会复用这些。

    -
    -const trunkRadius = .2;
    -const trunkHeight = 1;
    -const trunkRadialSegments = 12;
    -const trunkGeometry = new THREE.CylinderGeometry(
    -    trunkRadius, trunkRadius, trunkHeight, trunkRadialSegments);
    -
    -const topRadius = trunkRadius * 4;
    -const topHeight = trunkHeight * 2;
    -const topSegments = 12;
    -const topGeometry = new THREE.ConeGeometry(
    -    topRadius, topHeight, topSegments);
    -
    -const trunkMaterial = new THREE.MeshPhongMaterial({color: 'brown'});
    -const topMaterial = new THREE.MeshPhongMaterial({color: 'green'});
    -

    然后我们创建一个函数,对每一棵树的树干和树顶创建一个 Mesh - ,并把它们都加入到一个 Object3D对象下。

    -
    -function makeTree(x, z) {
    -  const root = new THREE.Object3D();
    -  const trunk = new THREE.Mesh(trunkGeometry, trunkMaterial);
    -  trunk.position.y = trunkHeight / 2;
    -  root.add(trunk);
    -
    -  const top = new THREE.Mesh(topGeometry, topMaterial);
    -  top.position.y = trunkHeight + topHeight / 2;
    -  root.add(top);
    -
    -  root.position.set(x, 0, z);
    -  scene.add(root);
    -
    -  return root;
    -}
    -

    然后我们会创建一个循环,生成树网络。

    -
    -for (let z = -50; z <= 50; z += 10) {
    -  for (let x = -50; x <= 50; x += 10) {
    -    makeTree(x, z);
    -  }
    -}
    -

    让我们再增加一个地平面。

    -
    -// 添加地面
    -{
    -  const size = 400;
    -  const geometry = new THREE.PlaneGeometry(size, size);
    -  const material = new THREE.MeshPhongMaterial({color: 'gray'});
    -  const mesh = new THREE.Mesh(geometry, material);
    -  mesh.rotation.x = Math.PI * -0.5;
    -  scene.add(mesh);
    -}
    -

    然后把背景调整为浅蓝(lightblue)

    - -
    -const scene = new THREE.Scene();
    --scene.background = new THREE.Color('white');
    -+scene.background = new THREE.Color('lightblue');
    -

    我们得到了一个树木网络

    -

    - -

    -

    这里有11x11或者121棵树,每棵树由12个多边形组成的锥体 + - 48个多边形组成的树干组成,所以每棵树包含60个多边形。121*60的结果是7260,这并不是很多,当然更精细的3D树可能有1000-3000个多边形构成。如果3000个多边形构成的树,那么121棵树将包含363000个多边形。 -

    -

    使用Facades,可以降低这个数字。

    -

    我们可以在一些绘图应用中手动创建一个Facade,现在让我们编写一些代码来手动生成一个。

    -

    现在写一些代码把对象绘制到纹理中,使用一个 RenderTarget,我们提到过使用 - RenderTarget - 来渲染,具体在这篇 渲染目标 文章里。 -

    -
    -function frameArea(sizeToFitOnScreen, boxSize, boxCenter, camera) {
    -  const halfSizeToFitOnScreen = sizeToFitOnScreen * 0.5;
    -  const halfFovY = THREE.MathUtils.degToRad(camera.fov * .5);
    -  const distance = halfSizeToFitOnScreen / Math.tan(halfFovY);
    -
    -  camera.position.copy(boxCenter);
    -  camera.position.z += distance;
    -
    -  // 为视锥体选择合适的near和far值
    -  // 可以把盒模型包裹进来
    -  camera.near = boxSize / 100;
    -  camera.far = boxSize * 100;
    -
    -  camera.updateProjectionMatrix();
    -}
    -
    -function makeSpriteTexture(textureSize, obj) {
    -  const rt = new THREE.WebGLRenderTarget(textureSize, textureSize);
    -
    -  const aspect = 1;  // 因为Render Target是正方形
    -  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -
    -  scene.add(obj);
    -
    -  // 计算对象的盒模型
    -  const box = new THREE.Box3().setFromObject(obj);
    -
    -  const boxSize = box.getSize(new THREE.Vector3());
    -  const boxCenter = box.getCenter(new THREE.Vector3());
    -
    -  // 设置相机去构建盒模型
    -  const fudge = 1.1;
    -  const size = Math.max(...boxSize.toArray()) * fudge;
    -  frameArea(size, size, boxCenter, camera);
    -
    -  renderer.autoClear = false;
    -  renderer.setRenderTarget(rt);
    -  renderer.render(scene, camera);
    -  renderer.setRenderTarget(null);
    -  renderer.autoClear = true;
    -
    -  scene.remove(obj);
    -
    -  return {
    -    position: boxCenter.multiplyScalar(fudge),
    -    scale: size,
    -    texture: rt.texture,
    -  };
    -}
    -

    关于上面代码的一些注意事项:

    -

    我们使用之前代码定义好的视图的 (fov) 属性, -

    -

    我们计算一个包含树的盒模型,这和 加载.obj的文件 中提到的方式一致,有一点微小的改变。

    -

    我们再次调用 frameArea,稍微改写了一下加载.obj的文件。 - 在这种情况下,我们计算相机需要离物体多远,从而让它的视野以包含对象。然后我们将相机的-z值设置为从对象盒模型的中心到此的距离。

    -

    我们将想要适应的大小乘以1.1倍(fudge) - 来确保树完全在渲染目标中。这是因为我们用来计算对象是否适合相机的视口的尺寸,没有考虑到对象的边缘可能会超出我们的可视区域之外。我们可以计算出如何让盒子100%合适,但这会浪费很多空间,所以我们就是蒙混 - (fudge) 一下。 -

    -

    然后我们渲染到RenderTarget中,然后从场景中移除此对象。

    -

    重点是需要场景中的灯光,但我们需要确保场景中没有其他东西。

    -

    我们也不能给场景设置背景色。

    -
    -const scene = new THREE.Scene();
    --scene.background = new THREE.Color('lightblue');
    -

    最后我们返回了纹理,位置和缩放比例,我们需要创建Facade,让它看起来在同一个地方。

    -

    然后我们制作一棵树,调用此代码:

    -
    -// 创建Billboard纹理
    -const tree = makeTree(0, 0);
    -const facadeSize = 64;
    -const treeSpriteInfo = makeSpriteTexture(facadeSize, tree);
    -

    然后我们可以制作一个Facade网络,而不是树网络。

    -
    -+function makeSprite(spriteInfo, x, z) {
    -+  const {texture, offset, scale} = spriteInfo;
    -+  const mat = new THREE.SpriteMaterial({
    -+    map: texture,
    -+    transparent: true,
    -+  });
    -+  const sprite = new THREE.Sprite(mat);
    -+  scene.add(sprite);
    -+  sprite.position.set(
    -+      offset.x + x,
    -+      offset.y,
    -+      offset.z + z);
    -+  sprite.scale.set(scale, scale, scale);
    -+}
    -
    -for (let z = -50; z <= 50; z += 10) {
    -  for (let x = -50; x <= 50; x += 10) {
    --    makeTree(x, z);
    -+    makeSprite(treeSpriteInfo, x, z);
    -  }
    -}
    -

    在上面的代码中,我们应用了定位Facade所需的偏移量和缩放比例,因此他会出现在和原树同一个地方。

    -

    现在我们已经完成了Facade纹理的制作,我们可以再次设置背景。

    -
    scene.background = new THREE.Color('lightblue');
    -

    现在我们得到了一个全是树Facades的场景。

    -

    - -

    -

    - 与上面的树模型相比,它们看起来非常相似。我们使用了低分辨率纹理,只有64x64像素,所以Facade是块状的,你当然可以提高分辨率。通常Facade只会用在非常远处的物体,因为当它们非常小的时候,低分辨率纹理就足够了。它节省了绘制远处只有几个像素的精致树模型时间。 -

    -

    另一个问题是我们只能从一侧查看树。这往往是通过渲染更多的Facade来解决,比如绘制对象周围的8个方向,然后根据实际相机的方向来设置要展示的Facade。

    -

    是否使用Facade由你决定,如果你决定去使用它们,希望这篇文章给了你一些想法和解决方案。

    -
    -
    -
    - - - - - - - - - - \ No newline at end of file diff --git a/manual/zh/cameras.html b/manual/zh/cameras.html deleted file mode 100644 index acf6fe30b1d214..00000000000000 --- a/manual/zh/cameras.html +++ /dev/null @@ -1,476 +0,0 @@ - - - 摄像机 - - - - - - - - - - - - - - -
    -
    -

    摄像机

    -
    -
    -
    -

    本文是关于 three.js 系列文章的一部分。第一篇文章是 three.js 基础。如果你还没看过而且对three.js 还不熟悉,那应该从那里开始,并且了解如何设置开发环境。上一篇文章介绍了 three.js 中的 纹理

    -

    我们开始谈谈three.js中的摄像机. 我们已经在第一篇文章 中涉及到了摄像机的一些知识,这里我们要更深入一些.

    -

    在three.js中最常用的摄像机并且之前我们一直用的摄像机是透视摄像机 PerspectiveCamera,它可以提供一个近大远小的3D视觉效果.

    -

    PerspectiveCamera 定义了一个 视锥(frustum)frustum 是一个切掉顶的三角锥或者说实心金字塔型。 -说到实心体solid,在这里通常是指一个立方体、一个圆锥、一个球、一个圆柱或锥台。

    -
    -
    立方体
    -
    圆锥
    -
    -
    圆柱
    -
    锥台
    -
    - -

    重新讲一遍这些东西是因为我好久没有在意过了。很多书或者文章提到锥台这个东西的时候我扫一眼就过去了。再了解一下不同几何体会让下面的一些表述变得更为感性...吧😅

    -

    PerspectiveCamera通过四个属性来定义一个视锥。near定义了视锥的前端,far定义了后端,fov是视野,通过计算正确的高度来从摄像机的位置获得指定的以near为单位的视野,定义的是视锥的前端和后端的高度。aspect间接地定义了视锥前端和后端的宽度,实际上视锥的宽度是通过高度乘以 aspect 来得到的。

    -

    -

    我们借用上一篇文章的场景. 其中包含一个地平面,一个球和一个立方体,我们可以在其中调整摄像机的设置。 -我们通过MinMaxGUIHelper来调整nearfar的设置。显然near应该总是比far要小。lil-gui 有minmax两个属性可调,然后这两个属性将决定摄像机的设置。

    -
    class MinMaxGUIHelper {
    -  constructor(obj, minProp, maxProp, minDif) {
    -    this.obj = obj;
    -    this.minProp = minProp;
    -    this.maxProp = maxProp;
    -    this.minDif = minDif;
    -  }
    -  get min() {
    -    return this.obj[this.minProp];
    -  }
    -  set min(v) {
    -    this.obj[this.minProp] = v;
    -    this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
    -  }
    -  get max() {
    -    return this.obj[this.maxProp];
    -  }
    -  set max(v) {
    -    this.obj[this.maxProp] = v;
    -    this.min = this.min;  // 这将调用min的setter
    -  }
    -}
    -
    -

    现在我们可以将GUI设置为:

    -
    function updateCamera() {
    -  camera.updateProjectionMatrix();
    -}
    -
    -const gui = new GUI();
    -gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
    -const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
    -gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
    -gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
    -
    -

    任何时候摄像机的设置变动,我们需要调用摄像机的updateProjectionMatrix来更新设置。我们写一个函数updataCamera,当lil-gui改变了属性的时候会调用它来更新参数。

    -

    - -

    -

    现在可以调整这些数值来观察这些参数是如何影响摄像机的。注意我们并没有改变aspect,因为这个参数来自于窗口的大小. 如果想调整aspect,只需要开个新窗口然后调整窗口大小就可以了。

    -

    即便是这样,观察参数对视野的影响还是挺麻烦的. 所以我们来设置两台摄像机吧! 一台是跟上面一样展现出摄像机中看到的实际场景,另一个则是用来观察这个实际工作的摄像机,然后画出摄像机的视锥.

    -

    我们需要用到three.js的剪函数(scissor function)来画两个场景和两个摄像机.

    -

    首先让我们用HTML和CSS来定义两个肩并肩的元素. 这也将帮助我们将两个摄像机赋予不同的OrbitControls

    -
    <body>
    -  <canvas id="c"></canvas>
    -+  <div class="split">
    -+     <div id="view1" tabindex="1"></div>
    -+     <div id="view2" tabindex="2"></div>
    -+  </div>
    -</body>
    -
    -

    CSS将控制两个视窗并排显示在 canvas 中:

    -
    .split {
    -  position: absolute;
    -  left: 0;
    -  top: 0;
    -  width: 100%;
    -  height: 100%;
    -  display: flex;
    -}
    -.split>div {
    -  width: 100%;
    -  height: 100%;
    -}
    -
    -

    接下来将添加一个CameraHelper, 它可以把摄像机的视锥画出来:

    -
    const cameraHelper = new THREE.CameraHelper(camera);
    -
    -...
    -
    -scene.add(cameraHelper);
    -
    -

    我们现在需要查找到刚刚定义的两个元素:

    -
    const view1Elem = document.querySelector('#view1');
    -const view2Elem = document.querySelector('#view2');
    -
    -

    现在只给第一个视窗中的摄像机分配OrbitControls

    -
    -const controls = new OrbitControls(camera, canvas);
    -+const controls = new OrbitControls(camera, view1Elem);
    -
    -

    我们定义第二个PerspectiveCameraOrbitControls

    -
    const camera2 = new THREE.PerspectiveCamera(
    -  60,  // fov
    -  2,   // aspect
    -  0.1, // near
    -  500, // far
    -);
    -camera2.position.set(40, 10, 30);
    -camera2.lookAt(0, 5, 0);
    -
    -const controls2 = new OrbitControls(camera2, view2Elem);
    -controls2.target.set(0, 5, 0);
    -controls2.update();
    -
    -

    最后,我们需要使用剪刀功能从每个摄影机的视角渲染场景,以仅渲染画布的一部分。 -这个函数接受一个元素,计算这个元素在canvas上的重叠面积,这将设置剪刀函数和视角长宽并返回 aspect :

    -
    function setScissorForElement(elem) {
    -  const canvasRect = canvas.getBoundingClientRect();
    -  const elemRect = elem.getBoundingClientRect();
    -
    -  // 计算canvas的尺寸
    -  const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
    -  const left = Math.max(0, elemRect.left - canvasRect.left);
    -  const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
    -  const top = Math.max(0, elemRect.top - canvasRect.top);
    -
    -  const width = Math.min(canvasRect.width, right - left);
    -  const height = Math.min(canvasRect.height, bottom - top);
    -
    -  // 设置剪函数以仅渲染一部分场景
    -  const positiveYUpBottom = canvasRect.height - bottom;
    -  renderer.setScissor(left, positiveYUpBottom, width, height);
    -  renderer.setViewport(left, positiveYUpBottom, width, height);
    -
    -  // 返回aspect
    -  return width / height;
    -}
    -
    -

    我们用这个函数在render中绘制两遍场景

    -
      function render() {
    -
    --    if (resizeRendererToDisplaySize(renderer)) {
    --      const canvas = renderer.domElement;
    --      camera.aspect = canvas.clientWidth / canvas.clientHeight;
    --      camera.updateProjectionMatrix();
    --    }
    -
    -+    resizeRendererToDisplaySize(renderer);
    -+
    -+    // 启用剪刀函数
    -+    renderer.setScissorTest(true);
    -+
    -+    // 渲染主视野
    -+    {
    -+      const aspect = setScissorForElement(view1Elem);
    -+
    -+      // 用计算出的aspect修改摄像机参数
    -+      camera.aspect = aspect;
    -+      camera.updateProjectionMatrix();
    -+      cameraHelper.update();
    -+
    -+      // 来原视野中不要绘制cameraHelper
    -+      cameraHelper.visible = false;
    -+
    -+      scene.background.set(0x000000);
    -+
    -+      // 渲染
    -+      renderer.render(scene, camera);
    -+    }
    -+
    -+    // 渲染第二台摄像机
    -+    {
    -+      const aspect = setScissorForElement(view2Elem);
    -+
    -+      // 调整aspect
    -+      camera2.aspect = aspect;
    -+      camera2.updateProjectionMatrix();
    -+
    -+      // 在第二台摄像机中绘制cameraHelper
    -+      cameraHelper.visible = true;
    -+
    -+      scene.background.set(0x000040);
    -+
    -+      renderer.render(scene, camera2);
    -+    }
    -
    --    renderer.render(scene, camera);
    -
    -    requestAnimationFrame(render);
    -  }
    -
    -  requestAnimationFrame(render);
    -}
    -
    -

    上面的代码还将主辅摄像机的背景色区分开以利观察。

    -

    我们可以移除updateCamera了,因为所有的东西在render中更新过了。

    -
    -function updateCamera() {
    --  camera.updateProjectionMatrix();
    --}
    -
    -const gui = new GUI();
    --gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
    -+gui.add(camera, 'fov', 1, 180);
    -const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
    --gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
    --gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
    -+gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near');
    -+gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far');
    -
    -

    现在我们就可以在辅摄像机中观察到主摄像机的视锥轮廓了。

    -

    - -

    -

    左侧可以看到主摄像机的视角,右侧则是辅摄像机观察主摄像机和主摄像机的视锥轮廓。可以调整nearfarfov和用鼠标移动摄像机来观察视锥轮廓和场景之间的关系.

    -

    near调整到大概20左右,前景就会在视锥中消失。far低于35时,远景也不复存在.

    -

    这带来一个问题,为什么不把near设置到0.0000000001然后将far设置成100000000,使得一切都可以尽收眼底? 原因是你的GPU没有足够的精度来决定某个东西是另一个东西的前面还是后面。更糟的是,在默认情况下,离摄像机近的将会更清晰,离摄像机远的模糊,从nearfar逐渐过渡。

    -

    从上面的例子出发,我们向场景中添加20个球:

    -
    {
    -  const sphereRadius = 3;
    -  const sphereWidthDivisions = 32;
    -  const sphereHeightDivisions = 16;
    -  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
    -  const numSpheres = 20;
    -  for (let i = 0; i < numSpheres; ++i) {
    -    const sphereMat = new THREE.MeshPhongMaterial();
    -    sphereMat.color.setHSL(i * .73, 1, 0.5);
    -    const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    -    mesh.position.set(-sphereRadius - 1, sphereRadius + 2, i * sphereRadius * -2.2);
    -    scene.add(mesh);
    -  }
    -}
    -
    -

    near 设置成0.00001

    -
    const fov = 45;
    -const aspect = 2;  // canvas 默认
    --const near = 0.1;
    -+const near = 0.00001;
    -const far = 100;
    -const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -
    -

    调整一下GUI使得能设置到0.00001

    -
    -gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
    -+gui.add(minMaxGUIHelper, 'min', 0.00001, 50, 0.00001).name('near').onChange(updateCamera);
    -
    -

    你觉得会发生什么?

    -

    - -

    -

    这就是一个典型的z冲突的例子。GPU没有足够的精度来决定哪个像素在前哪个在后。

    -

    如果你的机器太好可能不会出现我说的情况,我把我看到的截图放在这:

    -
    - -

    解决的方法之一是告诉three.js使用不同的方法计算像素的前后关系。我们可以在创建WebGLRenderer时开启logarithmicDepthBuffer

    -
    -const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -+const renderer = new THREE.WebGLRenderer({
    -+  antialias: true,
    -+  canvas,
    -+  logarithmicDepthBuffer: true,
    -+});
    -
    -

    这看起来就行了

    -

    - -

    -

    如果上面的方案不行的话,那你就遇到了为什么不能无脑使用这种解决方案的情况了。截止2018年9月,绝大多数台式机可以,但几乎没有移动设备支持这个功能。

    -

    另外,最好别用这种解决方案,因为这会大大降低运行速度。

    -

    即便是现在跑得好好地,选择太小的near和太大的far最终也会遇到同样的问题。

    -

    所以说你需要选择好好抉择nearfar的设置,来和你的场景配合。既不丢失重要的近景,也不让远处的东西消失不见。如果你想渲染一个巨大的场景,不但能看清面前的人的眼睫毛又想看到50公里以外的玩意,你得自己想一个厉害的方案,这里就不涉及了。现在,好好地选个适合的参数就行。

    -

    第二种常见的摄像机是正交摄像机 OrthographicCamera,和指定一个视锥不同的是,它需要设置leftright -topbottomnear,和far指定一个长方体,使得视野是平行的而不是透视的。

    -

    我们来把上面的例子改成OrthographicCamera, 首先来设置摄像机

    -
    const left = -1;
    -const right = 1;
    -const top = 1;
    -const bottom = -1;
    -const near = 5;
    -const far = 50;
    -const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
    -camera.zoom = 0.2;
    -
    -

    我们将leftbottom设置成 -1 righttop设成 1,这样就使盒子宽为两个单位,高两个单位。我们接下来通过调整lefttop来选择其 aspect 。我们将用zoom属性来调整相机到底展现多少的单位大小。

    -

    给GUI添加zoom设置:

    -
    const gui = new GUI();
    -+gui.add(camera, 'zoom', 0.01, 1, 0.01).listen();
    -
    -

    listen调用告诉lil-gui去监视属性的变化。写在这里是因为OrbitControls同样可以控制缩放。在这个例子中,鼠标滚轮将会通过OrbitControls控件来控制缩放。

    -

    最后更改aspect然后更新摄像机:

    -
    {
    -  const aspect = setScissorForElement(view1Elem);
    -
    -  // 使用aspect更新摄像机
    --  camera.aspect = aspect;
    -+  camera.left   = -aspect;
    -+  camera.right  =  aspect;
    -  camera.updateProjectionMatrix();
    -  cameraHelper.update();
    -
    -  // 在主摄像机中不绘制视野辅助线
    -  cameraHelper.visible = false;
    -
    -  scene.background.set(0x000000);
    -  renderer.render(scene, camera);
    -}
    -
    -

    现在就可以看到OrthographicCamera工作了。

    -

    - -

    -

    大多数情况下,绘制2D图像的时候会用到OrthographicCamera。你可以自己决定摄像机的视野大小。比如说你想让 canvas 的一个像素匹配摄像机的一个单位,你可以这么做:

    -

    将原点置于中心,令一个像素等于一个单位

    -
    camera.left = -canvas.width / 2;
    -camera.right = canvas.width / 2;
    -camera.top = canvas.height / 2;
    -camera.bottom = -canvas.height / 2;
    -camera.near = -1;
    -camera.far = 1;
    -camera.zoom = 1;
    -
    -

    或者如果我们想让原点在左上,就像是2D canvas

    -
    camera.left = 0;
    -camera.right = canvas.width;
    -camera.top = 0;
    -camera.bottom = canvas.height;
    -camera.near = -1;
    -camera.far = 1;
    -camera.zoom = 1;
    -
    -

    这样左上角就成了0,0

    -

    试试,这样设置摄像机

    -
    const left = 0;
    -const right = 300;  // 默认的canvas大小
    -const top = 0;
    -const bottom = 150;  // 默认的canvas大小
    -const near = -1;
    -const far = 1;
    -const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
    -camera.zoom = 1;
    -
    -

    然后我们载入六个材质,生成六个平面,一一对应。把每一个平面绑定到父对象THREE.Object3D上,以便调整每个平面和左上角原点的相对关系

    -
    const loader = new THREE.TextureLoader();
    -const textures = [
    -  loader.load('resources/images/flower-1.jpg'),
    -  loader.load('resources/images/flower-2.jpg'),
    -  loader.load('resources/images/flower-3.jpg'),
    -  loader.load('resources/images/flower-4.jpg'),
    -  loader.load('resources/images/flower-5.jpg'),
    -  loader.load('resources/images/flower-6.jpg'),
    -];
    -const planeSize = 256;
    -const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    -const planes = textures.map((texture) => {
    -  const planePivot = new THREE.Object3D();
    -  scene.add(planePivot);
    -  texture.magFilter = THREE.NearestFilter;
    -  const planeMat = new THREE.MeshBasicMaterial({
    -    map: texture,
    -    side: THREE.DoubleSide,
    -  });
    -  const mesh = new THREE.Mesh(planeGeo, planeMat);
    -  planePivot.add(mesh);
    -  // 调整平面使得左上角为原点
    -  mesh.position.set(planeSize / 2, planeSize / 2, 0);
    -  return planePivot;
    -});
    -
    -

    然后当 canvas 更新后我们更新摄像机设置

    -
    function render() {
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    camera.right = canvas.width;
    -    camera.bottom = canvas.height;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -  ...
    -
    -

    planesTHREE.Mesh的数组,每一个对应一个平面。 -现在让它随着时间移动

    -
    function render(time) {
    -  time *= 0.001;  // 转换为秒;
    -
    -  ...
    -
    -  const distAcross = Math.max(20, canvas.width - planeSize);
    -  const distDown = Math.max(20, canvas.height - planeSize);
    -
    -  // 来回运动的总距离
    -  const xRange = distAcross * 2;
    -  const yRange = distDown * 2;
    -  const speed = 180;
    -
    -  planes.forEach((plane, ndx) => {
    -    // 为每个平面单独计算时间
    -    const t = time * speed + ndx * 300;
    -
    -    // 在0到最远距离之间获取一个值
    -    const xt = t % xRange;
    -    const yt = t % yRange;
    -
    -    // 0到距离的一半, 向前运动
    -    // 另一半的时候往回运动
    -    const x = xt < distAcross ? xt : xRange - xt;
    -    const y = yt < distDown   ? yt : yRange - yt;
    -
    -    plane.position.set(x, y, 0);
    -  });
    -
    -  renderer.render(scene, camera);
    -
    -

    你可以看到图片在其中弹跳,和边际完美契合,就是2D canvas的效果一样

    -

    - -

    -

    另一个常见的用途是用OrthographicCamera来展示模型的三视图。

    -
    - -

    上面的截图展示了一个透视图和三个正交视角。

    -

    这就是摄像机的基础. 我们在其他的文章中会介绍另外的一些摄像机用法。现在,我们移步到阴影

    -

    - - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/canvas-textures.html b/manual/zh/canvas-textures.html deleted file mode 100644 index 08149d6eb60f3e..00000000000000 --- a/manual/zh/canvas-textures.html +++ /dev/null @@ -1,372 +0,0 @@ - - - Canvas 纹理 - - - - - - - - - - - - - - -
    -
    -

    Canvas 纹理

    -
    -
    -
    -

    这篇文章是此篇 关于纹理 文章的延续,如果你还没有读过,你或许应当从那篇开始。

    -

    上一篇讲解纹理的文章中,我们主要使用图像文件来生成动态纹理,有时候我们想在运行时生成一个纹理。一种可行的方式是使用 CanvasTexture

    -

    Canvas纹理 使用一个<canvas> 作为它的输入, 如果你还不知道如何使用2D Canvas API来在画布上绘制内容,MDN上有一篇很好的文章

    -

    我们来写一段简单的Canvas代码,这是一个在随机位置上绘制随机颜色的点的程序。

    - -
    const ctx = document.createElement('canvas').getContext('2d');
    -document.body.appendChild(ctx.canvas);
    -ctx.canvas.width = 256;
    -ctx.canvas.height = 256;
    -ctx.fillStyle = '#FFF';
    -ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    -
    -function randInt(min, max) {
    -  if (max === undefined) {
    -    max = min;
    -    min = 0;
    -  }
    -  return Math.random() * (max - min) + min | 0;
    -}
    -
    -function drawRandomDot() {
    -  ctx.fillStyle = `#${randInt(0x1000000).toString(16).padStart(6, '0')}`;
    -  ctx.beginPath();
    -
    -  const x = randInt(256);
    -  const y = randInt(256);
    -  const radius = randInt(10, 64);
    -  ctx.arc(x, y, radius, 0, Math.PI * 2);
    -  ctx.fill();
    -}
    -
    -function render() {
    -  drawRandomDot();
    -  requestAnimationFrame(render);
    -}
    -requestAnimationFrame(render);
    -

    这实在太简单了。

    -

    - -

    -

    现在让我们用它来绘制纹理。我们会用从 上一篇文章 中绘制立方体纹理的例子开始。 - 我们将删除加载图像的代码,取而代之的是使用我们的Canvas,通过创建一个CanvasTexture,然后把我们创建好的Canvas对象传入。

    -
    const cubes = [];  // 我们使用这个数组来旋转这些立方体
    --const loader = new THREE.TextureLoader();
    --
    -+const ctx = document.createElement('canvas').getContext('2d');
    -+ctx.canvas.width = 256;
    -+ctx.canvas.height = 256;
    -+ctx.fillStyle = '#FFF';
    -+ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    -+const texture = new THREE.CanvasTexture(ctx.canvas);
    -
    -const material = new THREE.MeshBasicMaterial({
    --  map: loader.load('resources/images/wall.jpg'),
    -+  map: texture,
    -});
    -const cube = new THREE.Mesh(geometry, material);
    -scene.add(cube);
    -cubes.push(cube);  // 添加到cube list中方便旋转
    -

    然后调用代码,在我们的渲染循环中绘制一个随机点。

    -
    function render(time) {
    -  time *= 0.001;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -+  drawRandomDot();
    -+  texture.needsUpdate = true;
    -
    -  cubes.forEach((cube, ndx) => {
    -    const speed = .2 + ndx * .1;
    -    const rot = time * speed;
    -    cube.rotation.x = rot;
    -    cube.rotation.y = rot;
    -  });
    -
    -  renderer.render(scene, camera);
    -
    -  requestAnimationFrame(render);
    -}
    -

    我们只需要做额外的一件事,设置了 CanvasTextureneedsUpdate属性来告诉THREE.js来更新纹理画布的最新内容。

    -

    这样,我们就有了一个用Canvas绘制纹理的立方体。

    -

    -

    -

    请注意,如果你想使用THREE.js绘制到Canvas中,你最好用 RenderTarget,在 这篇文章 中有提到。

    -

    纹理画布的一个常见用法是在场景中绘制文本。例如,你想把一个人的名字放在他们角色上面作为一个徽标(Badge),你也许需要使用Canvas来绘制徽标纹理。

    -

    让我们创建一个有3个人的场景,并给每个人绘制一个徽标或者标签(Label)。

    -

    让我们用上面的例子,移除所有相关的立方体。然后设置背景为白色,然后添加两个灯光

    -
    const scene = new THREE.Scene();
    -+scene.background = new THREE.Color('white');
    -+
    -+function addLight(position) {
    -+  const color = 0xFFFFFF;
    -+  const intensity = 1;
    -+  const light = new THREE.DirectionalLight(color, intensity);
    -+  light.position.set(...position);
    -+  scene.add(light);
    -+  scene.add(light.target);
    -+}
    -+addLight([-3, 1, 1]);
    -+addLight([ 2, 1, .5]);
    -

    让我们写一些代码以使用2D Canvas绘制标签

    -
    +function makeLabelCanvas(size, name) {
    -+  const borderSize = 2;
    -+  const ctx = document.createElement('canvas').getContext('2d');
    -+  const font =  `${size}px bold sans-serif`;
    -+  ctx.font = font;
    -+  // 测量一下name有多长
    -+  const doubleBorderSize = borderSize * 2;
    -+  const width = ctx.measureText(name).width + doubleBorderSize;
    -+  const height = size + doubleBorderSize;
    -+  ctx.canvas.width = width;
    -+  ctx.canvas.height = height;
    -+
    -+  // 注意,调整画布后需要重新修改字体
    -+  ctx.font = font;
    -+  ctx.textBaseline = 'top';
    -+
    -+  ctx.fillStyle = 'blue';
    -+  ctx.fillRect(0, 0, width, height);
    -+  ctx.fillStyle = 'white';
    -+  ctx.fillText(name, borderSize, borderSize);
    -+
    -+  return ctx.canvas;
    -+}
    -

    然后我们将用一个圆柱体作为身体,一个球体作为头部,一个平面作为标签来制作一个简单的人。

    -

    首先我们开始制作共享几何体。

    -
    +const bodyRadiusTop = .4;
    -+const bodyRadiusBottom = .2;
    -+const bodyHeight = 2;
    -+const bodyRadialSegments = 6;
    -+const bodyGeometry = new THREE.CylinderGeometry(
    -+    bodyRadiusTop, bodyRadiusBottom, bodyHeight, bodyRadialSegments);
    -+
    -+const headRadius = bodyRadiusTop * 0.8;
    -+const headLonSegments = 12;
    -+const headLatSegments = 5;
    -+const headGeometry = new THREE.SphereGeometry(
    -+    headRadius, headLonSegments, headLatSegments);
    -+
    -+const labelGeometry = new THREE.PlaneGeometry(1, 1);
    -

    然后我们写一个函数把这些部分组合成一个人。

    -
    +function makePerson(x, size, name, color) {
    -+  const canvas = makeLabelCanvas(size, name);
    -+  const texture = new THREE.CanvasTexture(canvas);
    -+  // 因为我们的Canvas长宽都不太可能是2的倍数,所以将filtering设置合理一些
    -+  texture.minFilter = THREE.LinearFilter;
    -+  texture.wrapS = THREE.ClampToEdgeWrapping;
    -+  texture.wrapT = THREE.ClampToEdgeWrapping;
    -+
    -+  const labelMaterial = new THREE.MeshBasicMaterial({
    -+    map: texture,
    -+    side: THREE.DoubleSide,
    -+    transparent: true,
    -+  });
    -+  const bodyMaterial = new THREE.MeshPhongMaterial({
    -+    color,
    -+    flatShading: true,
    -+  });
    -+
    -+  const root = new THREE.Object3D();
    -+  root.position.x = x;
    -+
    -+  const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
    -+  root.add(body);
    -+  body.position.y = bodyHeight / 2;
    -+
    -+  const head = new THREE.Mesh(headGeometry, bodyMaterial);
    -+  root.add(head);
    -+  head.position.y = bodyHeight + headRadius * 1.1;
    -+
    -+  const label = new THREE.Mesh(labelGeometry, labelMaterial);
    -+  root.add(label);
    -+  label.position.y = bodyHeight * 4 / 5;
    -+  label.position.z = bodyRadiusTop * 1.01;
    -+
    -+  // 如果单位是米, 那这里0.01就是将标签的尺寸转化为厘米
    -+  const labelBaseScale = 0.01;
    -+  label.scale.x = canvas.width  * labelBaseScale;
    -+  label.scale.y = canvas.height * labelBaseScale;
    -+
    -+  scene.add(root);
    -+  return root;
    -+}
    -

    在上面你可以看到,我们把身体、头部、标签放在了一个根Object3D 上并且调整了他们的位置。这样如果我们想移动人的话直接移动根对象就可以了。身体是2个单位的高度,如果1个单位等于1米,那么上面的代码会尝试用厘米为单位制作标签,它们使用厘米作为宽高,以更好的适合文本。

    -

    然后我们可以制作带标签的人

    -
    +makePerson(-3, 32, 'Purple People Eater', 'purple');
    -+makePerson(-0, 32, 'Green Machine', 'green');
    -+makePerson(+3, 32, 'Red Menace', 'red');
    -

    剩下的就是添加 OrbitControls 这样我们就可以移动相机了。

    -
    import * as THREE from 'three';
    -+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -
    const fov = 75;
    -const aspect = 2;  // Canvas默认值
    -const near = 0.1;
    --const far = 5;
    -+const far = 50;
    -const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    --camera.position.z = 2;
    -+camera.position.set(0, 2, 5);
    -
    -+const controls = new OrbitControls(camera, canvas);
    -+controls.target.set(0, 2, 0);
    -+controls.update();
    -

    然后我们得到了一些简单的标签。

    -

    -

    -

    注意事项:

    -
      -
    • 如果你过度放大,标签的分辨率会降低。
    • -
    -

    没有简单的解决方案,还有更复杂的字体渲染技术,据我所知没有插件可以解决这个问题。另外,还需要用户下载字体数据文件,这会变得很慢。

    -

    一种方案是增加标签的分辨率,尝试让尺寸变成现在的2倍,然后设置 labelBaseScale 是现在的一半。

    - -
      -
    • 名字越长,标签越长。
    • -
    -

    如果你想解决这个问题,你需要指定标签的固定大小,然后挤压文本。

    -

    这很容易做到。传入一个基本宽度并缩放文本以适应。

    -
    -function makeLabelCanvas(size, name) {
    -+function makeLabelCanvas(baseWidth, size, name) {
    -  const borderSize = 2;
    -  const ctx = document.createElement('canvas').getContext('2d');
    -  const font =  `${size}px bold sans-serif`;
    -  ctx.font = font;
    -  // 测量一下name有多长
    -+  const textWidth = ctx.measureText(name).width;
    -
    -  const doubleBorderSize = borderSize * 2;
    --  const width = ctx.measureText(name).width + doubleBorderSize;
    -+  const width = baseWidth + doubleBorderSize;
    -  const height = size + doubleBorderSize;
    -  ctx.canvas.width = width;
    -  ctx.canvas.height = height;
    -
    -  // 注意,调整画布后需要重新修改字体
    -  ctx.font = font;
    --  ctx.textBaseline = 'top';
    -+  ctx.textBaseline = 'middle';
    -+  ctx.textAlign = 'center';
    -
    -  ctx.fillStyle = 'blue';
    -  ctx.fillRect(0, 0, width, height);
    -
    -+  // 缩放以适应,但是不要拉伸
    -+  const scaleFactor = Math.min(1, baseWidth / textWidth);
    -+  ctx.translate(width / 2, height / 2);
    -+  ctx.scale(scaleFactor, 1);
    -  ctx.fillStyle = 'white';
    -  ctx.fillText(name, borderSize, borderSize);
    -
    -  return ctx.canvas;
    -}
    -

    然后我们可以传入预期标签的长度

    -
    -function makePerson(x, size, name, color) {
    --  const canvas = makeLabelCanvas(size, name);
    -+function makePerson(x, labelWidth, size, name, color) {
    -+  const canvas = makeLabelCanvas(labelWidth, size, name);
    -
    -...
    -
    -}
    -
    --makePerson(-3, 32, 'Purple People Eater', 'purple');
    --makePerson(-0, 32, 'Green Machine', 'green');
    --makePerson(+3, 32, 'Red Menace', 'red');
    -+makePerson(-3, 150, 32, 'Purple People Eater', 'purple');
    -+makePerson(-0, 150, 32, 'Green Machine', 'green');
    -+makePerson(+3, 150, 32, 'Red Menace', 'red');
    -

    我们将文本居中并缩放以适应标签的尺寸。

    -

    -

    -

    上面我们为每一个纹理使用了单独的Canvas,是否为每个纹理使用单独的Canvas取决于你。如果你需要经常单独更新它们,每个纹理一个Canvas是一个比较好的选择。如果它们很少或者从不更新,那么你可以用一个Canvas,通过THREE.js来生成多个纹理。让我们更改上面的代码来完成这一点。

    -
    +const ctx = document.createElement('canvas').getContext('2d');
    -function makeLabelCanvas(baseWidth, size, name) {
    -  const borderSize = 2;
    --  const ctx = document.createElement('canvas').getContext('2d');
    -  const font =  `${size}px bold sans-serif`;
    -
    -  ...
    -
    -}
    -
    -+const forceTextureInitialization = function() {
    -+  const material = new THREE.MeshBasicMaterial();
    -+  const geometry = new THREE.PlaneGeometry();
    -+  const scene = new THREE.Scene();
    -+  scene.add(new THREE.Mesh(geometry, material));
    -+  const camera = new THREE.Camera();
    -+
    -+  return function forceTextureInitialization(texture) {
    -+    material.map = texture;
    -+    renderer.render(scene, camera);
    -+  };
    -+}();
    -
    -function makePerson(x, labelWidth, size, name, color) {
    -  const canvas = makeLabelCanvas(labelWidth, size, name);
    -  const texture = new THREE.CanvasTexture(canvas);
    -  // 因为我们的Canvas长宽都不太可能是2的倍数,所以将filtering设置合理一些
    -  texture.minFilter = THREE.LinearFilter;
    -  texture.wrapS = THREE.ClampToEdgeWrapping;
    -  texture.wrapT = THREE.ClampToEdgeWrapping;
    -+  forceTextureInitialization(texture);
    -
    -  ...
    -

    -

    -

    另一个问题是标签并不总是面向相机,如果你使用标签作为徽标,这可能是一件好事。 - 如果你使用标签来放置3D游戏中玩家的名字,也许你希望标签总是面对相机。 - 具体内容在 广告牌(Billboards)文章 有覆盖到。

    -

    特别是对于标签,另一种解决方案是使用HTML, - 本文中的标签是 位于3D场景中 ,如果你想要他们被其他对象遮挡是很好的,因为 HTML 标签 总是在最上层。 -

    -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/cleanup.html b/manual/zh/cleanup.html deleted file mode 100644 index cf878673370e12..00000000000000 --- a/manual/zh/cleanup.html +++ /dev/null @@ -1,410 +0,0 @@ - - - 清除 - - - - - - - - - - - - - -
    -
    -

    清除

    -
    -
    -
    -

    Three.js应用经常使用大量的内存。一个3D模型的所有节点,可能占用1-20M内存。 - 一个模型可能会使用很多纹理,即使它们被压缩成了图片文件,也必须被展开成为未压缩的形态来使用。每个 1024 x 1024 大小的纹理会占用4-5M内存。

    -

    大多数的three.js应用在初始化的时候加载资源,并且一直使用这些资源直到页面关闭。但是,如果你想随时间的变动加载和改变资源怎么办呢?

    -

    不像大多数的JavaScript库,three.js不能自动的清除这些资源。 - 如果你切换页面,浏览器会清除这些资源,其它时候如何管理它们取决于你。这是WebGL设计的问题,three.js没有追索权只能将释放资源的责任托付给你。

    -

    通过在纹理、 - 图元和 - 材质对象上调用dispose方法来释放资源 -

    -

    你可以手动来处理。起初,你可能创建了一些资源。

    -
    const boxGeometry = new THREE.BoxGeometry(...);
    -const boxTexture = textureLoader.load(...);
    -const boxMaterial = new THREE.MeshPhongMaterial({map: texture});
    -
    -

    然后,当你处理完了它们,可以释放它们

    -
    boxGeometry.dispose();
    -boxTexture.dispose();
    -boxMaterial.dispose();
    -
    -

    随着你使用越来越多的资源,这将会变得越来越乏味。

    -

    为了减少一些乏味的工作,让我们创建一个类来跟踪这些资源。我们会请求这个类来帮我们做清除的工作。 -

    -

    这个类一开始是这个样子。

    -
    class ResourceTracker {
    -  constructor() {
    -    this.resources = new Set();
    -  }
    -  track(resource) {
    -    if (resource.dispose) {
    -      this.resources.add(resource);
    -    }
    -    return resource;
    -  }
    -  untrack(resource) {
    -    this.resources.delete(resource);
    -  }
    -  dispose() {
    -    for (const resource of this.resources) {
    -      resource.dispose();
    -    }
    -    this.resources.clear();
    -  }
    -}
    -
    -

    让我们在纹理文章中的例子中使用这个类。我们可以创建一个这个类的实例。

    -
    const resTracker = new ResourceTracker();
    -
    -

    然后为了让这个类的使用更加地简单,让我们来为track方法创建一个绑定函数。

    -
    const resTracker = new ResourceTracker();
    -+const track = resTracker.track.bind(resTracker);
    -
    -

    现在,我们只需要在我们创建的每个图元、纹理、材质对象上调用track方法就可以使用它。

    -
    const boxWidth = 1;
    -const boxHeight = 1;
    -const boxDepth = 1;
    --const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    -+const geometry = track(new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth));
    -
    -const cubes = [];  // an array we can use to rotate the cubes
    -const loader = new THREE.TextureLoader();
    -
    --const material = new THREE.MeshBasicMaterial({
    --  map: loader.load('resources/images/wall.jpg'),
    --});
    -+const material = track(new THREE.MeshBasicMaterial({
    -+  map: track(loader.load('resources/images/wall.jpg')),
    -+}));
    -const cube = new THREE.Mesh(geometry, material);
    -scene.add(cube);
    -cubes.push(cube);  // add to our list of cubes to rotate
    -
    -

    然后,我们从场景中移除这些立方体,再然后调用resTracker.dispose来释放它们。

    -
    for (const cube of cubes) {
    -  scene.remove(cube);
    -}
    -cubes.length = 0;  // clears the cubes array
    -resTracker.dispose();
    -
    -

    这起作用了,但是我发现必须从场景中移除立方体有些乏味。让我们给ResourceTracker增加这个功能。

    -
    class ResourceTracker {
    -  constructor() {
    -    this.resources = new Set();
    -  }
    -  track(resource) {
    --    if (resource.dispose) {
    -+    if (resource.dispose || resource instanceof THREE.Object3D) {
    -      this.resources.add(resource);
    -    }
    -    return resource;
    -  }
    -  untrack(resource) {
    -    this.resources.delete(resource);
    -  }
    -  dispose() {
    -    for (const resource of this.resources) {
    --      resource.dispose();
    -+      if (resource instanceof THREE.Object3D) {
    -+        if (resource.parent) {
    -+          resource.parent.remove(resource);
    -+        }
    -+      }
    -+      if (resource.dispose) {
    -+        resource.dispose();
    -+      }
    -+    }
    -    this.resources.clear();
    -  }
    -}
    -
    -

    现在我们可以跟踪这些立方体了

    -
    const material = track(new THREE.MeshBasicMaterial({
    -  map: track(loader.load('resources/images/wall.jpg')),
    -}));
    -const cube = track(new THREE.Mesh(geometry, material));
    -scene.add(cube);
    -cubes.push(cube);  // add to our list of cubes to rotate
    -
    -

    我们不再需要编码从场景中移除这些立方体了。

    -
    -for (const cube of cubes) {
    --  scene.remove(cube);
    --}
    -cubes.length = 0;  // clears the cube array
    -resTracker.dispose();
    -
    -

    让我们来调整一下代码,这样我们可以重新添加立方体、纹理和材质。

    -
    const scene = new THREE.Scene();
    -*const cubes = [];  // just an array we can use to rotate the cubes
    -
    -+function addStuffToScene() {
    -  const resTracker = new ResourceTracker();
    -  const track = resTracker.track.bind(resTracker);
    -
    -  const boxWidth = 1;
    -  const boxHeight = 1;
    -  const boxDepth = 1;
    -  const geometry = track(new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth));
    -
    -  const loader = new THREE.TextureLoader();
    -
    -  const material = track(new THREE.MeshBasicMaterial({
    -    map: track(loader.load('resources/images/wall.jpg')),
    -  }));
    -  const cube = track(new THREE.Mesh(geometry, material));
    -  scene.add(cube);
    -  cubes.push(cube);  // add to our list of cubes to rotate
    -+  return resTracker;
    -+}
    -
    -

    让我们来编写一些随着时间变动添加和移除物体的代码。

    -
    function waitSeconds(seconds = 0) {
    -  return new Promise(resolve => setTimeout(resolve, seconds * 1000));
    -}
    -
    -async function process() {
    -  for (;;) {
    -    const resTracker = addStuffToScene();
    -    await wait(2);
    -    cubes.length = 0;  // remove the cubes
    -    resTracker.dispose();
    -    await wait(1);
    -  }
    -}
    -process();
    -
    -

    代码将会创建立方体、纹理和材质,等待2秒,然后释放它们,然后等待1秒,重复这个过程。

    -

    - -

    -

    这好像能工作了。

    -

    对于加载文件来说,还需要一点额外的工作。大多数的加载器仅仅返回一个Object3D - 对象,作为它加载的层次对象的根节点,因此我们需要去发现所有的这些资源是哪些。

    -

    让我们更新ResourceTracker来试着去实现它。

    -

    首先,我们来检测这个物体是否是一个Object3D,然后跟踪它的图元、材质和子对象。

    -
    class ResourceTracker {
    -  constructor() {
    -    this.resources = new Set();
    -  }
    -  track(resource) {
    -    if (resource.dispose || resource instanceof THREE.Object3D) {
    -      this.resources.add(resource);
    -    }
    -+    if (resource instanceof THREE.Object3D) {
    -+      this.track(resource.geometry);
    -+      this.track(resource.material);
    -+      this.track(resource.children);
    -+    }
    -    return resource;
    -  }
    -  ...
    -}
    -
    -

    现在,因为任意的resource.geometryresource.materialresource.children有可能为null或undefined, - 我们将在track的入口执行检查。

    -
    class ResourceTracker {
    -  constructor() {
    -    this.resources = new Set();
    -  }
    -  track(resource) {
    -+    if (!resource) {
    -+      return resource;
    -+    }
    -
    -    if (resource.dispose || resource instanceof THREE.Object3D) {
    -      this.resources.add(resource);
    -    }
    -    if (resource instanceof THREE.Object3D) {
    -      this.track(resource.geometry);
    -      this.track(resource.material);
    -      this.track(resource.children);
    -    }
    -    return resource;
    -  }
    -  ...
    -}
    -
    -

    同时,因为resource.children是一个数组, - 同时resource.material也可能是数组,让我们对数组做检测。 -

    -
    class ResourceTracker {
    -  constructor() {
    -    this.resources = new Set();
    -  }
    -  track(resource) {
    -    if (!resource) {
    -      return resource;
    -    }
    -
    -+    // handle children and when material is an array of materials.
    -+    if (Array.isArray(resource)) {
    -+      resource.forEach(resource => this.track(resource));
    -+      return resource;
    -+    }
    -
    -    if (resource.dispose || resource instanceof THREE.Object3D) {
    -      this.resources.add(resource);
    -    }
    -    if (resource instanceof THREE.Object3D) {
    -      this.track(resource.geometry);
    -      this.track(resource.material);
    -      this.track(resource.children);
    -    }
    -    return resource;
    -  }
    -  ...
    -}
    -
    -

    最后我们需要遍历这些材质的属性和uniforms来寻找纹理。

    -
    class ResourceTracker {
    -  constructor() {
    -    this.resources = new Set();
    -  }
    -  track(resource) {
    -    if (!resource) {
    -      return resource;
    -    }
    -
    -*    // handle children and when material is an array of materials or
    -*    // uniform is array of textures
    -    if (Array.isArray(resource)) {
    -      resource.forEach(resource => this.track(resource));
    -      return resource;
    -    }
    -
    -    if (resource.dispose || resource instanceof THREE.Object3D) {
    -      this.resources.add(resource);
    -    }
    -    if (resource instanceof THREE.Object3D) {
    -      this.track(resource.geometry);
    -      this.track(resource.material);
    -      this.track(resource.children);
    --    }
    -+    } else if (resource instanceof THREE.Material) {
    -+      // We have to check if there are any textures on the material
    -+      for (const value of Object.values(resource)) {
    -+        if (value instanceof THREE.Texture) {
    -+          this.track(value);
    -+        }
    -+      }
    -+      // We also have to check if any uniforms reference textures or arrays of textures
    -+      if (resource.uniforms) {
    -+        for (const value of Object.values(resource.uniforms)) {
    -+          if (value) {
    -+            const uniformValue = value.value;
    -+            if (uniformValue instanceof THREE.Texture ||
    -+                Array.isArray(uniformValue)) {
    -+              this.track(uniformValue);
    -+            }
    -+          }
    -+        }
    -+      }
    -+    }
    -    return resource;
    -  }
    -  ...
    -}
    -
    -

    让我们来使用“加载gltf文件文章“中的例子,让它能够加载和释放文件。

    -
    const gltfLoader = new GLTFLoader();
    -function loadGLTF(url) {
    -  return new Promise((resolve, reject) => {
    -    gltfLoader.load(url, resolve, undefined, reject);
    -  });
    -}
    -
    -function waitSeconds(seconds = 0) {
    -  return new Promise(resolve => setTimeout(resolve, seconds * 1000));
    -}
    -
    -const fileURLs = [
    -  'resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf',
    -  'resources/models/3dbustchallange_submission/scene.gltf',
    -  'resources/models/mountain_landscape/scene.gltf',
    -  'resources/models/simple_house_scene/scene.gltf',
    -];
    -
    -async function loadFiles() {
    -  for (;;) {
    -    for (const url of fileURLs) {
    -      const resMgr = new ResourceTracker();
    -      const track = resMgr.track.bind(resMgr);
    -      const gltf = await loadGLTF(url);
    -      const root = track(gltf.scene);
    -      scene.add(root);
    -
    -      // compute the box that contains all the stuff
    -      // from root and below
    -      const box = new THREE.Box3().setFromObject(root);
    -
    -      const boxSize = box.getSize(new THREE.Vector3()).length();
    -      const boxCenter = box.getCenter(new THREE.Vector3());
    -
    -      // set the camera to frame the box
    -      frameArea(boxSize * 1.1, boxSize, boxCenter, camera);
    -
    -      await waitSeconds(2);
    -      renderer.render(scene, camera);
    -
    -      resMgr.dispose();
    -
    -      await waitSeconds(1);
    -
    -    }
    -  }
    -}
    -loadFiles();
    -
    -

    然后我们得到了

    -

    - -

    -

    关于代码的一些注释。

    -

    如果我们想要加载2个或者更多的文件,并且想要随时地释放它们,我们将要每个文件使用一个ResourceTracker

    -

    在上面中,我们只是在场景加载之后跟踪了gltf.scene。 - 根据ResourceTracker的当前实现版本,它会跟踪刚刚加载的所有资源。如果我们向场景中添加了更多的资源,我们需要决定是否要跟踪它们。 -

    -

    举例来说,在我们加载了一个角色之后,我们把一个工具放入它的手中,这是通过把工具成为手的子对象来实现的。因此工具将不会被释放。我猜更多的时候,这不是我们想要的。

    -

    这带来了一个问题。当我起初写上面的ResourceTracker的时候, - 我是在dispose方法中遍历所有对象而不是在 - track方法中。稍后,我就想到了上面的成为手的子对象的工具的这个例子, - 在track方法中确切地跟踪哪些对象需要被释放更加地灵活,按理来说也更加地准确,因为我们可以跟踪从文件中加载了什么,而不是稍后从资源图中释放状态。 -

    -

    诚实地说,我对ResourceTracker并不是100%满意。在3D引擎中,做这样的事情并不是很常见。我们不应该去猜测什么资源被加载了,我们应该知道。 - 如果three.js能做出改变,所有的文件加载器返回能够引用所有加载资源的标准对象,就太好了。至少在这个时候,three.js在场景加载的时候并没有提供给我们其它的信息。因此,这个方法是有效的。

    -

    我希望这个例子能给你带来帮助或者至少成为在three.js中如何释放资源的一份好的参考

    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/color-management.html b/manual/zh/color-management.html deleted file mode 100644 index 76e2471d37cee7..00000000000000 --- a/manual/zh/color-management.html +++ /dev/null @@ -1,351 +0,0 @@ - - - 颜色管理 - - - - - - - - - - - - - - - -
    -
    -

    颜色管理

    -
    -
    -
    - -

    什么是色彩空间?

    - -

    - 每一种色彩空间,都是一组经过权衡的设计选择。它们共同目标是: - 在满足精度和显示技术限制的前提下,覆盖尽可能大的颜色范围。 - 在创建 3D 资源或把多个 3D 资源组装进同一场景时, - 理解这些属性及其在不同色彩空间之间的关系非常重要。 -

    - -
    - -
    - 参考 CIE 1931 色度图中的 sRGB 颜色与白点(D65)。 - 彩色区域是 sRGB 色域(三维体积)在二维中的投影。 - 来源:Wikipedia -
    -
    - -
      -
    • - 色彩原色(Color primaries):原色(如红、绿、蓝)并非绝对值; - 它们是在有限精度与显示设备能力约束下,从可见光谱中选定的。 - 颜色由各原色的比例来表达。 -
    • -
    • - 白点(White point):大多数色彩空间都会定义: - 当原色满足 R = G = B 时呈现“无色(中性)”。 - 白、灰等中性色的视觉效果依赖人眼感知,而感知又与观察环境相关。 - 因此色彩空间会指定一个“白点”以统一基准。 - sRGB 的白点是 [link:https://en.wikipedia.org/wiki/Illuminant_D65 D65]。 -
    • -
    • - 传递函数(Transfer functions):在确定色域和颜色模型后, - 还要定义数值与颜色空间之间的映射(传递函数)。 - r = 0.5 是表示物理光照比 r = 1.0 少 50%, - 还是表示人眼感知亮度少 50%?两者并不等价, - 差异由数学函数来描述。根据目标不同,传递函数可以是 - 线性非线性。sRGB 使用非线性传递函数。 - 这些函数有时会近似为伽马函数,但“gamma”一词在这里含义模糊, - 应尽量避免混用。 -
    • -
    - - 以上三个参数(原色、白点、传递函数)共同定义了一个色彩空间。 - 在此基础上,再补充几个术语会更清晰: - -
      -
    • - 颜色模型(Color model):在既定色域中用数字描述颜色的方式, - 可理解为颜色坐标系。在 three.js 里我们主要使用 RGB 模型, - 坐标为 r, g, b ∈ [0,1](闭区间)或 - r, g, b ∈ [0,+∞)(开域),每一项都表示某个原色所占比例。 - 其他模型(HSL、Lab、LCH)常用于美术调色。 -
    • -
    • - 色域(Color gamut):当原色与白点确定后,就确定了可见光谱中的一个体积范围, - 这就是“色域”。不在这个体积内的颜色(超出色域)无法用闭区间 [0,1] 的 RGB 表达。 - 在开域 [0,+∞) 中,色域在数学上可以视为无限。 -
    • -
    - -

    - 来看两个最常见的色彩空间:`SRGBColorSpace`(sRGB)与 - `LinearSRGBColorSpace`(Linear-sRGB)。两者原色和白点相同, - 因此色域一致,也都使用 RGB 模型。它们只在传递函数上不同: - Linear-sRGB 相对于物理光强是线性的;sRGB 使用非线性传递函数, - 更接近人眼感知与常见显示设备的响应特性。 -

    - -

    - 这个差异非常关键。光照计算和大多数渲染运算通常必须在线性色彩空间中完成。 - 但线性颜色在图像或帧缓冲中的存储效率较低,且直接显示给人眼时观感不正确。 - 因此,输入纹理与最终输出图像通常会使用非线性的 sRGB 色彩空间。 -

    - -
    -

    - ℹ️ 注意:虽然部分现代显示器支持 Display-P3 等更宽色域, - 但 Web 平台图形 API 仍主要基于 sRGB。 - 当前 three.js 应用通常只会使用 sRGB 与 Linear-sRGB。 -

    -
    - -

    色彩空间在流程中的角色

    - -

    - 现代渲染所需的线性工作流通常会涉及不止一种色彩空间, - 每种色彩空间承担不同职责。线性与非线性色彩空间适用于不同环节, - 如下所示。 -

    - -

    输入色彩空间

    - -

    - 传入 three.js 的颜色(来自取色器、纹理、3D 模型等)都带有各自色彩空间。 - 凡是不在 Linear-sRGB 工作色彩空间中的输入,都需要转换; - 纹理也必须正确设置 texture.colorSpace。 - 如果在初始化颜色前启用 THREE.ColorManagement, - 某些转换(如十六进制颜色与 CSS sRGB 颜色)会自动处理: -

    - - -THREE.ColorManagement.enabled = true; - - -

    - THREE.ColorManagement 默认已启用。 -

    - -
      -
    • - 材质、灯光与着色器:其颜色数据中的 RGB 分量存储在线性的 - Linear-sRGB 工作空间中。 -
    • -
    • - 顶点颜色:`BufferAttribute` 中的 RGB 分量也存储在 - Linear-sRGB 工作空间中。 -
    • -
    • - 颜色纹理:包含颜色信息的 PNG/JPEG `Texture` - (如 `.map`、`.emissiveMap`)应使用闭区间 sRGB, - 并标注 texture.colorSpace = SRGBColorSpace。 - OpenEXR 等格式(常用于 `.envMap`、`.lightMap`)则使用 Linear-sRGB, - 标注为 texture.colorSpace = LinearSRGBColorSpace, - 并且可能包含开域 [0,+∞) 的值。 -
    • -
    • - 非颜色纹理:不存储颜色信息的纹理(如 `.normalMap`、`.roughnessMap`) - 没有对应色彩空间,一般使用默认标注 - texture.colorSpace = NoColorSpace。 - 在少数场景下,非颜色数据可能因技术原因采用其他非线性编码。 -
    • -
    - -
    -

    - ⚠️ 警告:许多 3D 模型格式并未正确或一致地定义色彩空间信息。 - three.js 虽会尽量处理常见情况,但旧格式仍常出现问题。 - 为获得最佳结果,请优先使用 glTF 2.0(`GLTFLoader`), - 并尽早在在线查看器中验证资源本身是否正确。 -

    -
    - -

    工作色彩空间

    - -

    - 渲染、插值及许多其他计算,必须在开域的线性工作色彩空间中进行, - 此时 RGB 分量与物理光照强度成比例。在 three.js 中, - 工作色彩空间是 Linear-sRGB。 -

    - -

    输出色彩空间

    - -

    - 输出到显示设备、图片或视频时,通常需要将开域 Linear-sRGB - 工作空间转换到目标色彩空间。该转换由 - `WebGLRenderer.outputColorSpace` 定义。 - 使用后处理时,需要 `OutputPass`。 -

    - -
      -
    • - 显示:写入 WebGL 画布并显示的颜色应使用 sRGB。 -
    • -
    • - 图像:写入图像时应使用与格式和用途匹配的色彩空间。 - 完整渲染后保存为 PNG/JPEG 的图片通常使用 sRGB。 - 若图像包含自发光、光照贴图或其他不受 [0,1] 限制的数据, - 通常使用开域 Linear-sRGB,并配合 OpenEXR 等兼容格式。 -
    • -
    - -
    -

    - ⚠️ 警告:渲染目标可使用 sRGB 或 Linear-sRGB。 - sRGB 在有限精度下利用率更高:在闭区间内,sRGB 常用 8-bit 即可, - 而 Linear-sRGB 可能需要至少 16-bit(half float)。 - 若后续管线阶段还要求 Linear-sRGB 输入,额外转换会带来一定性能开销。 -

    -
    - -

    - 基于 `ShaderMaterial` 和 `RawShaderMaterial` 的自定义材质需要自行实现输出色彩空间转换。 - 对于 `ShaderMaterial`,通常在片元着色器 `main()` 中加入 - `colorspace_fragment` shader chunk 即可。 -

    - -

    使用 THREE.Color 实例

    - -

    - 读取或修改 `Color` 的方法默认假设数据已经在 three.js 的工作色彩空间 - (Linear-sRGB)中。RGB 与 HSL 分量都直接对应 `Color` 实例内部数据, - 不会被隐式转换。你可以显式调用 - .convertLinearToSRGB().convertSRGBToLinear() 进行转换。 -

    - -
    -// RGB 分量(不发生转换)。
    -color.r = color.g = color.b = 0.5;
    -console.log( color.r ); // → 0.5
    -
    -// 手动转换。
    -color.r = 0.5;
    -color.convertSRGBToLinear();
    -console.log( color.r ); // → 0.214041140
    -
    - -

    - 当设置 ColorManagement.enabled = true(推荐,且默认开启)后, - 某些转换会自动执行。由于十六进制与 CSS 颜色通常属于 sRGB, - `Color` 在 setter 中会把它们从 sRGB 转为 Linear-sRGB; - 在 getter 返回十六进制或 CSS 值时,则会从 Linear-sRGB 转回 sRGB。 -

    - -
    -// 十六进制转换。
    -color.setHex( 0x808080 );
    -console.log( color.r ); // → 0.214041140
    -console.log( color.getHex() ); // → 0x808080
    -
    -// CSS 颜色转换。
    -color.setStyle( 'rgb( 0.5, 0.5, 0.5 )' );
    -console.log( color.r ); // → 0.214041140
    -
    -// 通过 'colorSpace' 参数覆盖默认转换。
    -color.setHex( 0x808080, LinearSRGBColorSpace );
    -console.log( color.r ); // → 0.5
    -console.log( color.getHex( LinearSRGBColorSpace ) ); // → 0x808080
    -console.log( color.getHex( SRGBColorSpace ) ); // → 0xBCBCBC
    -
    - -

    常见错误

    - -

    - 当某个颜色或纹理配置错误时,它看起来会比预期更亮或更暗。 - 当渲染器输出色彩空间配置错误时,整张场景都可能偏暗 - (例如遗漏了到 sRGB 的转换)或偏亮(例如后处理中重复转换到 sRGB)。 - 这类问题通常并非全局线性偏差,单纯增减光照并不能真正解决。 -

    - -

    - 更隐蔽的问题是:当输入和输出色彩空间设置错误时, - 整体亮度看似正常,但颜色会在不同光照下异常变化, - 或明暗层次变得过曝、生硬。两个错误不会相互抵消。 - 务必确保工作色彩空间是线性的(scene referred), - 输出色彩空间是非线性的(display referred)。 -

    - -

    延伸阅读

    - - - -
    -
    -
    - - - - - - - - - - - diff --git a/manual/zh/creating-a-scene.html b/manual/zh/creating-a-scene.html deleted file mode 100644 index 67b88405373797..00000000000000 --- a/manual/zh/creating-a-scene.html +++ /dev/null @@ -1,179 +0,0 @@ - - - 创建场景 - - - - - - - - - - - - - -
    -
    -

    创建场景

    -
    -
    -
    - -

    本节的目标是简要介绍 three.js。我们将从搭建一个包含旋转立方体的场景开始。页面底部提供了可运行的示例,如果你遇到困难可以参考。

    - -

    开始之前

    - -

    - 如果你还没有阅读过安装指南,请先阅读它。我们假设你已经搭建好了相同的项目结构(包括 index.htmlmain.js),安装了 three.js,并且正在使用构建工具,或使用本地服务器并配合 CDN 与 import maps。 -

    - -

    创建场景

    - -

    要使用 three.js 显示任何内容,我们需要三样东西:场景(scene)、相机(camera)和渲染器(renderer),这样我们才能通过相机来渲染场景。

    - -

    main.js —

    - -
    -import * as THREE from 'three';
    -
    -const scene = new THREE.Scene();
    -const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
    -
    -const renderer = new THREE.WebGLRenderer();
    -renderer.setSize( window.innerWidth, window.innerHeight );
    -document.body.appendChild( renderer.domElement );
    -
    - -

    让我们花点时间解释一下这里发生了什么。我们现在已经设置好了场景、相机和渲染器。

    - -

    three.js 中有几种不同的相机。目前,我们先使用 `PerspectiveCamera`(透视相机)。

    - -

    第一个参数是`视野范围`(field of view)。FOV 是指在任意时刻显示器上能看到的场景范围,值以角度为单位。

    - -

    第二个参数是`宽高比`(aspect ratio)。几乎总是应该使用元素的宽度除以高度,否则会出现类似在宽屏电视上播放老电影的效果——画面看起来会被压扁。

    - -

    接下来的两个参数是近裁剪面(`near`)和远裁剪面(`far`)。也就是说,距离相机比 `far` 更远或比 `near` 更近的物体将不会被渲染。你现在不必担心这个,但在实际应用中可能需要调整这些值以获得更好的性能。

    - -

    接下来是渲染器。除了创建渲染器实例之外,我们还需要设置渲染尺寸。通常建议用应用需要填满的区域宽高——在这里就是浏览器窗口的宽度和高度。对于性能要求较高的应用,你也可以给 `setSize` 传入较小的值,例如 `window.innerWidth/2` 和 `window.innerHeight/2`,这会使应用以四分之一的尺寸进行渲染。

    - -

    如果你希望保持应用的显示尺寸不变,但以较低的分辨率渲染,可以在调用 `setSize` 时将第三个参数 `updateStyle` 设为 false。例如,假设你的 <canvas> 宽高均为 100%,调用 `setSize(window.innerWidth/2, window.innerHeight/2, false)` 将以一半的分辨率渲染应用。

    - -

    最后,我们将 `renderer` 元素添加到 HTML 文档中。这是一个 <canvas> 元素,渲染器用它来向我们展示场景。

    - -

    "听起来不错,但你说好的立方体呢?" 现在就来添加它。

    - -
    -const geometry = new THREE.BoxGeometry( 1, 1, 1 );
    -const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
    -const cube = new THREE.Mesh( geometry, material );
    -scene.add( cube );
    -
    -camera.position.z = 5;
    -
    - -

    要创建一个立方体,我们需要一个 `BoxGeometry`(立方体几何体)。这个对象包含了立方体的所有顶点(`vertices`)和面(`faces`)。我们以后会进一步探索这些内容。

    - -

    除了几何体,我们还需要一个材质来为它着色。Three.js 提供了多种材质,这里我们先使用 `MeshBasicMaterial`。所有材质都接受一个属性对象。为了简单起见,我们只提供一个颜色属性 `0x00ff00`,即绿色。颜色的工作方式与 CSS 或 Photoshop 中的十六进制颜色(`hex colors`)相同。

    - -

    我们需要的第三样东西是 `Mesh`(网格)。网格是一个接受几何体并将材质应用于其上的对象,然后我们可以将它插入场景中并自由移动。

    - -

    默认情况下,当我们调用 `scene.add()` 时,添加的对象会被放置在坐标 `(0,0,0)` 处。这会导致相机和立方体重叠在一起。为了避免这种情况,我们只需将相机稍微向外移动一些。

    - -

    渲染场景

    - -

    如果你将上面的代码复制到之前创建的 main.js 文件中,你会发现什么都看不到。这是因为我们还没有真正进行渲染。为此,我们需要一个所谓的渲染循环或动画循环。

    - -
    -function animate( time ) {
    -  renderer.render( scene, camera );
    -}
    -renderer.setAnimationLoop( animate );
    -
    - -

    这会创建一个循环,让渲染器在每次屏幕刷新时绘制场景(在普通屏幕上这意味着每秒 60 次)。如果你是浏览器游戏开发的新手,可能会问"为什么不直接用 setInterval?"当然可以,但 `WebGLRenderer` 内部使用的 `requestAnimationFrame` 有很多优势。其中最重要的一点是,当用户切换到其他浏览器标签页时它会自动暂停,从而不会浪费宝贵的处理资源和电池寿命。

    - -

    让立方体动起来

    - -

    如果你将上面所有的代码都插入到文件中,你应该能看到一个绿色的立方体。让我们给它添加旋转,使它更有趣一些。

    - -

    在 `animate` 函数中的 `renderer.render` 调用之前添加以下代码:

    - -
    -cube.rotation.x = time / 2000;
    -cube.rotation.y = time / 1000;
    -
    - -

    这段代码会在每一帧执行(通常每秒 60 次),让立方体产生流畅的旋转动画。基本上,在应用运行期间你想要移动或改变的任何东西都需要通过动画循环来实现。当然,你可以在其中调用其他函数,这样就不会让 `animate` 函数变得过于冗长。

    - -

    最终效果

    -

    恭喜!你已经完成了你的第一个 three.js 应用。虽然很简单,但万事总要有个开始。

    - -

    完整代码如下,也可以作为可编辑的 [link:https://jsfiddle.net/zycqb61k/ 在线示例] 查看。试着修改代码来加深理解。

    - -

    index.html —

    - -
    -<!DOCTYPE html>
    -<html lang="en">
    -  <head>
    -    <meta charset="utf-8">
    -    <title>My first three.js app</title>
    -    <style>
    -      body { margin: 0; }
    -    </style>
    -  </head>
    -  <body>
    -    <script type="module" src="/main.js"></script>
    -  </body>
    -</html>
    -
    - -

    main.js —

    - -
    -import * as THREE from 'three';
    -
    -const scene = new THREE.Scene();
    -const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
    -
    -const renderer = new THREE.WebGLRenderer();
    -renderer.setSize( window.innerWidth, window.innerHeight );
    -renderer.setAnimationLoop( animate );
    -document.body.appendChild( renderer.domElement );
    -
    -const geometry = new THREE.BoxGeometry( 1, 1, 1 );
    -const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
    -const cube = new THREE.Mesh( geometry, material );
    -scene.add( cube );
    -
    -camera.position.z = 5;
    -
    -function animate( time ) {
    -
    -  cube.rotation.x = time / 2000;
    -  cube.rotation.y = time / 1000;
    -
    -  renderer.render( scene, camera );
    -
    -}
    -
    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/creating-text.html b/manual/zh/creating-text.html deleted file mode 100644 index 5c7f49975009c8..00000000000000 --- a/manual/zh/creating-text.html +++ /dev/null @@ -1,159 +0,0 @@ - - - 创建文本 - - - - - - - - - - - - - -
    -
    -

    创建文本

    -
    -
    -
    - -
    -

    - 在 three.js 应用中,你经常需要用到文本——下面是几种实现方式。 -

    -
    - -

    1. DOM + CSS

    -
    -

    - 使用 HTML 通常是添加文本最简单、最快捷的方式。大多数 three.js 示例中的描述性叠加层都采用了这种方法。 -

    -

    你可以向某个元素添加内容,例如:

    -
    -<div id="info">Description</div>
    -
    -

    - 然后使用 CSS 将其绝对定位,并通过 z-index 使其显示在所有其他元素之上,尤其是在 three.js 全屏运行时。 -

    - -
    -#info {
    -  position: absolute;
    -  top: 10px;
    -  width: 100%;
    -  text-align: center;
    -  z-index: 100;
    -  display:block;
    -}
    -
    - -
    - - -

    2. 使用 `CSS2DRenderer` 或 `CSS3DRenderer`

    -
    -

    - 使用这些渲染器可以将包含在 DOM 元素中的高质量文本绘制到 three.js 场景中。 - 这与方法 1 类似,但元素能更紧密、更动态地融入场景。 -

    -
    - - -

    3. 将文本绘制到 canvas 上并用作 `Texture`

    -
    -

    如果你希望在 three.js 场景中的平面上轻松绘制文本,可以使用此方法。

    -
    - - -

    4. 在你常用的 3D 应用中创建模型并导出到 three.js

    -
    -

    如果你更喜欢使用 3D 建模应用来制作模型,然后导入到 three.js 中,可以使用此方法。

    -
    - - -

    5. 程序化文本几何体

    -
    -

    - 如果你更倾向于完全在 THREE.js 中工作,或者需要创建程序化、动态的 3D 文本几何体, - 可以创建一个网格,其几何体是 THREE.TextGeometry 的实例: -

    -

    - new THREE.TextGeometry( text, parameters ); -

    -

    - 不过要使其正常工作,TextGeometry 的 `font` 参数需要设置为一个 THREE.Font 实例。 - - 请参阅 `TextGeometry` 页面,了解如何设置字体、各参数的说明,以及 THREE.js 发行版自带的 JSON 字体列表。 -

    - -

    示例

    - -

    - [example:webgl_geometry_text WebGL / geometry / text]
    - [example:webgl_shadowmap WebGL / shadowmap] -

    - -

    - 如果 Typeface 不可用,或你想使用其中没有的字体,可参考一个教程, - 其中包含用于 Blender 的 Python 脚本,可将文本导出为 Three.js 的 JSON 格式: - [link:http://www.jaanga.com/2012/03/blender-to-threejs-create-3d-text-with.html] -

    - -
    - - -

    6. 位图字体

    -
    -

    - BMFonts(位图字体)允许将字形批量合并到单个 BufferGeometry 中。BMFont 渲染支持自动换行、字母间距、字距调整、带标准导数的有符号距离场、多通道有符号距离场、多纹理字体等。 - 参阅 [link:https://github.com/felixmariotto/three-mesh-ui three-mesh-ui] 或 [link:https://github.com/Jam3/three-bmfont-text three-bmfont-text]。 -

    -

    - 现成的字体可以在 [link:https://github.com/etiennepinchon/aframe-fonts A-Frame Fonts] 等项目中找到, - 你也可以从任何 .TTF 字体创建自己的位图字体,并优化为仅包含项目所需的字符。 -

    -

    - 一些有用的工具: -

    -
      -
    • [link:http://msdf-bmfont.donmccurdy.com/ msdf-bmfont-web] (基于 Web)
    • -
    • [link:https://github.com/soimy/msdf-bmfont-xml msdf-bmfont-xml] (命令行)
    • -
    • [link:https://github.com/libgdx/libgdx/wiki/Hiero hiero] (桌面应用)
    • -
    -
    - - -

    7. Troika Text

    -
    -

    - [link:https://www.npmjs.com/package/troika-three-text troika-three-text] 该包使用与 BMFonts 类似的技术渲染高质量抗锯齿文本,但可以直接使用任何 .TTF 或 .WOFF 字体文件,无需离线预生成字形纹理。它还提供了以下功能: -

    -
      -
    • 描边、投影和弯曲等效果
    • -
    • 可以应用任何 three.js 材质,甚至是自定义 ShaderMaterial
    • -
    • 支持连字、连写字母书写体系(如阿拉伯文),以及从右到左/双向排版
    • -
    • 针对大量动态文本进行了优化,大部分工作在 Web Worker 中完成,避免占用主线程
    • -
    -
    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/custom-buffergeometry.html b/manual/zh/custom-buffergeometry.html deleted file mode 100644 index 795fdf956ecbbe..00000000000000 --- a/manual/zh/custom-buffergeometry.html +++ /dev/null @@ -1,391 +0,0 @@ - - - 自定义缓冲几何体 - - - - - - - - - - - - - - -
    -
    -

    自定义缓冲几何体

    -
    -
    -
    -

    在three.js中, BufferGeometry 是用来代表所有几何体的一种方式。 BufferGeometry 本质上是一系列 BufferAttributes 的 名称 。每一个 BufferAttribute 代表一种类型数据的数组:位置,法线,颜色,uv,等等…… 这些合起来, BufferAttributes 代表每个顶点所有数据的 并行数组

    -
    - -

    上面提到,我们有四个属性:position, normal, color, uv 。 -它们指的是 并行数组 ,代表每个属性的第N个数据集属于同一个顶点。index=4的顶点被高亮表示贯穿所有属性的平行数据定义一个顶点。

    -

    这就告诉我们,这是一个方块的数据图,高亮的地方代表一个角。

    -
    - -

    考虑下方块的单个角,不同的面都需要一个不同的法线。法线是面朝向的信息。在图中,在方块的角周围用箭头表示的法线,代表共用顶点位置的面需要指向不同方向的法线。

    -

    同理,一个角在不同的面需要不同的UVs。UVs是用来指定纹理区域中,画在相应顶点位置三角形的纹理坐标。你可以看到,绿色的面需要顶点的UV对应于F纹理的右上角,蓝色的面需要的UV对应于F纹理的左上角,红色的面需要的UV对应于F纹理的左下角。

    -

    一个简单的 顶点 是所有组成部分的集合。如果顶点需要其中任一部分变得不同,那么它必须是一个不同的顶点。

    -

    举一个简单的例子,让我们创建一个使用 BufferGeometry 的方块。方块很有趣,因为它看起来在角的地方共用顶点但实际上不是。在我们的例子中,我们将列出所有顶点数据,然后转化成并行数组,最后用它们创建 BufferAttributes 并添加到 BufferGeometry

    -

    我们从方块所需的所有数据开始。再次记住如果顶点有任何独一无二的部分,它必须是不同的顶点。像这里创建一个方块需要36个顶点,每个面2个三角形,每个三角形3个顶点,6个面=36个顶点。

    -
    const vertices = [
    -  // front
    -  { pos: [-1, -1,  1], norm: [ 0,  0,  1], uv: [0, 0], },
    -  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
    -  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
    -
    -  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
    -  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
    -  { pos: [ 1,  1,  1], norm: [ 0,  0,  1], uv: [1, 1], },
    -  // right
    -  { pos: [ 1, -1,  1], norm: [ 1,  0,  0], uv: [0, 0], },
    -  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
    -  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
    -
    -  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
    -  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
    -  { pos: [ 1,  1, -1], norm: [ 1,  0,  0], uv: [1, 1], },
    -  // back
    -  { pos: [ 1, -1, -1], norm: [ 0,  0, -1], uv: [0, 0], },
    -  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
    -  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
    -
    -  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
    -  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
    -  { pos: [-1,  1, -1], norm: [ 0,  0, -1], uv: [1, 1], },
    -  // left
    -  { pos: [-1, -1, -1], norm: [-1,  0,  0], uv: [0, 0], },
    -  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
    -  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
    -
    -  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
    -  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
    -  { pos: [-1,  1,  1], norm: [-1,  0,  0], uv: [1, 1], },
    -  // top
    -  { pos: [ 1,  1, -1], norm: [ 0,  1,  0], uv: [0, 0], },
    -  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
    -  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
    -
    -  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
    -  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
    -  { pos: [-1,  1,  1], norm: [ 0,  1,  0], uv: [1, 1], },
    -  // bottom
    -  { pos: [ 1, -1,  1], norm: [ 0, -1,  0], uv: [0, 0], },
    -  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
    -  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
    -
    -  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
    -  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
    -  { pos: [-1, -1, -1], norm: [ 0, -1,  0], uv: [1, 1], },
    -];
    -
    -

    然后我们能将它们全部转换成3个并行数组

    -
    const positions = [];
    -const normals = [];
    -const uvs = [];
    -for (const vertex of vertices) {
    -  positions.push(...vertex.pos);
    -  normals.push(...vertex.norm);
    -  uvs.push(...vertex.uv);
    -}
    -
    -

    最终我们能创建一个 BufferGeometry ,然后为每个数组创建一个 BufferAttribute 并添加到 BufferGeometry

    -
      const geometry = new THREE.BufferGeometry();
    -  const positionNumComponents = 3;
    -  const normalNumComponents = 3;
    -  const uvNumComponents = 2;
    -  geometry.setAttribute(
    -      'position',
    -      new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
    -  geometry.setAttribute(
    -      'normal',
    -      new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
    -  geometry.setAttribute(
    -      'uv',
    -      new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
    -
    -

    注意名字很重要。你必须将属性的名字命名成three.js所期望的(除非你正在创建自定义着色器),在这里是 positionnormaluv 。如果你想要设置顶点颜色则命名属性为 color

    -

    在上面我们创建了3个JavaScript原生数组, positions, normalsuvs 。 -然后我们将他们转换为 Float32Array 的类型数组TypedArraysBufferAttribute 是类型数组而不是原生数组。同时 BufferAttribute 需要你设定每个顶点有多少组成成分。对于位置和法线,每个顶点我们需要3个组成成分,x、y和z。对于UVs我们需要2个,u和v。

    -

    - -

    -

    那会是大量的数据。我们可以做点改善,可以用索引来代表顶点。看回我们的方块数据,每个面由2个三角形组成,每个三角形3个顶点,总共6个,但是其中2个是完全一样的;同样的位置,同样的法线,和同样的uv。因此,我们可以移除匹配的顶点,然后用索引代表他们。首先我们移除匹配的顶点。

    -
    const vertices = [
    -  // front
    -  { pos: [-1, -1,  1], norm: [ 0,  0,  1], uv: [0, 0], }, // 0
    -  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], }, // 1
    -  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], }, // 2
    --
    --  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], },
    --  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], },
    -  { pos: [ 1,  1,  1], norm: [ 0,  0,  1], uv: [1, 1], }, // 3
    -  // right
    -  { pos: [ 1, -1,  1], norm: [ 1,  0,  0], uv: [0, 0], }, // 4
    -  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], }, // 5
    --
    --  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
    --  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
    -  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], }, // 6
    -  { pos: [ 1,  1, -1], norm: [ 1,  0,  0], uv: [1, 1], }, // 7
    -  // back
    -  { pos: [ 1, -1, -1], norm: [ 0,  0, -1], uv: [0, 0], }, // 8
    -  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], }, // 9
    --
    --  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], },
    --  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], },
    -  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], }, // 10
    -  { pos: [-1,  1, -1], norm: [ 0,  0, -1], uv: [1, 1], }, // 11
    -  // left
    -  { pos: [-1, -1, -1], norm: [-1,  0,  0], uv: [0, 0], }, // 12
    -  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], }, // 13
    --
    --  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
    --  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
    -  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], }, // 14
    -  { pos: [-1,  1,  1], norm: [-1,  0,  0], uv: [1, 1], }, // 15
    -  // top
    -  { pos: [ 1,  1, -1], norm: [ 0,  1,  0], uv: [0, 0], }, // 16
    -  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], }, // 17
    --
    --  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
    --  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
    -  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], }, // 18
    -  { pos: [-1,  1,  1], norm: [ 0,  1,  0], uv: [1, 1], }, // 19
    -  // bottom
    -  { pos: [ 1, -1,  1], norm: [ 0, -1,  0], uv: [0, 0], }, // 20
    -  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], }, // 21
    --
    --  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
    --  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
    -  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], }, // 22
    -  { pos: [-1, -1, -1], norm: [ 0, -1,  0], uv: [1, 1], }, // 23
    -];
    -
    -

    现在我们有24个唯一的顶点。然后我们为36个要画的顶点设定36个索引,通过调用 BufferGeometry.setIndex 并传入索引数组来创建12个三角形。

    -
    geometry.setAttribute(
    -    'position',
    -    new THREE.BufferAttribute(positions, positionNumComponents));
    -geometry.setAttribute(
    -    'normal',
    -    new THREE.BufferAttribute(normals, normalNumComponents));
    -geometry.setAttribute(
    -    'uv',
    -    new THREE.BufferAttribute(uvs, uvNumComponents));
    -
    -+geometry.setIndex([
    -+   0,  1,  2,   2,  1,  3,  // front
    -+   4,  5,  6,   6,  5,  7,  // right
    -+   8,  9, 10,  10,  9, 11,  // back
    -+  12, 13, 14,  14, 13, 15,  // left
    -+  16, 17, 18,  18, 17, 19,  // top
    -+  20, 21, 22,  22, 21, 23,  // bottom
    -+]);
    -
    -

    - -

    -

    如果你没有提供法线数据的话, BufferGeometry 有个方法computeVertexNormals可以用来计算法线。不幸的是,因为如果顶点的其他数据不同的话,位置数据不能被共享,调用 computeVertexNormals 会让你的几何体像球面或者圆筒一样连接自身。

    -
    -
    -
    -
    -
    - -

    对于上面的圆筒,法线是通过 computeVertexNormals 方法创建的。 -如果你仔细观察会发现在圆筒上有条缝。这是因为在圆筒的开始和结束的地方没有办法共享顶点数据,需要不同的UVs,所以该方法不知道它们是同样的顶点以平滑过度。只要知道一点,解决方法是应用自己的法线数据。

    -

    我们同样可以在一开始使用类型数组TypedArrays取代JavaScript的原生数组。 -缺点是你必须在一开始定义数组的大小。当然那不是很难,但是使用原生数组我们只需要用 push 将数据加入数组并最后通过 length 查看数组大小。使用类型数组我们没有这样的方法,所以需要记录添加的数据。

    -

    在这个例子,提前计算数组长度很简单,因为我们一开始使用一大块静态数据。

    -
    -const positions = [];
    --const normals = [];
    --const uvs = [];
    -+const numVertices = vertices.length;
    -+const positionNumComponents = 3;
    -+const normalNumComponents = 3;
    -+const uvNumComponents = 2;
    -+const positions = new Float32Array(numVertices * positionNumComponents);
    -+const normals = new Float32Array(numVertices * normalNumComponents);
    -+const uvs = new Float32Array(numVertices * uvNumComponents);
    -+let posNdx = 0;
    -+let nrmNdx = 0;
    -+let uvNdx = 0;
    -for (const vertex of vertices) {
    --  positions.push(...vertex.pos);
    --  normals.push(...vertex.norm);
    --  uvs.push(...vertex.uv);
    -+  positions.set(vertex.pos, posNdx);
    -+  normals.set(vertex.norm, nrmNdx);
    -+  uvs.set(vertex.uv, uvNdx);
    -+  posNdx += positionNumComponents;
    -+  nrmNdx += normalNumComponents;
    -+  uvNdx += uvNumComponents;
    -}
    -
    -geometry.setAttribute(
    -    'position',
    --    new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
    -+    new THREE.BufferAttribute(positions, positionNumComponents));
    -geometry.setAttribute(
    -    'normal',
    --    new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
    -+    new THREE.BufferAttribute(normals, normalNumComponents));
    -geometry.setAttribute(
    -    'uv',
    --    new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
    -+    new THREE.BufferAttribute(uvs, uvNumComponents));
    -
    -geometry.setIndex([
    -   0,  1,  2,   2,  1,  3,  // front
    -   4,  5,  6,   6,  5,  7,  // right
    -   8,  9, 10,  10,  9, 11,  // back
    -  12, 13, 14,  14, 13, 15,  // left
    -  16, 17, 18,  18, 17, 19,  // top
    -  20, 21, 22,  22, 21, 23,  // bottom
    -]);
    -
    -

    - -

    -

    一个使用类型数组的好理由,是如果你想动态更新顶点数据的任何一部分。

    -

    因为想不起动态更新顶点数据的好例子,所以我决定创建一个球面并从中央开始进进出出地移动每个四边形。但愿它是个有用的例子。

    -

    这里是用来产生球面的位置和索引数据的代码。代码共享了四边形内的顶点数据,但是四边形之间的没有共享,因为我们需要分别地移动每个四边形。

    -

    因为我懒,所以我通过3个 Object3D 对象的层级关系,计算球面的点。关于如何计算在这篇文章有解释the article on optimizing lots of objects

    -
    function makeSpherePositions(segmentsAround, segmentsDown) {
    -  const numVertices = segmentsAround * segmentsDown * 6;
    -  const numComponents = 3;
    -  const positions = new Float32Array(numVertices * numComponents);
    -  const indices = [];
    -
    -  const longHelper = new THREE.Object3D();
    -  const latHelper = new THREE.Object3D();
    -  const pointHelper = new THREE.Object3D();
    -  longHelper.add(latHelper);
    -  latHelper.add(pointHelper);
    -  pointHelper.position.z = 1;
    -  const temp = new THREE.Vector3();
    -
    -  function getPoint(lat, long) {
    -    latHelper.rotation.x = lat;
    -    longHelper.rotation.y = long;
    -    longHelper.updateMatrixWorld(true);
    -    return pointHelper.getWorldPosition(temp).toArray();
    -  }
    -
    -  let posNdx = 0;
    -  let ndx = 0;
    -  for (let down = 0; down < segmentsDown; ++down) {
    -    const v0 = down / segmentsDown;
    -    const v1 = (down + 1) / segmentsDown;
    -    const lat0 = (v0 - 0.5) * Math.PI;
    -    const lat1 = (v1 - 0.5) * Math.PI;
    -
    -    for (let across = 0; across < segmentsAround; ++across) {
    -      const u0 = across / segmentsAround;
    -      const u1 = (across + 1) / segmentsAround;
    -      const long0 = u0 * Math.PI * 2;
    -      const long1 = u1 * Math.PI * 2;
    -
    -      positions.set(getPoint(lat0, long0), posNdx);  posNdx += numComponents;
    -      positions.set(getPoint(lat1, long0), posNdx);  posNdx += numComponents;
    -      positions.set(getPoint(lat0, long1), posNdx);  posNdx += numComponents;
    -      positions.set(getPoint(lat1, long1), posNdx);  posNdx += numComponents;
    -
    -      indices.push(
    -        ndx, ndx + 1, ndx + 2,
    -        ndx + 2, ndx + 1, ndx + 3,
    -      );
    -      ndx += 4;
    -    }
    -  }
    -  return {positions, indices};
    -}
    -
    -

    然后我们像这样调用。

    -
    const segmentsAround = 24;
    -const segmentsDown = 16;
    -const {positions, indices} = makeSpherePositions(segmentsAround, segmentsDown);
    -
    -

    因为返回的位置数据是单位球面位置,所以它们跟我们需要的法线数据完全一样,我们只需要复制它们。

    -
    const normals = positions.slice();
    -
    -

    然后我们像之前一样设置属性

    -
    const geometry = new THREE.BufferGeometry();
    -const positionNumComponents = 3;
    -const normalNumComponents = 3;
    -
    -+const positionAttribute = new THREE.BufferAttribute(positions, positionNumComponents);
    -+positionAttribute.setUsage(THREE.DynamicDrawUsage);
    -geometry.setAttribute(
    -    'position',
    -+    positionAttribute);
    -geometry.setAttribute(
    -    'normal',
    -    new THREE.BufferAttribute(normals, normalNumComponents));
    -geometry.setIndex(indices);
    -
    -

    我已经高亮一些区别。我们保存了位置属性的引用。 -同时我们标记它为动态。这是提示THREE.js我们将会经常改变属性的内容。

    -

    在我们的渲染循环中,每一帧我们基于它们的法线更新位置

    -
    const temp = new THREE.Vector3();
    -
    -...
    -
    -for (let i = 0; i < positions.length; i += 3) {
    -  const quad = (i / 12 | 0);
    -  const ringId = quad / segmentsAround | 0;
    -  const ringQuadId = quad % segmentsAround;
    -  const ringU = ringQuadId / segmentsAround;
    -  const angle = ringU * Math.PI * 2;
    -  temp.fromArray(normals, i);
    -  temp.multiplyScalar(THREE.MathUtils.lerp(1, 1.4, Math.sin(time + ringId + angle) * .5 + .5));
    -  temp.toArray(positions, i);
    -}
    -positionAttribute.needsUpdate = true;
    -
    -

    我们设置 positionAttribute.needsUpdate 告诉THREE.js更新我们的改变。

    -

    - -

    -

    我希望这些例子能对如何使用 BufferGeometry 直接创建你自己的几何体和如何动态更新 BufferAttribute 的内容发挥作用。

    - -

    -

    - - - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/debugging-glsl.html b/manual/zh/debugging-glsl.html deleted file mode 100644 index 3e5161dfc61ed5..00000000000000 --- a/manual/zh/debugging-glsl.html +++ /dev/null @@ -1,88 +0,0 @@ - - - 在中调试GLSL - - - - - - - - - - - - - - -
    -
    -

    在中调试GLSL

    -
    -
    -
    -

    这个网站不会教你GLSL本身就像它不会教你JavaScript一样。这些真的是很大的话题。如果您想学习 GLSL,可以考虑查看这些文章作为起点。

    -

    如果您已经了解 GLSL,那么这里有一些调试技巧。

    -

    当我正在写一个新的 GLSL 着色器并且什么都没有出现的时候,一般情况下,我做的第一件事是改变片段着色器返回一个纯色。 -例如,在着色器的最底部,我可以把

    -
    void main() {
    -
    -  ...
    -
    -  gl_FragColor = vec4(1, 0, 0, 1);  // red
    -}
    -
    -

    如果我看到我试图绘制的对象,那么我就知道这个问题与我的片段着色器有关。 -它可能是任何不好的纹理,未初始化的uniforms变量,有错误值的uniforms变量,但至少我有一个方向去检查。

    -

    为了测试其中的一些点,我可能会开始尝试绘制一些输入。例如,如果我在片段着色器中使用常量,那么我可以添加

    -
    gl_FragColor = vec4(vNormal * 0.5 + 0.5, 1);
    -
    -

    常量从 -1到 + 1,所以乘以0.5,再加上0.5,我们得到的值从0.0到1.0,这使得它们对颜色很有用。

    -

    尝试一些你知道会生效的然后你就会开始知道改变某个常量它应该是什么样子的。如果你改变的常量看起来和正常情况不太一样,你就有线索去排查了。如果你正在操作片段着色器中的常量,你可以使用同样的技术来绘制操作的结果。

    -
    - -

    类似的,如果我们使用纹理,会有纹理坐标,我们可以用类似的东西来绘制它们

    -
    gl_FragColor = vec4(fract(vUv), 0, 1);
    -
    -

    如果我们使用的纹理坐标超出了0到1的范围,那么问题就在fract那里。 如果 texture.repeat 设置为大于1的值,这种情况很常见。

    -
    - -

    你可以在片段着色器中对所有的值做类似的事情。弄清楚它们的范围可能是什么,添加一些代码来设置 gl _ fragcolor,该范围可从0.0到1.0。

    -

    若要检查纹理,请尝试使用你知道有效的CanvasTexture 或 DataTexture。

    -

    相反,如果设置 gl _ fragcolor 为红色后,我仍然看不到任何东西,那么我有一个提示,我的问题可能是在与顶点着色器相关的东西的方向上。某些矩阵可能是错误的,或者我的属性可能有错误的数据,或者设置不正确。

    -

    我会先看一下矩阵。我可能会在调用 renderer.render(scene, camera)之后立即设置一个断点,然后开始在检查面板中展开内容。 -相机的world matrix和投影矩阵是不是NaN?扩展场景并观察它的children,我会检查world matrix看起来是否合理(没有 NaN) , -每个矩阵的最后4个值对于我的场景来说是否合理。如果我期望我的场景是50x50x50单位,而一些矩阵显示552352623.123显然有问题。

    -
    - -

    就像我们为片段着色器所做的一样,我们也可以通过将顶点着色器的值传递给片段着色器来绘制这些值。 -在它们上都定义一个变量,并传递那个你也不知道正确与否的值。实际上如果我的着色器使用正在被使用的常量, -我会像上面提到的那样改变片段着色器来显示它们。然后将 vNormal 设置为我想要显示的值,但是将其缩小, -这样值就在0.0~1.0的范围内。 -然后我看看结果,看看它们是否符合我的期望。

    -

    另一个好办法是使用更简单的着色器。你能用 MeshBasicMaterial 绘制你的数据吗? -如果你可以,尝试它,并确保它显示如预期。

    -

    如果顶点着色器没有简单到让你可视化你的结构,通常先和下面这个一样简单

    -
    gl_Position = projection * modelView * vec4(position.xyz, 1);
    -
    -

    如果这样做有效,那就每次都添加一小点改变。

    -

    你也可以使用Shader Editor extension for Chrome或者在其他浏览器中使用类似的工具。 -这是一个观察其他着色器如何工作的好方法。改动一些代码来实时查看变化也是一个非常好的办法。

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/debugging-javascript.html b/manual/zh/debugging-javascript.html deleted file mode 100644 index b36a430e498b51..00000000000000 --- a/manual/zh/debugging-javascript.html +++ /dev/null @@ -1,530 +0,0 @@ - - - - - - 调试 JavaScript - - - - - - - - - - - - - - -
    -
    -

    调试 JavaScript

    -
    -
    -
    -

    - 总的来说这篇文章的大部分是关于如何调试JavaScript的,而不是直接关于THREE.js的。这似乎很重要,因为很多刚开始学习THREE.js的人同时也刚开始学JavaScript,所以我希望这可以帮助他们更轻松地解决遇到的任何问题。 -

    -

    调试是一个大话题,我可能无法介绍出所有需要了解的内容,但如果您是JavaScript新手,我在这里会尝试给您一些建议。我强烈建议你花点时间来学习它们。它们会对你的学习有很大帮助。

    -

    了解你的浏览器开发者工具

    -

    所有的浏览器都有开发者工具。 - Chrome, - Firefox, - Safari, - Edge. -

    -

    在Chrome浏览器中你可以点击标志, 选择 More - Tools->Developer Tools - 来打开开发者工具。快捷键也显示在了上面。

    -
    - -

    在Firefox中你可以点击 标志, 选择 "Web Developer", 接着选择 - "Toggle Tools"

    -
    - -

    在Safari中你首先要从Advanced Safari Preferences中打开 Develop menu。

    -
    - -

    接着在Develop菜单你可以选择"Show/Connect Web Inspector"。

    -
    - -

    在Chrome中你也可以使用电脑端的开发者工具来调试运行在安卓手机或者平板的chrome浏览器上的网页. - 同样的在Safari中你可以 - 使用电脑端调试iPhones和iPads的Safari上的网页. -

    -

    我对Chrome最为熟悉,因此本指南在提到开发者工具时将以Chrome为例,但大多数浏览器都有类似的功能,因此应该很容易将此处的任何内容应用于所有浏览器。

    -

    关闭缓存

    -

    浏览器试图重用他们已经下载的数据。这对用户来说非常好,因此,如果您再次访问网站,许多用于显示网站的文件将不会被再次下载。

    -

    但是这可能不利于web开发。如果您改变了加载的资源,并重新加载了页面,但由于浏览器使用了上次缓存的版本,因此可能会看不到变动。

    -

    在开发过程中一个解决方案是关闭缓存。这样,浏览器将始终获取文件的最新版本。

    -

    首先在右上角点击设置按钮

    -
    - -

    接着选择"Disable Cache (while DevTools is open)".

    -
    - -

    使用JavaScript控制台

    -

    在所有开发者工具中都有 控制台。 它显示了警告和错误信息。

    -

    读这些信息!!

    -

    通常应该只有 1 或 2 条信息。

    -
    - -

    如果你看到有其他的消息,请务必 读它们。 例如:

    -
    - -

    我把 "three" 错拼成了 "threee"

    -

    通过console.log 方法,你也可以打印你自己信息到控制台,比如

    -
    console.log(someObject.position.x, someObject.position.y, someObject.position.z);
    -
    -

    更酷的是,如果你打印了一个object你可以检查它。 例如,如果从加载gLTF这篇文章中打印了一个根场景object

    -
      {
    -  const gltfLoader = new GLTFLoader();
    -  gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
    -    const root = gltf.scene;
    -    scene.add(root);
    -+      console.log(root);
    -
    -

    我们接着就可以在JavaScript控制台中展开那个object

    -
    -
    - -

    你也可以使用 console.error 来输出错误信息,该方法会打印一条红色的信息并附上错误栈

    -

    在屏幕上显示数据

    -

    另一个直观的但是经常被忽视的方式是通过添加<div><pre> 标签 - 并将要显示的数据放在标签内。

    -

    最简单的方法就是写一些HTML元素

    -
    <canvas id="c"></canvas>
    -+<div id="debug">
    -+  <div>x:<span id="x"></span></div>
    -+  <div>y:<span id="y"></span></div>
    -+  <div>z:<span id="z"></span></div>
    -+</div>
    -
    -

    给它们添加些样式让它们显示在canvas元素之上(假设你的canvas元素充满了整个页面)。

    -
    <style>
    -#debug {
    -position: absolute;
    -left: 1em;
    -top: 1em;
    -padding: 1em;
    -background: rgba(0, 0, 0, 0.8);
    -color: white;
    -font-family: monospace;
    -}
    -</style>
    -
    -

    然后查找元素并设置其内容。

    -
    // at init time
    -const xElem = document.querySelector('#x');
    -const yElem = document.querySelector('#y');
    -const zElem = document.querySelector('#z');
    -
    -// at render or update time
    -xElem.textContent = someObject.position.x.toFixed(3);
    -yElem.textContent = someObject.position.y.toFixed(3);
    -zElem.textContent = someObject.position.z.toFixed(3);
    -
    -

    这在展示实时数据时更有用

    -

    - - -

    -

    - 将数据显示在屏幕上的另一种方法是制作一个”清除记录器“。这是我编的一个术语,我参与的很多游戏中都使用了这个方法。其思想是您有一个只显示一帧消息的缓冲区,有显示数据需求的时候,就会调用一些函数向该缓冲区添加每一帧的数据。相对于上一种方法为每个数据都添加一个html元素,这种方法会减少更多工作量。 -

    -

    举个例子,我们把上面的html代码调整为这样

    -
    <canvas id="c"></canvas>
    -<div id="debug">
    -<pre></pre>
    -</div>
    -
    -

    接着我们只做一个简单的类来管理这些 “clear back buffer”

    -
    class ClearingLogger {
    -constructor(elem) {
    -  this.elem = elem;
    -  this.lines = [];
    -}
    -log(...args) {
    -  this.lines.push([...args].join(' '));
    -}
    -render() {
    -  this.elem.textContent = this.lines.join('\n');
    -  this.lines = [];
    -}
    -}
    -
    -

    接着我们做一个简单的例子:每次我们点击鼠标后,就会产生一个出现2秒钟的物体,并朝着一个随机的方向移动。我们将从一篇文章中的一个示例开始 making - things - responsive

    -

    下面是每次点击鼠标都新增一个Mesh - 的代码

    -
    const geometry = new THREE.SphereGeometry();
    -const material = new THREE.MeshBasicMaterial({color: 'red'});
    -
    -const things = [];
    -
    -function rand(min, max) {
    -if (max === undefined) {
    -  max = min;
    -  min = 0;
    -}
    -return Math.random() * (max - min) + min;
    -}
    -
    -function createThing() {
    -const mesh = new THREE.Mesh(geometry, material);
    -scene.add(mesh);
    -things.push({
    -  mesh,
    -  timer: 2,
    -  velocity: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5)),
    -});
    -}
    -
    -canvas.addEventListener('click', createThing);
    -
    -

    接着下面的代码描述了如何移动我们创造出来的mesh,记录它们的信息以及当它们的计时器到期的时候删除它们

    -
    const logger = new ClearingLogger(document.querySelector('#debug pre'));
    -
    -let then = 0;
    -function render(now) {
    -now *= 0.001;  // convert to seconds
    -const deltaTime = now - then;
    -then = now;
    -
    -...
    -
    -logger.log('fps:', (1 / deltaTime).toFixed(1));
    -logger.log('num things:', things.length);
    -for (let i = 0; i < things.length;) {
    -  const thing = things[i];
    -  const mesh = thing.mesh;
    -  const pos = mesh.position;
    -  logger.log(
    -      'timer:', thing.timer.toFixed(3),
    -      'pos:', pos.x.toFixed(3), pos.y.toFixed(3), pos.z.toFixed(3));
    -  thing.timer -= deltaTime;
    -  if (thing.timer <= 0) {
    -    // remove this thing. Note we don't advance `i`
    -    things.splice(i, 1);
    -    scene.remove(mesh);
    -  } else {
    -    mesh.position.addScaledVector(thing.velocity, deltaTime);
    -    ++i;
    -  }
    -}
    -
    -renderer.render(scene, camera);
    -logger.render();
    -
    -requestAnimationFrame(render);
    -}
    -
    -

    现在在下面的例子中点击几次鼠标

    -

    - - -

    -

    查询参数

    -

    另一件需要记住的事情是,网页可以通过查询参数或锚(有时称为搜索和散列)将数据传递到网页中。

    -
    https://domain/path/?query#anchor
    -
    -

    可以使用这个参数使一些功能开启或关闭或传入参数。

    -

    以上一个例子为例,我们可以做到只有向URL中传入?debug=true参数的时候,上面的例子才会显示调试数据

    -

    首先我们需要一些代码来解析携带查询参数字符串

    -
    /**
    -* Returns the query parameters as a key/value object.
    -* Example: If the query parameters are
    -*
    -*    abc=123&def=456&name=gman
    -*
    -* Then `getQuery()` will return an object like
    -*
    -*    {
    -*      abc: '123',
    -*      def: '456',
    -*      name: 'gman',
    -*    }
    -*/
    -function getQuery() {
    -return Object.fromEntries(new URLSearchParams(window.location.search).entries());
    -}
    -
    -

    接着我们可能想让显示调试信息的元素在默认情况下不出现

    -
    <canvas id="c"></canvas>
    -+<div id="debug" style="display: none;">
    -<pre></pre>
    -</div>
    -
    -

    接着在代码中我们通过获取特定参数来决定当且仅当?debug=true传入的时候才显示我们的调试信息

    - -
    const query = getQuery();
    -const debug = query.debug === 'true';
    -const logger = debug
    - ? new ClearingLogger(document.querySelector('#debug pre'))
    - : new DummyLogger();
    -if (debug) {
    -document.querySelector('#debug').style.display = '';
    -}
    -
    -

    同时我们也写了一个什么事情也不做的DummyLogger,并在?debug=true 没有被传入的时候使用它。

    -
    class DummyLogger {
    -log() {}
    -render() {}
    -}
    -
    -

    你可以看到在这个URL里

    -

    debug-js-params.html

    -

    没有调试信息。但是当我们使用这个url的时候:

    -

    debug-js-params.html?debug=true

    -

    就包含了调试信息

    -

    多个参数传递的时候需要以'&'分隔。例如somepage.html?someparam=somevalue&someotherparam=someothervalue. - 使用这样的参数我们就可以传入各种各样的选项。例如传入speed=0.01 - 以降低app速度来更好的理解某个过程。或者传入 - showHelpers=true - 来决定是否添加一个helper来辅助显示其他课程中提到过的光线、阴影或者相机的视锥 -

    - -

    学习使用调试器

    -

    每个浏览器都有一个调试器,您可以在其中逐行暂停程序并检查所有变量。

    -

    教您如何使用调试器对于本文来说是一个太大的主题,但这里有几个链接

    - -

    在调试器或其他地方检查NaN

    -

    NaN 不是数字的缩写。当你做了一些不符合数学的事情时,JavaScript会把它指定为一个值。

    -

    举一个简单的例子

    -
    -
    - -

    通常,当我在制作一些东西时,屏幕上什么也没有出现,我会检查一些值,如果我看到NaN - 我会立即知道问题所在

    -

    举一个例子,当我第一次在 - article about loading gLTF files 文章中创建路径的时候,我使用SplineCurve ——一个专门制作2D曲线的类,做了一条曲线。 -

    -

    接着我使用这条曲线使汽车像这样移动

    -
    curve.getPointAt(zeroToOnePointOnCurve, car.position);
    -
    -

    在内部 curve.getPointAt 方法将调用传入的第二个参数对象上的 set 函数。在例子中第二个变量是car.position也就是一个Vector3类型。 Vector3的 - set 函数需要三个参数, x, y, 和 z 但是 SplineCurve 是一个2D曲线,因此它仅向car.position.set方法传入了x和y值。 -

    -

    结果car.position.set 方法将x设置为了x,y设置成了y,z设置成了undefined.

    -

    在调试器中简单查看一下汽车的matrixWorld - 就会发现许多的 NaN 值。

    -
    - -

    看到矩阵中包含NaN值就表明一些像 - position, - rotation, scale 或者其他的会影响矩阵的函数就有坏数据。根据这一点来找bug就会很容易发现问题所在。 -

    -

    除了NaN 之外还有Infinity,也能表明一些地方有了数学bug

    -

    快来看看代码!

    -

    THREE.js是一个开源的库。别害怕深入库内部查看代码。 - 你可以在github里查看它的源代码。 - 您还可以通过在调试器中单步执行函数来查看内部。

    -

    requestAnimationFrame 放着在渲染函数的底部。

    -

    我经常看到如下的代码

    -
    function render() {
    - requestAnimationFrame(render);
    -
    - // -- do stuff --
    -
    - renderer.render(scene, camera);
    -}
    -requestAnimationFrame(render);
    -
    -

    我建议将requestAnimationFrame 的调用放在底部,如下所示

    -
    function render() {
    - // -- do stuff --
    -
    - renderer.render(scene, camera);
    -
    - requestAnimationFrame(render);
    -}
    -requestAnimationFrame(render);
    -
    -

    最大的原因是,在正常情况下,如果出现错误,代码执行将停止。但将requestAnimationFrame - 置于顶部意味着您的代码将继续运行,因为您已经请求了另一帧。在我看来,发现这些错误比忽略它们要好。它们很容易成为某些东西没有按预期出现的原因,但除非代码停止,否则您可能根本不会注意到。 -

    -

    检查你使用的单位

    -

    这基本上意味着知道何时使用度(degrees)和何时使用弧度(radians)。不幸的是THREE.js并不是在所有地方都使用相同的单位。 - 现在我能想到的情况就是摄像机的视野是以degrees为单位的。所有其他角度均以radians为单位。

    - -

    - 另一个值得注意的地方是你所使用的长度单位的大小。目前3D应用程序还可以选择他们想要的任何单位大小。 - 一个应用程序可能会选择1个单位=1厘米。另一个可能选择1个单位=1英尺。事实上,你可以为某些应用选择任何你想要的单位。 - 也就是说,THREE.js假设1个单位=1米。这对于基于物理的渲染(PBR)——使用米来计算照明效果之类的事情很重要。这对于AR和VR也很重要,因为它们需要处理真实世界中的长度,如手机所在的位置或VR控制器所在的位置。 -

    -

    - 在Stack Overflow上制作一个 - 最小的、完整的、可验证的 示例 -

    - -

    如果你决定问一个关于THREE.js的问题,你需要提供一个MCVE,即最小的(Minimal)、完整的(Complete)、可验证的(Verifiable)示例。

    - -

    最小的(Minimal) 是很重要的。 比如说在上一个例子loading a gLTF - article中你有一个关于运动轨迹的例子。那个例子有很多部分,比如 -

    -
      -
    1. 一些HTML
    2. -
    3. 一些 CSS
    4. -
    5. 光源
    6. -
    7. 阴影
    8. -
    9. 操作阴影的lil-gui 代码
    10. -
    11. 加载.GLTF文件的代码
    12. -
    13. 调整canvas大小的代码
    14. -
    15. 让汽车沿着路线移动的代码
    16. -
    -

    看起来真的很多。如果你的问题仅仅是关于路线跟随的部分,你可以将大部分HTML代码删除,因为你只需要一个<canvas> - 和一个<script>标记就可以了。你可以删除CSS和调整大小的代码,也可以删除.GLTF代码,因为你只关心路径。 - 你可以使用MeshBasicMaterial删除灯光和阴影。你当然也可以删除lil gui代码。代码中纹理创建地平面,如果使用GridHelper.会更容易。 - 最后,如果我们的问题是关于在路径上移动物体,我们可以在路径上使用立方体,而不是装载的汽车模型。 -

    -

    - 这里有一个更简单的例子,考虑了以上所有因素。它从271行缩减到135行。我们甚至可以考虑通过简化路径来进一步缩小它。也许一条有3点或4点的路径和我们有21点的路径一样有效。 -

    -

    - - -

    -

    我保留了 OrbitController 因为它是很有用的——别人可以通过移动相机 - 搞清楚发生了什么。但是根据实际情况你也可以移除它。 -

    -

    - 认真写一个MCVE所带来的好处就是我们经常能自己解决问题。删除所有不需要的内容,并尽我们所能制作最小的示例来重现问题的过程往往会使得错误原因的展现。 -

    -

    - 最重要的是,它尊重了查看你问题代码的其他人的时间。通过做一个简单的例子,你就可以让他们更容易地帮助你。你也将在这个过程中学习。 -

    -

    同样重要的是,当你去到Stack Overflow 提交问题的时候 将你的代码放在代码片段.里 - 你当然可以使用JSFiddle 或者 Codepen 或者相似的第三方网站去测试你的MCVE,但是一旦你在Stack - Overflow上面发布问题,你就需要把代码放在问题本身中重现你的问题。 -

    -

    - 还请注意,此站点上的所有实时示例都应作为片段运行。只需将HTML、CSS和JavaScript部分复制到 - 代码段编辑器 - 各自的部分。请记住,尽量删除与您的问题无关的部分,并尽量使您的代码达到所需的最小数量。 -

    -

    遵循这些建议你就可能在解决问题上得到帮助

    -

    使用MeshBasicMaterial

    -

    由于MeshBasicMaterial不使用光照,这样做就不会产生一些物体不显示的问题了。如果你的物体在使用MeshBasicMaterial - 的时候显示,但是在使用其他任何材料时候不显示,那么你便知道问题出在材料或者光照上而不是其他地方的代码。 -

    -

    检查你相机的 nearfar 设置 -

    -

    一个 PerspectiveCameranear 和 - far 设置——在 - article on cameras这篇文章中讲过。保证它的值的设置所能观察到的空间范围能够包裹你渲染的物体 - 甚至可以暂时性的设置为 near = 0.001 以及 far = 1000000。你可能遇到深度层级分辨率问题但是你至少可以看到你的物体——前提是它在你的相机前。 -

    -

    检查相机前的场景

    -

    有时候一些东西不会出现因为它们不在相机前。 如果你的相机是不可控制的,尝试添加一个相机控制器比如 - OrbitController,这样你就可以看到周围的景象并找到你的场景。 - 或者,尝试使用这篇文章中介绍的代码来设置场景的帧。该代码查找场景部分的大小,然后移动摄影机并调整nearfar设置以使其可见。然后可以查看调试器或添加一些 console.log 消息以打印场景的大小和中心。 -

    -

    在相机前放一些东西

    -

    - 这只是另一种说法,如果所有其他方法都失败了,那么就从一些有效的方法开始,然后慢慢地再添加一些东西。 - 如果你得到一个屏幕上没有任何东西,那么试着把一些东西直接放在相机前面。制作一个球体或长方体,给它一个简单的材质,比如 MeshBasicMaterial , - 确保你可以在屏幕上看到它。然后可以一次添加一些东西,然后再测试它。最终,你要么复现出你的bug,要么在途中发现它。 -

    -
    -

    以上这些就是调试JavaScript的一些建议。让我们开始浏览调试GLSL的一些建议吧。

    - -
    -
    -
    - - - - - - - - - - \ No newline at end of file diff --git a/manual/zh/drawing-lines.html b/manual/zh/drawing-lines.html deleted file mode 100644 index 5e9ce0ddd29a71..00000000000000 --- a/manual/zh/drawing-lines.html +++ /dev/null @@ -1,91 +0,0 @@ - - - 绘制线条 - - - - - - - - - - - - - -
    -
    -

    绘制线条

    -
    -
    -
    - -

    - 假设你想绘制一条线或一个圆,而不是线框 `Mesh`。 - 首先我们需要设置渲染器、场景和相机(参见"创建场景"页面)。 -

    - -

    以下是我们将使用的代码:

    -
    -const renderer = new THREE.WebGLRenderer();
    -renderer.setSize( window.innerWidth, window.innerHeight );
    -document.body.appendChild( renderer.domElement );
    -
    -const camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 1, 500 );
    -camera.position.set( 0, 0, 100 );
    -camera.lookAt( 0, 0, 0 );
    -
    -const scene = new THREE.Scene();
    -
    -

    接下来我们需要定义一个材质。对于线条,需要使用 `LineBasicMaterial` 或 `LineDashedMaterial`。

    -
    -// 创建一个蓝色的 LineBasicMaterial
    -const material = new THREE.LineBasicMaterial( { color: 0x0000ff } );
    -
    - -

    - 有了材质之后,我们还需要一个带有顶点的几何体: -

    - -
    -const points = [];
    -points.push( new THREE.Vector3( - 10, 0, 0 ) );
    -points.push( new THREE.Vector3( 0, 10, 0 ) );
    -points.push( new THREE.Vector3( 10, 0, 0 ) );
    -
    -const geometry = new THREE.BufferGeometry().setFromPoints( points );
    -
    - -

    注意,线条是在每对相邻顶点之间绘制的,而不会连接首尾两个顶点(即线条不是闭合的)。

    - -

    现在我们有了两条线段的顶点和一个材质,可以将它们组合成一条线:

    -
    -const line = new THREE.Line( geometry, material );
    -
    -

    剩下的就是将其添加到场景中并调用 `renderer.render()`。

    - -
    -scene.add( line );
    -renderer.render( scene, camera );
    -
    - -

    现在你应该能看到一个由两条蓝色线段组成的向上箭头。

    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/faq.html b/manual/zh/faq.html deleted file mode 100644 index e267754b6bb67c..00000000000000 --- a/manual/zh/faq.html +++ /dev/null @@ -1,93 +0,0 @@ - - - 常见问题 - - - - - - - - - - - - - -
    -
    -

    常见问题

    -
    -
    -
    - -

    哪种 3D 模型格式的支持最完善?

    -
    -

    - 推荐使用 glTF(GL Transmission Format)格式来导入和导出资源。由于 glTF 专注于运行时资源传输,它体积紧凑、加载速度快。 -

    -

    - three.js 也提供了许多其他常见格式的加载器,如 FBX、Collada 和 OBJ 等。尽管如此,你应该始终优先在项目中建立基于 glTF 的工作流。 -

    -
    - -

    为什么示例中有 meta viewport 标签?

    -
    -
    <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
    - -

    这些标签用于控制移动端浏览器的视口大小和缩放比例(在移动端,页面内容的渲染尺寸可能与可见视口不同)。

    - -

    [link:https://developer.apple.com/library/content/documentation/AppleApplications/Reference/SafariWebContent/UsingtheViewport/UsingtheViewport.html Safari: Using the Viewport]

    - -

    [link:https://developer.mozilla.org/zh-CN/docs/Web/HTML/Viewport_meta_tag MDN: 使用 viewport meta 标签]

    -
    - -

    如何在窗口缩放时保持场景比例?

    -

    - 我们希望所有物体无论距离相机多远,在窗口缩放时都保持相同的显示大小。 - - 解决这个问题的关键公式是给定距离下的可见高度: - -

    visible_height = 2 * Math.tan( ( Math.PI / 180 ) * camera.fov / 2 ) * distance_from_camera;
    - 如果我们将窗口高度增加一定百分比,那么我们希望所有距离下的可见高度也增加相同的百分比。 - - 这无法通过改变相机位置来实现,而需要改变相机的视野范围(field-of-view)。 - [link:http://jsfiddle.net/Q4Jpu/ 示例]。 -

    - -

    为什么我的物体有一部分不可见?

    -

    - 这可能是由于面剔除(face culling)导致的。每个面都有一个朝向,决定了哪一侧是正面、哪一侧是背面。默认情况下,剔除会移除背面。 - 要检查是否是这个问题,可以将材质的 side 属性设置为 THREE.DoubleSide。 -

    material.side = THREE.DoubleSide
    -

    - -

    为什么 three.js 对无效输入有时会返回奇怪的结果?

    -

    - 出于性能考虑,three.js 在大多数情况下不会验证输入。确保所有输入有效是你的应用的责任。 -

    - -

    能否在 Node.js 中使用 three.js?

    -

    - 由于 three.js 是为 Web 构建的,它依赖于浏览器和 DOM API,而这些在 Node.js 中并不总是存在。部分问题可以通过使用 - [link:https://github.com/stackgl/headless-gl headless-gl] 和 [link:https://github.com/rstacruz/jsdom-global jsdom-global] 等 shim(兼容层)来解决, - 或者用自定义替代方案替换 `TextureLoader` 等组件。其他 DOM API 可能与使用它们的代码深度耦合,更难处理。我们欢迎简洁且易于维护的 Pull Request 来改善 Node.js 支持,但建议先提交 issue 讨论你的改进方案。 -

    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/fog.html b/manual/zh/fog.html deleted file mode 100644 index d5a92a14d4fac8..00000000000000 --- a/manual/zh/fog.html +++ /dev/null @@ -1,221 +0,0 @@ - - - - - - - - - - - - - - - - - -
    -
    -

    -
    -
    -
    -

    本文是three.js系列文章的一部分。第一篇文章是three.js 基础。如果你是个新手,还没读过,请从那里开始。如果你还没读过有关摄像机的章节,请从这篇文章开始。

    -

    在3D引擎里,雾通常是基于离摄像机的距离褪色至某种特定颜色的方式。在three.js中添加雾是通过创建 Fog 或者 FogExp2 实例并设定scene的fog 属性。

    -

    Fog 让你设定 nearfar 属性,代表距离摄像机的距离。任何物体比 near 近不会受到影响,任何物体比 far 远则完全是雾的颜色。在 nearfar 中间的物体,会从它们自身材料的颜色褪色到雾的颜色。

    -

    FogExp2 会根据离摄像机的距离呈指数增长。

    -

    选择其中一个类型,创建雾并设定到场景中如下:

    -
    const scene = new THREE.Scene();
    -{
    -  const color = 0xFFFFFF;  // white
    -  const near = 10;
    -  const far = 100;
    -  scene.fog = new THREE.Fog(color, near, far);
    -}
    -
    -

    或者对于 FogExp2 会是:

    -
    const scene = new THREE.Scene();
    -{
    -  const color = 0xFFFFFF;
    -  const density = 0.1;
    -  scene.fog = new THREE.FogExp2(color, density);
    -}
    -
    -

    FogExp2 比较接近现实效果,但是 Fog 使用的更加普遍,因为它支持设定影响区域,所以你可以设定一定距离内显示清晰的场景,过了这段距离再褪色到某种颜色。

    -
    -
    -
    -
    THREE.Fog
    -
    -
    -
    -
    THREE.FogExp2
    -
    -
    - -

    需要注意的是雾是作用在 渲染的物体 上的,是物体颜色中每个像素计算的一部分。这意味着如果你想让你的场景褪色到某种颜色,你需要设定雾 场景的背景颜色为同一种颜色。背景颜色通过scene.background属性设置。你可以通过 THREE.Color 选择背景颜色设置。例如:

    -
    scene.background = new THREE.Color('#F00');  // red
    -
    -
    -
    -
    -
    fog blue, background red
    -
    -
    -
    -
    fog blue, background blue
    -
    -
    - -

    这是我们之前添加雾的例子。唯一的改动是在添加雾之后,我们设置了场景的背景颜色。

    -
    const scene = new THREE.Scene();
    -
    -+{
    -+  const near = 1;
    -+  const far = 2;
    -+  const color = 'lightblue';
    -+  scene.fog = new THREE.Fog(color, near, far);
    -+  scene.background = new THREE.Color(color);
    -+}
    -
    -

    在下面的例子,摄像机的 near 是0.1, far 是5,位于 z = 2的位置。方块为单位大小,位于Z=0的位置。这意味着将雾设置为 near = 1far = 2 ,方块会在它的中间位置淡出。

    -

    - -

    -

    让我们添加界面来调整雾。我们将再次使用lil-gui。lil-gui接收对象和属性参数,并自动为其创建界面。我们能够简单地操纵雾的 nearfar 属性,但是 near 数值大于 far 是无效的,所以我们创建助手(helper)来确保 nearfar 属性,让 near 小于或等于 farfar 大于或等于 near

    -
    // We use this class to pass to lil-gui
    -// so when it manipulates near or far
    -// near is never > far and far is never < near
    -class FogGUIHelper {
    -  constructor(fog) {
    -    this.fog = fog;
    -  }
    -  get near() {
    -    return this.fog.near;
    -  }
    -  set near(v) {
    -    this.fog.near = v;
    -    this.fog.far = Math.max(this.fog.far, v);
    -  }
    -  get far() {
    -    return this.fog.far;
    -  }
    -  set far(v) {
    -    this.fog.far = v;
    -    this.fog.near = Math.min(this.fog.near, v);
    -  }
    -}
    -
    -

    之后我们可以像这样添加

    -
    {
    -  const near = 1;
    -  const far = 2;
    -  const color = 'lightblue';
    -  scene.fog = new THREE.Fog(color, near, far);
    -  scene.background = new THREE.Color(color);
    -+
    -+  const fogGUIHelper = new FogGUIHelper(scene.fog);
    -+  gui.add(fogGUIHelper, 'near', near, far).listen();
    -+  gui.add(fogGUIHelper, 'far', near, far).listen();
    -}
    -
    -

    当我们设置摄像机的时候,设置(助手的 nearfar 以调节雾的最小值和最大值。

    -

    最后两行调用 .listen() 告诉lil-gui 监听 变化。当我们编辑 far 改变了 near 或者编辑 near 改变了 far ,lil-gui将会为我们更新其他属性的UI。

    -

    或许能够改变雾的颜色是个不错的主意,但是如上面提到的,我们需要保持雾的颜色和背景颜色一致。所以,让我们在助手上添加另一个 虚拟 属性,当lil-gui改变它时会设置这两个颜色。

    -

    lil-gui能够通过4种方式设置颜色。分别是6位hex字符串 (如: #112233),色相、饱和度、明度的对象 (如: {h: 60, s: 1, v: }),RGB数组 (如: [255, 128, 64]),或者RGBA数组 (如: [127, 200, 75, 0.3])。

    -

    对于我们的目的而言,最简单的是用hex字符串的方式,因为lil-gui只修改单个数值。幸运的是通过 THREE.ColorgetHexString 方法我们能轻松地获得这个字符串,只需要在其前面添加 '#' 。

    -
    // We use this class to pass to lil-gui
    -// so when it manipulates near or far
    -// near is never > far and far is never < near
    -+// Also when lil-gui manipulates color we'll
    -+// update both the fog and background colors.
    -class FogGUIHelper {
    -*  constructor(fog, backgroundColor) {
    -    this.fog = fog;
    -+    this.backgroundColor = backgroundColor;
    -  }
    -  get near() {
    -    return this.fog.near;
    -  }
    -  set near(v) {
    -    this.fog.near = v;
    -    this.fog.far = Math.max(this.fog.far, v);
    -  }
    -  get far() {
    -    return this.fog.far;
    -  }
    -  set far(v) {
    -    this.fog.far = v;
    -    this.fog.near = Math.min(this.fog.near, v);
    -  }
    -+  get color() {
    -+    return `#${this.fog.color.getHexString()}`;
    -+  }
    -+  set color(hexString) {
    -+    this.fog.color.set(hexString);
    -+    this.backgroundColor.set(hexString);
    -+  }
    -}
    -
    -

    然后我们调用 gui.addColor 来为我们的助手虚拟属性添加颜色界面。

    -
    {
    -  const near = 1;
    -  const far = 2;
    -  const color = 'lightblue';
    -  scene.fog = new THREE.Fog(color, near, far);
    -  scene.background = new THREE.Color(color);
    -
    -*  const fogGUIHelper = new FogGUIHelper(scene.fog, scene.background);
    -  gui.add(fogGUIHelper, 'near', near, far).listen();
    -  gui.add(fogGUIHelper, 'far', near, far).listen();
    -+  gui.addColor(fogGUIHelper, 'color');
    -}
    -
    -

    - -

    -

    你可以观察到,设置 near 如1.9, far 为2.0能在未雾化和完全雾化之间获得锐利的变化效果,而设置 near = 1.1, far = 2.9 会让我们旋转的方块在距离摄像机2个单位距离的位置获得最平滑的变化效果。

    -

    最后, fog 在材料上有个布尔属性,用来设置渲染物体的材料是否会受到雾的影响。对于大多数材料而言默认设置为 true ,作为你可能想关掉雾生效的例子,设想下你正在制作一个3D车辆模拟器并处于驾驶员座位或座舱的视角,你很可能为了看清车内的物体将它们的是否受雾影响属性关闭。

    -

    一个更好的例子会是一个外面弥漫浓雾的房子。让我们假设将雾设置在2米外 (near = 2) 并且在4米的地方完全进入雾中 (far = 4)。房间大于2米并且很可能大于4米,那么你需要将房子内的材质设置为不受雾的影响,否则当站在房子内尽头往墙壁外看会觉得房子是在雾里。

    -
    -
    -
    -
    fog: true, all
    -
    -
    - -

    注意房间尽头的墙壁和天花板正受到雾的影响,当我们把房子材料上的是否受雾影响属性关闭可以解决这个问题。

    -
    -
    -
    -
    fog: true, only outside materials
    -
    -
    - -

    - - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/fundamentals.html b/manual/zh/fundamentals.html deleted file mode 100644 index ddc50bbe05b7b0..00000000000000 --- a/manual/zh/fundamentals.html +++ /dev/null @@ -1,319 +0,0 @@ - - - 基础 - - - - - - - - - - - - - - -
    -
    -

    基础

    -
    -
    -
    -

    这是Three.js系列文章的第一篇。 -Three.js是一个尽可能简化在网页端获取3D -内容的库。

    -

    Three.js经常会和WebGL混淆, -但也并不总是,three.js其实是使用WebGL来绘制三维效果的。 -WebGL是一个只能画点、线和三角形的非常底层的系统. -想要用WebGL来做一些实用的东西通常需要大量的代码, -这就是Three.js的用武之地。它封装了诸如场景、灯光、阴影、材质、贴图、空间运算等一系列功能,让你不必要再从底层WebGL开始写起。

    -

    这套教程假设你已经了解了JavaScript,且大部分内容会使用 -ES6的语法。点击这里查看你需要提前掌握的东西。 -大部分支持Three.js的浏览器都会自动更新,所以绝大多数用户应该都能运行本套教程的代码。 -如果你想在非常老的浏览器上运行此代码, -你需要一个像Babel一样的语法编译器 。 -当然使用非常老的浏览器的用户可能根本不能运行Three.js。

    -

    人们在学习大多数编程语言的时候第一件事就是让电脑打印个"Hello World!"。 -对于三维来说第一件事往往是创建一个三维的立方体。 -所以我们从"Hello Cube!"开始。

    -

    在我们开始前,让我们试着让你了解一下一个three.js应用的整体结构。一个three.js应用需要创建很多对象,并且将他们关联在一起。下图是一个基础的three.js应用结构。

    -
    - -

    上图需要注意的事项:

    - -

    有了以上基本概念,我们接下来就来画个下图所示的"Hello Cube"吧。

    -
    - -

    首先是加载three.js

    -
    <script type="module">
    -import * as THREE from 'three';
    -</script>
    -
    -

    type="module"放到script标签中很重要。这可以让我们使用import关键字加载three.js。还有其他的方法可以加载three.js,但是自r106开始,使用模块是最推荐的方式。模块的优点是可以很方便地导入需要的其他模块。这样我们就不用再手动引入它们所依赖的其他文件了。

    -

    下一步我们需要一个<canvas>标签。

    -
    <body>
    -  <canvas id="c"></canvas>
    -</body>
    -
    -

    Three.js需要使用这个canvas标签来绘制,所以我们要先获取它然后传给three.js。

    -
    <script type="module">
    -import * as THREE from 'three';
    -
    -+function main() {
    -+  const canvas = document.querySelector('#c');
    -+  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -+  ...
    -</script>
    -
    -

    拿到canvas后我们需要创建一个WebGL渲染器(WebGLRenderer)。渲染器负责将你提供的所有数据渲染绘制到canvas上。

    -

    注意这里有一些细节。如果你没有给three.js传canvas,three.js会自己创建一个 ,但是你必须手动把它添加到文档中。在哪里添加可能会不一样这取决你怎么使用, 我发现给three.js传一个canvas会更灵活一些。我可以将canvas放到任何地方, 代码都会找到它,假如我有一段代码是将canvas插入到文档中,那么当需求变化时, 我很可能必须去修改这段代码。

    -

    接下来我们需要一个透视摄像机(PerspectiveCamera)

    -
    const fov = 75;
    -const aspect = 2;  // 相机默认值
    -const near = 0.1;
    -const far = 5;
    -const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -
    -

    fov是视野范围(field of view)的缩写。上述代码中是指垂直方向为75度。 -注意three.js中大多数的角用弧度表示,但是因为某些原因透视摄像机使用角度表示。

    -

    aspect指画布的宽高比。我们将在别的文章详细讨论,在默认情况下 -画布是300x150像素,所以宽高比为300/150或者说2。

    -

    nearfar代表近平面和远平面,它们限制了摄像机面朝方向的可绘区域。 -任何距离小于或超过这个范围的物体都将被裁剪掉(不绘制)。

    -

    这四个参数定义了一个 "视椎(frustum)"视椎(frustum)是指一个像被削去顶部的金字塔形状。换句话说,可以把"视椎(frustum)"想象成其他三维形状如球体、立方体、棱柱体、截椎体。

    -

    -

    近平面和远平面的高度由视野范围决定,宽度由视野范围和宽高比决定。

    -

    视椎体内部的物体将被绘制,视椎体外的东西将不会被绘制。

    -

    摄像机默认指向Z轴负方向,上方向朝向Y轴正方向。我们将会把立方体放置在坐标原点,所以我们需要往后移一下摄像机才能显示出物体。

    -
    camera.position.z = 2;
    -
    -

    下图是我们想要达到的效果。

    -

    -

    我们能看到摄像机的位置在z = 2。它朝向Z轴负方向。我们的视椎体范围从摄像机前方0.1到5。因为这张图是俯视图,视野范围会受到宽高比的影响。画布的宽度是高度的两倍,所以水平视角会比我们设置的垂直视角75度要大。

    -

    然后我们创建一个场景(Scene)场景(Scene)是three.js的基本的组成部分。需要three.js绘制的东西都需要加入到scene中。 我们将会在场景是如何工作的一文中详细讨论。

    -
    const scene = new THREE.Scene();
    -
    -

    然后创建一个包含盒子信息的立方几何体(BoxGeometry)。几乎所有希望在three.js中显示的物体都需要一个包含了组成三维物体的顶点信息的几何体。

    -
    const boxWidth = 1;
    -const boxHeight = 1;
    -const boxDepth = 1;
    -const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    -
    -

    然后创建一个基本的材质并设置它的颜色. 颜色的值可以用css方式和十六进制来表示。

    -
    const material = new THREE.MeshBasicMaterial({color: 0x44aa88});
    -
    -

    再创建一个网格(Mesh)对象,它包含了:

    -
      -
    1. 几何体(Geometry)(物体的形状)
    2. -
    3. 材质(Material)(如何绘制物体,光滑还是平整,什么颜色,什么贴图等等)
    4. -
    5. 对象在场景中相对于他父对象的位置、朝向、和缩放。下面的代码中父对象即为场景对象。
    6. -
    -
    const cube = new THREE.Mesh(geometry, material);
    -
    -

    最后我们将网格添加到场景中。

    -
    scene.add(cube);
    -
    -

    之后将场景和摄像机传递给渲染器来渲染出整个场景。

    -
    renderer.render(scene, camera);
    -
    -

    这里有一个实例。

    -

    - -

    -

    很难看出来这是一个三维的立方体,因为我们直视Z轴的负方向并且立方体和坐标轴是对齐的,所以我们只能看到一个面。

    -

    我们来让立方体旋转起来,以便更好的在三维环境中显示。为了让它动起来我们需要用到一个渲染循环函数 -requestAnimationFrame.

    -

    代码如下:

    -
    function render(time) {
    -  time *= 0.001;  // 将时间单位变为秒
    -
    -  cube.rotation.x = time;
    -  cube.rotation.y = time;
    -
    -  renderer.render(scene, camera);
    -
    -  requestAnimationFrame(render);
    -}
    -requestAnimationFrame(render);
    -
    -

    requestAnimationFrame函数会告诉浏览器你需要显示动画。传入一个函数作为回调函数。本例中的函数是render函数。如果你更新了跟页面显示有关的任何东西,浏览器会调用你传入的函数来重新渲染页面。我们这里是调用three.js的renderer.render函数来绘制我们的场景。

    -

    requestAnimationFrame会将页面开始加载到函数运行所经历的时间当作入参传给回调函数,单位是毫秒数。但我觉得用秒会更简单所以我将它转换成了秒。

    -

    然后我们把立方体的X轴和Y轴方向的旋转角度设置成这个时间。这些旋转角度是弧度制。一圈的弧度为2Π所以我们的立方体在每个方向旋转一周的时间为6.28秒。

    -

    最后渲染我们的场景并调用另一个帧动画函数来继续我们的循环。

    -

    回调函数之外在主进程中我们调用一次requestAnimationFrame来开始整个渲染循环。

    -

    - -

    -

    效果好了一些但还是很难看出是三维的。我们来添加些光照效果,应该会有点帮助。three.js中有很多种类型的灯光,我们将在后期文章中详细讨论。现在我们先创建一盏平行光。

    -
    {
    -  const color = 0xFFFFFF;
    -  const intensity = 3;
    -  const light = new THREE.DirectionalLight(color, intensity);
    -  light.position.set(-1, 2, 4);
    -  scene.add(light);
    -}
    -
    -

    平行光有一个位置和目标点。默认值都为(0, 0, 0)。我们这里 -将灯光的位置设为(-1, 2, 4),让它位于摄像机前面稍微左上方一点的地方。目标点还是(0, 0, 0),让它朝向坐标原点方向。

    -

    我们还需要改变下立方体的材质。MeshBasicMaterial材质不会受到灯光的影响。我们将他改成会受灯光影响的MeshPhongMaterial材质。

    -
    -const material = new THREE.MeshBasicMaterial({color: 0x44aa88});  // 绿蓝色
    -+const material = new THREE.MeshPhongMaterial({color: 0x44aa88});  // 绿蓝色
    -
    -

    这是我们新的项目结构

    -
    - -

    下面开始生效了。

    -

    - -

    -

    现在应该可以很清楚的看出是三维立方体了。

    -

    我们再添加两个立方体来增添点趣味性。

    -

    每个立方体会引用同一个几何体和不同的材质,这样每个立方体将会是不同的颜色。

    -

    首先我们创建一个根据指定的颜色生成新材质的函数。它会根据指定的几何体生成对应网格,然后将网格添加进场景并设置其X轴的位置。

    -
    function makeInstance(geometry, color, x) {
    -  const material = new THREE.MeshPhongMaterial({color});
    -
    -  const cube = new THREE.Mesh(geometry, material);
    -  scene.add(cube);
    -
    -  cube.position.x = x;
    -
    -  return cube;
    -}
    -
    -

    然后我们将用三种不同的颜色和X轴位置调用三次函数,将生成的网格实例存在一个数组中。

    -
    const cubes = [
    -  makeInstance(geometry, 0x44aa88,  0),
    -  makeInstance(geometry, 0x8844aa, -2),
    -  makeInstance(geometry, 0xaa8844,  2),
    -];
    -
    -

    最后我们将在渲染函数中旋转三个立方体。我们给每个立方体设置了稍微不同的旋转角度。

    -
    function render(time) {
    -  time *= 0.001;  // 将时间单位变为秒
    -
    -  cubes.forEach((cube, ndx) => {
    -    const speed = 1 + ndx * .1;
    -    const rot = time * speed;
    -    cube.rotation.x = rot;
    -    cube.rotation.y = rot;
    -  });
    -
    -  ...
    -
    -

    这里是结果。

    -

    - -

    -

    如果你对比上面的示意图可以看到此效果符合我们的预想。位置为X = -2 和 X = +2的立方体有一部分在我们的视椎体外面。他们大部分是被包裹的,因为水平方向的视角非常大。

    -

    我们的项目现在有了这样的结构

    -
    - -

    正如你看见的那样,我们有三个网格(Mesh)引用了相同的立方几何体(BoxGeometry)。每个网格(Mesh)引用了一个单独的MeshPhongMaterial材质来显示不同的颜色。

    -

    希望这个简短的介绍能帮助你起步。接下来我们将介绍如何使我们的代码具有响应性,从而使其能够适应多种情况.

    -
    -

    es6模块,three.js,和文件夹结构

    -

    从r106版本开始,使用three.js的首选方式是通过es6模块

    -

    -在一个脚本中,es6模块可以通过import关键字加载或者通过<script type="module">行内标签。这有一个两种方法都用的例子。 -

    -
    <script type="module">
    -import * as THREE from 'three';
    -
    -...
    -
    -</script>
    -
    -

    -路径必须是绝对或相对的。相对路径通常由./或者../开头,和其他标签不同如<img><a>. -

    -

    -只要它们的绝对路径完全相同,对同一脚本的引用将只被加载一次。对于three.js这意味着它需要你把所有的实例的库放在正确的文件夹结构中。 -

    -
    someFolder
    - |
    - ├-build
    - | |
    - | +-three.module.js
    - |
    - +-examples
    -   |
    -   +-jsm
    -     |
    -     +-controls
    -     | |
    -     | +-OrbitControls.js
    -     | +-TrackballControls.js
    -     | +-...
    -     |
    -     +-loaders
    -     | |
    -     | +-GLTFLoader.js
    -     | +-...
    -     |
    -     ...
    -
    -

    -之所以需要这种文件夹结构,是因为像OrbitControls.js这样的示例中的脚本有一个复杂的相对路径,像下面这样 -

    -
    import * as THREE from '../../../build/three.module.js';
    -
    -

    -使用相同的结构保证了当你导入three和任一示例库时,它们都会引用同一个three.module.js文件。 -

    -
    import * as THREE from './someFolder/build/three.module.js';
    -import {OrbitControls} from './someFolder/addons/controls/OrbitControls.js';
    -
    -

    在使用CDN时,是同样的道理。确保three.modules.js的路径以 -/build/three.modules.js结尾,比如

    -
    import * as THREE from 'https://cdn.jsdelivr.net/npm/three@<version>/build/three.module.js';
    -import {OrbitControls} from 'https://cdn.jsdelivr.net/npm/three@<version>/addons/controls/OrbitControls.js';
    -
    -
    -
    -
    -
    - - - - - - - - diff --git a/manual/zh/game.html b/manual/zh/game.html deleted file mode 100644 index 3e5cf057883f45..00000000000000 --- a/manual/zh/game.html +++ /dev/null @@ -1,1649 +0,0 @@ - - - 制作一个游戏 - - - - - - - - - - - - - -
    -
    -

    制作一个游戏

    -
    -
    -
    -

    很多人想用 three.js 来写游戏。这篇文章希望能给你一些如何开始的思路。

    -

    至少在我写这篇文章的时候,它可能会成为本站最长的文章。这里的代码可能过度设计了,但在我编写每个新功能时,都会遇到需要解决的问题,而这些解决方案都来自我以前写过的其他游戏。换句话说,每个新的解决方案看起来都很重要,所以我会尽量解释为什么需要它们。当然,你的游戏越小,就越不需要这里展示的某些解决方案,但这本身是一个相当小的游戏,然而由于 3D 角色的复杂性,许多事情比 2D 角色需要更多的组织。

    -

    举个例子,如果你在制作 2D 版的吃豆人,吃豆人转弯时会瞬间完成 90 度旋转,没有中间过程。但在 3D 游戏中,我们通常需要角色在多帧之间旋转。这个简单的变化就会增加很多复杂性,并需要不同的解决方案。

    -

    这里的大部分代码实际上并不是 three.js 的代码,这一点很重要,three.js 不是一个游戏引擎。Three.js 是一个 3D 库。它提供了一个场景图以及在场景图中显示 3D 对象的功能,但它不提供制作游戏所需的所有其他东西。没有碰撞检测,没有物理引擎,没有输入系统,没有寻路等等...所以,我们必须自己提供这些功能。

    -

    我最终写了相当多的代码来制作这个简单的未完成的游戏原型,而且我觉得可能过度设计了,应该有更简单的解决方案,但我觉得我实际上还没有写够代码,希望我能解释我认为还缺少什么。

    -

    这里的许多想法深受 Unity 的影响。如果你不熟悉 Unity,那可能并不重要。我提到它只是因为有数以万计的游戏是使用这些理念发布的。

    -

    让我们从 three.js 部分开始。我们需要为游戏加载模型。

    -

    opengameart.org 上我找到了这个由 quaternius 制作的动画骑士模型

    -
    - -

    quaternius 还制作了这些动画动物

    -
    - -

    这些看起来是很好的起步模型,所以我们首先需要加载它们。

    -

    我们之前讲过加载 glTF 文件。这次的不同之处在于我们需要加载多个模型,而且在所有模型加载完成之前不能开始游戏。

    -

    幸运的是 three.js 提供了 LoadingManager 来满足这个需求。我们创建一个 LoadingManager 并将它传递给其他加载器。LoadingManager 提供了 onProgressonLoad 属性供我们附加回调函数。当所有文件加载完成时会调用 onLoad 回调。每个单独的文件加载完成后会调用 onProgress 回调,让我们有机会显示加载进度。

    -

    加载 glTF 文件的代码开始,我移除了所有与场景取景相关的代码,并添加了以下代码来加载所有模型。

    -
    const manager = new THREE.LoadingManager();
    -manager.onLoad = init;
    -const models = {
    -  pig:    { url: 'resources/models/animals/Pig.gltf' },
    -  cow:    { url: 'resources/models/animals/Cow.gltf' },
    -  llama:  { url: 'resources/models/animals/Llama.gltf' },
    -  pug:    { url: 'resources/models/animals/Pug.gltf' },
    -  sheep:  { url: 'resources/models/animals/Sheep.gltf' },
    -  zebra:  { url: 'resources/models/animals/Zebra.gltf' },
    -  horse:  { url: 'resources/models/animals/Horse.gltf' },
    -  knight: { url: 'resources/models/knight/KnightCharacter.gltf' },
    -};
    -{
    -  const gltfLoader = new GLTFLoader(manager);
    -  for (const model of Object.values(models)) {
    -    gltfLoader.load(model.url, (gltf) => {
    -      model.gltf = gltf;
    -    });
    -  }
    -}
    -
    -function init() {
    -  // 待实现
    -}
    -
    -

    这段代码会加载上面所有的模型,LoadingManager 会在完成后调用 init。我们稍后会使用 models 对象来访问已加载的模型,所以每个模型的 GLTFLoader 回调会将加载的数据附加到该模型的信息上。

    -

    所有模型及其动画目前大约 6.6MB。这是一个相当大的下载量。假设你的服务器支持压缩(本站的服务器就支持),可以将它们压缩到大约 1.4MB。这肯定比 6.6MB 好,但仍然不是很小的数据量。如果我们添加一个进度条,让用户知道还需要等待多长时间,那就好了。

    -

    所以,让我们添加一个 onProgress 回调。调用时会传入 3 个参数:最后加载的对象的 url,到目前为止已加载的项目数量,以及项目总数。

    -

    让我们设置一些 HTML 来做加载条

    -
    <body>
    -  <canvas id="c"></canvas>
    -+  <div id="loading">
    -+    <div>
    -+      <div>...loading...</div>
    -+      <div class="progress"><div id="progressbar"></div></div>
    -+    </div>
    -+  </div>
    -</body>
    -
    -

    我们会查找 #progressbar div,并将其宽度从 0% 设置到 100% 来显示进度。我们只需要在回调中设置它即可。

    -
    const manager = new THREE.LoadingManager();
    -manager.onLoad = init;
    -
    -+const progressbarElem = document.querySelector('#progressbar');
    -+manager.onProgress = (url, itemsLoaded, itemsTotal) => {
    -+  progressbarElem.style.width = `${itemsLoaded / itemsTotal * 100 | 0}%`;
    -+};
    -
    -

    我们已经设置了 init 在所有模型加载完成时被调用,所以我们可以通过隐藏 #loading 元素来关闭进度条。

    -
    function init() {
    -+  // 隐藏加载条
    -+  const loadingElem = document.querySelector('#loading');
    -+  loadingElem.style.display = 'none';
    -}
    -
    -

    这是一堆用于样式化进度条的 CSS。CSS 使 #loading <div> 占满整个页面并居中其子元素。CSS 创建了一个 .progress 区域来包含进度条。CSS 还为进度条添加了对角条纹的 CSS 动画。

    -
    #loading {
    -  position: absolute;
    -  left: 0;
    -  top: 0;
    -  width: 100%;
    -  height: 100%;
    -  display: flex;
    -  align-items: center;
    -  justify-content: center;
    -  text-align: center;
    -  font-size: xx-large;
    -  font-family: sans-serif;
    -}
    -#loading>div>div {
    -  padding: 2px;
    -}
    -.progress {
    -  width: 50vw;
    -  border: 1px solid black;
    -}
    -#progressbar {
    -  width: 0;
    -  transition: width ease-out .5s;
    -  height: 1em;
    -  background-color: #888;
    -  background-image: linear-gradient(
    -    -45deg,
    -    rgba(255, 255, 255, .5) 25%,
    -    transparent 25%,
    -    transparent 50%,
    -    rgba(255, 255, 255, .5) 50%,
    -    rgba(255, 255, 255, .5) 75%,
    -    transparent 75%,
    -    transparent
    -  );
    -  background-size: 50px 50px;
    -  animation: progressanim 2s linear infinite;
    -}
    -
    -@keyframes progressanim {
    -  0% {
    -    background-position: 50px 50px;
    -  }
    -  100% {
    -    background-position: 0 0;
    -  }
    -}
    -
    -

    现在我们有了进度条,让我们来处理模型。这些模型有动画,我们希望能够访问这些动画。动画默认存储在数组中,但我们希望能够通过名称轻松访问它们,所以让我们为每个模型设置一个 animations 属性来实现这一点。当然,这意味着动画必须有唯一的名称。

    -
    +function prepModelsAndAnimations() {
    -+  Object.values(models).forEach(model => {
    -+    const animsByName = {};
    -+    model.gltf.animations.forEach((clip) => {
    -+      animsByName[clip.name] = clip;
    -+    });
    -+    model.animations = animsByName;
    -+  });
    -+}
    -
    -function init() {
    -  // 隐藏加载条
    -  const loadingElem = document.querySelector('#loading');
    -  loadingElem.style.display = 'none';
    -
    -+  prepModelsAndAnimations();
    -}
    -
    -

    让我们显示带动画的模型。

    -

    之前加载 glTF 文件的例子不同,这次我们可能想要显示每个模型的多个实例。为此,我们不是像在加载 glTF 文章中那样直接添加加载的 gltf 场景,而是要克隆场景,特别是为蒙皮动画角色克隆场景。幸运的是有一个工具函数 SkeletonUtils.clone 可以用来做这件事。所以,首先我们需要引入这个工具。

    -
    import * as THREE from 'three';
    -import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
    -+import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
    -
    -

    然后我们可以克隆刚刚加载的模型

    -
    function init() {
    -  // 隐藏加载条
    -  const loadingElem = document.querySelector('#loading');
    -  loadingElem.style.display = 'none';
    -
    -  prepModelsAndAnimations();
    -
    -+  Object.values(models).forEach((model, ndx) => {
    -+    const clonedScene = SkeletonUtils.clone(model.gltf.scene);
    -+    const root = new THREE.Object3D();
    -+    root.add(clonedScene);
    -+    scene.add(root);
    -+    root.position.x = (ndx - 3) * 3;
    -+  });
    -}
    -
    -

    上面的代码中,对于每个模型,我们克隆了加载的 gltf.scene 并将其挂载到一个新的 Object3D 上。我们需要将它挂载到另一个对象上,因为播放动画时,动画会将动画位置应用到加载场景中的节点上,这意味着我们将无法控制这些位置。

    -

    要播放动画,每个克隆的模型都需要一个 AnimationMixer。一个 AnimationMixer 包含一个或多个 AnimationActionAnimationAction 引用一个 AnimationClipAnimationAction 有各种播放设置,可以链接到另一个动作或在动作之间交叉淡入淡出。让我们先获取第一个 AnimationClip 并为它创建一个动作。默认情况下,动作会永远循环播放其片段。

    -
    +const mixers = [];
    -
    -function init() {
    -  // 隐藏加载条
    -  const loadingElem = document.querySelector('#loading');
    -  loadingElem.style.display = 'none';
    -
    -  prepModelsAndAnimations();
    -
    -  Object.values(models).forEach((model, ndx) => {
    -    const clonedScene = SkeletonUtils.clone(model.gltf.scene);
    -    const root = new THREE.Object3D();
    -    root.add(clonedScene);
    -    scene.add(root);
    -    root.position.x = (ndx - 3) * 3;
    -
    -+    const mixer = new THREE.AnimationMixer(clonedScene);
    -+    const firstClip = Object.values(model.animations)[0];
    -+    const action = mixer.clipAction(firstClip);
    -+    action.play();
    -+    mixers.push(mixer);
    -  });
    -}
    -
    -

    我们调用了 play 来启动动作,并将所有 AnimationMixer 存储在一个名为 mixers 的数组中。最后我们需要在渲染循环中更新每个 AnimationMixer,计算自上一帧以来经过的时间并将其传递给 AnimationMixer.update

    -
    +let then = 0;
    -function render(now) {
    -+  now *= 0.001;  // 转换为秒
    -+  const deltaTime = now - then;
    -+  then = now;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -+  for (const mixer of mixers) {
    -+    mixer.update(deltaTime);
    -+  }
    -
    -  renderer.render(scene, camera);
    -
    -  requestAnimationFrame(render);
    -}
    -
    -

    这样我们应该能加载每个模型并播放其第一个动画了。

    -

    - -

    -

    让我们能够检查所有动画。我们将所有片段作为动作添加,然后一次只启用一个。

    -
    -const mixers = [];
    -+const mixerInfos = [];
    -
    -function init() {
    -  // 隐藏加载条
    -  const loadingElem = document.querySelector('#loading');
    -  loadingElem.style.display = 'none';
    -
    -  prepModelsAndAnimations();
    -
    -  Object.values(models).forEach((model, ndx) => {
    -    const clonedScene = SkeletonUtils.clone(model.gltf.scene);
    -    const root = new THREE.Object3D();
    -    root.add(clonedScene);
    -    scene.add(root);
    -    root.position.x = (ndx - 3) * 3;
    -
    -    const mixer = new THREE.AnimationMixer(clonedScene);
    --    const firstClip = Object.values(model.animations)[0];
    --    const action = mixer.clipAction(firstClip);
    --    action.play();
    --    mixers.push(mixer);
    -+    const actions = Object.values(model.animations).map((clip) => {
    -+      return mixer.clipAction(clip);
    -+    });
    -+    const mixerInfo = {
    -+      mixer,
    -+      actions,
    -+      actionNdx: -1,
    -+    };
    -+    mixerInfos.push(mixerInfo);
    -+    playNextAction(mixerInfo);
    -  });
    -}
    -
    -+function playNextAction(mixerInfo) {
    -+  const {actions, actionNdx} = mixerInfo;
    -+  const nextActionNdx = (actionNdx + 1) % actions.length;
    -+  mixerInfo.actionNdx = nextActionNdx;
    -+  actions.forEach((action, ndx) => {
    -+    const enabled = ndx === nextActionNdx;
    -+    action.enabled = enabled;
    -+    if (enabled) {
    -+      action.play();
    -+    }
    -+  });
    -+}
    -
    -

    上面的代码为每个 AnimationClip 创建了一个 AnimationAction 数组。它创建了一个 mixerInfos 对象数组,其中包含对每个模型的 AnimationMixer 和所有 AnimationAction 的引用。然后它调用 playNextAction,将除了一个动作之外的所有动作的 enabled 设为 false。

    -

    我们需要为新数组更新渲染循环

    -
    -for (const mixer of mixers) {
    -+for (const {mixer} of mixerInfos) {
    -  mixer.update(deltaTime);
    -}
    -
    -

    让我们实现按键 1 到 8 来播放每个模型的下一个动画

    -
    window.addEventListener('keydown', (e) => {
    -  const mixerInfo = mixerInfos[e.keyCode - 49];
    -  if (!mixerInfo) {
    -    return;
    -  }
    -  playNextAction(mixerInfo);
    -});
    -
    -

    现在你应该能点击示例,然后按 1 到 8 键来循环切换每个模型的可用动画。

    -

    - -

    -

    这基本上就是本文 three.js 部分的全部内容了。我们讲解了加载多个文件、克隆蒙皮模型以及在它们上播放动画。在真正的游戏中,你需要对 AnimationAction 对象做更多的操作。

    -

    让我们开始构建游戏基础架构

    -

    制作现代游戏的一个常见模式是使用实体组件系统(Entity Component System)。在实体组件系统中,游戏中的对象被称为实体(entity),由一组组件(component)组成。你通过决定将哪些组件附加到实体上来构建实体。那么,让我们来构建一个实体组件系统。

    -

    我们将实体称为 GameObject。它实际上只是组件的集合和一个 three.js Object3D

    -
    function removeArrayElement(array, element) {
    -  const ndx = array.indexOf(element);
    -  if (ndx >= 0) {
    -    array.splice(ndx, 1);
    -  }
    -}
    -
    -class GameObject {
    -  constructor(parent, name) {
    -    this.name = name;
    -    this.components = [];
    -    this.transform = new THREE.Object3D();
    -    parent.add(this.transform);
    -  }
    -  addComponent(ComponentType, ...args) {
    -    const component = new ComponentType(this, ...args);
    -    this.components.push(component);
    -    return component;
    -  }
    -  removeComponent(component) {
    -    removeArrayElement(this.components, component);
    -  }
    -  getComponent(ComponentType) {
    -    return this.components.find(c => c instanceof ComponentType);
    -  }
    -  update() {
    -    for (const component of this.components) {
    -      component.update();
    -    }
    -  }
    -}
    -
    -

    调用 GameObject.update 会调用所有组件的 update

    -

    我添加 name 只是为了帮助调试,这样在调试器中查看 GameObject 时可以看到一个名称来帮助识别。

    -

    有些东西可能看起来有点奇怪:

    -

    GameObject.addComponent 用于创建组件。我不确定这是好主意还是坏主意。我的想法是,组件存在于游戏对象之外没有意义,所以我认为如果创建组件时自动将该组件添加到游戏对象并将游戏对象传递给组件的构造函数会比较好。换句话说,添加组件时你这样做

    -
    const gameObject = new GameObject(scene, 'foo');
    -gameObject.addComponent(TypeOfComponent);
    -
    -

    如果我不这样做,你就需要这样写

    -
    const gameObject = new GameObject(scene, 'foo');
    -const component = new TypeOfComponent(gameObject);
    -gameObject.addComponent(component);
    -
    -

    第一种方式更短更自动化,这是更好还是更差,因为它看起来不太常规?我不知道。

    -

    GameObject.getComponent 通过类型查找组件。这意味着你不能在一个游戏对象上有两个相同类型的组件,或者至少如果你有的话,在不添加其他 API 的情况下只能查找到第一个。

    -

    一个组件查找另一个组件是很常见的,查找时必须按类型匹配,否则你可能会找到错误的组件。我们也可以给每个组件一个名称,然后按名称查找。这样会更灵活,因为你可以有多个相同类型的组件,但也会更繁琐。同样,我不确定哪种更好。

    -

    现在来看组件本身。这是它们的基类。

    -
    // 所有组件的基类
    -class Component {
    -  constructor(gameObject) {
    -    this.gameObject = gameObject;
    -  }
    -  update() {
    -  }
    -}
    -
    -

    组件需要基类吗?JavaScript 不像大多数严格类型语言,所以实际上我们可以没有基类,让每个组件在其构造函数中做它想做的事,知道第一个参数始终是组件的游戏对象。如果它不关心游戏对象就不存储它。但我还是觉得这个公共基类是好的。它意味着如果你有一个组件的引用,你总是可以找到它的父游戏对象,从父对象你可以轻松查找其他组件以及查看它的变换。

    -

    要管理游戏对象,我们可能需要某种游戏对象管理器。你可能认为我们可以只维护一个游戏对象数组,但在真正的游戏中,游戏对象的组件可能在运行时添加和移除其他游戏对象。例如,一个枪游戏对象可能在每次开火时添加一个子弹游戏对象。一个怪物游戏对象可能在被杀死时移除自己。这样我们就会遇到一个问题,我们可能有这样的代码

    -
    for (const gameObject of globalArrayOfGameObjects) {
    -  gameObject.update();
    -}
    -
    -

    如果在某个组件的 update 函数中在循环中途向 globalArrayOfGameObjects 添加或移除游戏对象,上面的循环就会失败或产生意外行为。

    -

    为了防止这个问题,我们需要一些更安全的东西。这是一个尝试。

    -
    class SafeArray {
    -  constructor() {
    -    this.array = [];
    -    this.addQueue = [];
    -    this.removeQueue = new Set();
    -  }
    -  get isEmpty() {
    -    return this.addQueue.length + this.array.length > 0;
    -  }
    -  add(element) {
    -    this.addQueue.push(element);
    -  }
    -  remove(element) {
    -    this.removeQueue.add(element);
    -  }
    -  forEach(fn) {
    -    this._addQueued();
    -    this._removeQueued();
    -    for (const element of this.array) {
    -      if (this.removeQueue.has(element)) {
    -        continue;
    -      }
    -      fn(element);
    -    }
    -    this._removeQueued();
    -  }
    -  _addQueued() {
    -    if (this.addQueue.length) {
    -      this.array.splice(this.array.length, 0, ...this.addQueue);
    -      this.addQueue = [];
    -    }
    -  }
    -  _removeQueued() {
    -    if (this.removeQueue.size) {
    -      this.array = this.array.filter(element => !this.removeQueue.has(element));
    -      this.removeQueue.clear();
    -    }
    -  }
    -}
    -
    -

    上面的类允许你向 SafeArray 添加或移除元素,但在遍历时不会直接修改数组本身。新元素会被添加到 addQueue,要移除的元素添加到 removeQueue,然后在循环之外进行实际的添加或移除。

    -

    使用它,这是我们管理游戏对象的类。

    -
    class GameObjectManager {
    -  constructor() {
    -    this.gameObjects = new SafeArray();
    -  }
    -  createGameObject(parent, name) {
    -    const gameObject = new GameObject(parent, name);
    -    this.gameObjects.add(gameObject);
    -    return gameObject;
    -  }
    -  removeGameObject(gameObject) {
    -    this.gameObjects.remove(gameObject);
    -  }
    -  update() {
    -    this.gameObjects.forEach(gameObject => gameObject.update());
    -  }
    -}
    -
    -

    有了这些,现在让我们创建第一个组件。这个组件只负责管理像我们刚才创建的那种蒙皮 three.js 对象。为了简单起见,它只有一个方法 setAnimation,接受要播放的动画名称并播放它。

    -
    class SkinInstance extends Component {
    -  constructor(gameObject, model) {
    -    super(gameObject);
    -    this.model = model;
    -    this.animRoot = SkeletonUtils.clone(this.model.gltf.scene);
    -    this.mixer = new THREE.AnimationMixer(this.animRoot);
    -    gameObject.transform.add(this.animRoot);
    -    this.actions = {};
    -  }
    -  setAnimation(animName) {
    -    const clip = this.model.animations[animName];
    -    // 关闭所有当前动作
    -    for (const action of Object.values(this.actions)) {
    -      action.enabled = false;
    -    }
    -    // 获取或创建该片段的动作
    -    const action = this.mixer.clipAction(clip);
    -    action.enabled = true;
    -    action.reset();
    -    action.play();
    -    this.actions[animName] = action;
    -  }
    -  update() {
    -    this.mixer.update(globals.deltaTime);
    -  }
    -}
    -
    -

    你可以看到,它基本上就是我们之前的代码,克隆加载的场景,然后设置一个 AnimationMixersetAnimation 为特定的 AnimationClip 添加一个 AnimationAction(如果还不存在的话),并禁用所有现有的动作。

    -

    代码引用了 globals.deltaTime。让我们创建一个 globals 对象

    -
    const globals = {
    -  time: 0,
    -  deltaTime: 0,
    -};
    -
    -

    并在渲染循环中更新它

    -
    let then = 0;
    -function render(now) {
    -  // 转换为秒
    -  globals.time = now * 0.001;
    -  // 确保 deltaTime 不会太大
    -  globals.deltaTime = Math.min(globals.time - then, 1 / 20);
    -  then = globals.time;
    -
    -

    上面确保 deltaTime 不超过 1/20 秒的检查是因为,如果我们隐藏标签页,就会得到一个巨大的 deltaTime 值。我们可能隐藏标签页几秒或几分钟,然后当标签页被切回前台时 deltaTime 会非常大,如果我们有这样的代码,可能会把角色传送到游戏世界的另一端

    -
    position += velocity * deltaTime;
    -
    -

    通过限制 deltaTime 的最大值可以防止这个问题。

    -

    现在让我们为玩家创建一个组件。

    -
    class Player extends Component {
    -  constructor(gameObject) {
    -    super(gameObject);
    -    const model = models.knight;
    -    this.skinInstance = gameObject.addComponent(SkinInstance, model);
    -    this.skinInstance.setAnimation('Run');
    -  }
    -}
    -
    -

    玩家用 'Run' 调用 setAnimation。为了知道有哪些可用的动画,我修改了之前的示例来打印动画名称

    -
    function prepModelsAndAnimations() {
    -  Object.values(models).forEach(model => {
    -+    console.log('------->:', model.url);
    -    const animsByName = {};
    -    model.gltf.animations.forEach((clip) => {
    -      animsByName[clip.name] = clip;
    -+      console.log('  ', clip.name);
    -    });
    -    model.animations = animsByName;
    -  });
    -}
    -
    -

    运行后在 JavaScript 控制台中得到了这个列表。

    -
     ------->:  resources/models/animals/Pig.gltf
    -    Idle
    -    Death
    -    WalkSlow
    -    Jump
    -    Walk
    - ------->:  resources/models/animals/Cow.gltf
    -    Walk
    -    Jump
    -    WalkSlow
    -    Death
    -    Idle
    - ------->:  resources/models/animals/Llama.gltf
    -    Jump
    -    Idle
    -    Walk
    -    Death
    -    WalkSlow
    - ------->:  resources/models/animals/Pug.gltf
    -    Jump
    -    Walk
    -    Idle
    -    WalkSlow
    -    Death
    - ------->:  resources/models/animals/Sheep.gltf
    -    WalkSlow
    -    Death
    -    Jump
    -    Walk
    -    Idle
    - ------->:  resources/models/animals/Zebra.gltf
    -    Jump
    -    Walk
    -    Death
    -    WalkSlow
    -    Idle
    - ------->:  resources/models/animals/Horse.gltf
    -    Jump
    -    WalkSlow
    -    Death
    -    Walk
    -    Idle
    - ------->:  resources/models/knight/KnightCharacter.gltf
    -    Run_swordRight
    -    Run
    -    Idle_swordLeft
    -    Roll_sword
    -    Idle
    -    Run_swordAttack
    -

    幸运的是,所有动物的动画名称都是一样的,这在之后会很方便。目前我们只关心玩家有一个叫 Run 的动画。

    -

    让我们使用这些组件。这是更新后的 init 函数。它所做的就是创建一个 GameObject 并添加一个 Player 组件。

    -
    const globals = {
    -  time: 0,
    -  deltaTime: 0,
    -};
    -+const gameObjectManager = new GameObjectManager();
    -
    -function init() {
    -  // 隐藏加载条
    -  const loadingElem = document.querySelector('#loading');
    -  loadingElem.style.display = 'none';
    -
    -  prepModelsAndAnimations();
    -
    -+  {
    -+    const gameObject = gameObjectManager.createGameObject(scene, 'player');
    -+    gameObject.addComponent(Player);
    -+  }
    -}
    -
    -

    我们需要在渲染循环中调用 gameObjectManager.update

    -
    let then = 0;
    -function render(now) {
    -  // 转换为秒
    -  globals.time = now * 0.001;
    -  // 确保 deltaTime 不会太大
    -  globals.deltaTime = Math.min(globals.time - then, 1 / 20);
    -  then = globals.time;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -  }
    -
    --  for (const {mixer} of mixerInfos) {
    --    mixer.update(deltaTime);
    --  }
    -+  gameObjectManager.update();
    -
    -  renderer.render(scene, camera);
    -
    -  requestAnimationFrame(render);
    -}
    -
    -

    如果我们运行它,会得到一个单独的玩家。

    -

    - -

    -

    仅仅为了一个实体组件系统就写了这么多代码,但这是大多数游戏需要的基础设施。

    -

    让我们添加一个输入系统。与其直接读取按键,我们将创建一个类,让代码的其他部分可以检查 leftright。这样我们可以分配多种方式来输入 leftright 等。我们先从按键开始

    -
    // 保持按键/按钮的状态
    -//
    -// 你可以检查
    -//
    -//   inputManager.keys.left.down
    -//
    -// 来查看左键是否当前被按住
    -// 你也可以检查
    -//
    -//   inputManager.keys.left.justPressed
    -//
    -// 来查看左键是否在这一帧被按下
    -//
    -// 按键有 'left', 'right', 'a', 'b', 'up', 'down'
    -class InputManager {
    -  constructor() {
    -    this.keys = {};
    -    const keyMap = new Map();
    -
    -    const setKey = (keyName, pressed) => {
    -      const keyState = this.keys[keyName];
    -      keyState.justPressed = pressed && !keyState.down;
    -      keyState.down = pressed;
    -    };
    -
    -    const addKey = (keyCode, name) => {
    -      this.keys[name] = { down: false, justPressed: false };
    -      keyMap.set(keyCode, name);
    -    };
    -
    -    const setKeyFromKeyCode = (keyCode, pressed) => {
    -      const keyName = keyMap.get(keyCode);
    -      if (!keyName) {
    -        return;
    -      }
    -      setKey(keyName, pressed);
    -    };
    -
    -    addKey(37, 'left');
    -    addKey(39, 'right');
    -    addKey(38, 'up');
    -    addKey(40, 'down');
    -    addKey(90, 'a');
    -    addKey(88, 'b');
    -
    -    window.addEventListener('keydown', (e) => {
    -      setKeyFromKeyCode(e.keyCode, true);
    -    });
    -    window.addEventListener('keyup', (e) => {
    -      setKeyFromKeyCode(e.keyCode, false);
    -    });
    -  }
    -  update() {
    -    for (const keyState of Object.values(this.keys)) {
    -      if (keyState.justPressed) {
    -        keyState.justPressed = false;
    -      }
    -    }
    -  }
    -}
    -
    -

    上面的代码跟踪按键是按下还是松开,你可以通过检查例如 inputManager.keys.left.down 来判断一个键是否当前被按下。它还为每个键提供了 justPressed 属性,这样你可以检查用户是否刚刚按下了该键。例如跳跃键,你不想知道按钮是否被持续按住,你想知道用户是否现在按下了它。

    -

    让我们创建一个 InputManager 实例

    -
    const globals = {
    -  time: 0,
    -  deltaTime: 0,
    -};
    -const gameObjectManager = new GameObjectManager();
    -+const inputManager = new InputManager();
    -
    -

    并在渲染循环中更新它

    -
    function render(now) {
    -
    -  ...
    -
    -  gameObjectManager.update();
    -+  inputManager.update();
    -
    -  ...
    -}
    -
    -

    它需要在 gameObjectManager.update 之后调用,否则 justPressed 在组件的 update 函数中永远不会为 true。

    -

    让我们在 Player 组件中使用它

    -
    +const kForward = new THREE.Vector3(0, 0, 1);
    -const globals = {
    -  time: 0,
    -  deltaTime: 0,
    -+  moveSpeed: 16,
    -};
    -
    -class Player extends Component {
    -  constructor(gameObject) {
    -    super(gameObject);
    -    const model = models.knight;
    -    this.skinInstance = gameObject.addComponent(SkinInstance, model);
    -    this.skinInstance.setAnimation('Run');
    -+    this.turnSpeed = globals.moveSpeed / 4;
    -  }
    -+  update() {
    -+    const {deltaTime, moveSpeed} = globals;
    -+    const {transform} = this.gameObject;
    -+    const delta = (inputManager.keys.left.down  ?  1 : 0) +
    -+                  (inputManager.keys.right.down ? -1 : 0);
    -+    transform.rotation.y += this.turnSpeed * delta * deltaTime;
    -+    transform.translateOnAxis(kForward, moveSpeed * deltaTime);
    -+  }
    -}
    -
    -

    上面的代码使用 Object3D.transformOnAxis 来向前移动玩家。Object3D.transformOnAxis 在本地空间中工作,所以只有当对象在场景的根级别时才有效,如果它是其他东西的子对象则不行 1

    -

    我们还添加了一个全局 moveSpeed,并基于移动速度计算 turnSpeed。转向速度基于移动速度,以确保角色能够足够快地转向以到达目标。如果 turnSpeed 太小,角色会围绕目标转圈但永远无法到达。我没有费心去计算给定移动速度所需的转向速度,只是猜的。

    -

    到目前为止的代码可以工作,但如果玩家跑出屏幕就无法知道他们在哪里了。让我们实现如果他们离开屏幕超过一定时间就传送回原点。我们可以使用 three.js 的 Frustum 类来检查一个点是否在摄像机的视锥体内。

    -

    我们需要从摄像机构建一个视锥体。我们可以在 Player 组件中做这件事,但其他对象可能也想使用它,所以让我们添加另一个带有管理视锥体组件的游戏对象。

    -
    class CameraInfo extends Component {
    -  constructor(gameObject) {
    -    super(gameObject);
    -    this.projScreenMatrix = new THREE.Matrix4();
    -    this.frustum = new THREE.Frustum();
    -  }
    -  update() {
    -    const {camera} = globals;
    -    this.projScreenMatrix.multiplyMatrices(
    -        camera.projectionMatrix,
    -        camera.matrixWorldInverse);
    -    this.frustum.setFromProjectionMatrix(this.projScreenMatrix);
    -  }
    -}
    -
    -

    然后让我们在初始化时设置另一个游戏对象。

    -
    function init() {
    -  // 隐藏加载条
    -  const loadingElem = document.querySelector('#loading');
    -  loadingElem.style.display = 'none';
    -
    -  prepModelsAndAnimations();
    -
    -+  {
    -+    const gameObject = gameObjectManager.createGameObject(camera, 'camera');
    -+    globals.cameraInfo = gameObject.addComponent(CameraInfo);
    -+  }
    -
    -  {
    -    const gameObject = gameObjectManager.createGameObject(scene, 'player');
    -    gameObject.addComponent(Player);
    -  }
    -}
    -
    -

    现在我们可以在 Player 组件中使用它了。

    -
    class Player extends Component {
    -  constructor(gameObject) {
    -    super(gameObject);
    -    const model = models.knight;
    -    this.skinInstance = gameObject.addComponent(SkinInstance, model);
    -    this.skinInstance.setAnimation('Run');
    -    this.turnSpeed = globals.moveSpeed / 4;
    -+    this.offscreenTimer = 0;
    -+    this.maxTimeOffScreen = 3;
    -  }
    -  update() {
    --    const {deltaTime, moveSpeed} = globals;
    -+    const {deltaTime, moveSpeed, cameraInfo} = globals;
    -    const {transform} = this.gameObject;
    -    const delta = (inputManager.keys.left.down  ?  1 : 0) +
    -                  (inputManager.keys.right.down ? -1 : 0);
    -    transform.rotation.y += this.turnSpeed * delta * deltaTime;
    -    transform.translateOnAxis(kForward, moveSpeed * deltaTime);
    -
    -+    const {frustum} = cameraInfo;
    -+    if (frustum.containsPoint(transform.position)) {
    -+      this.offscreenTimer = 0;
    -+    } else {
    -+      this.offscreenTimer += deltaTime;
    -+      if (this.offscreenTimer >= this.maxTimeOffScreen) {
    -+        transform.position.set(0, 0, 0);
    -+      }
    -+    }
    -  }
    -}
    -
    -

    在试运行之前还有一件事,让我们为移动端添加触摸屏支持。首先添加一些 HTML 用于触摸

    -
    <body>
    -  <canvas id="c"></canvas>
    -+  <div id="ui">
    -+    <div id="left"><img src="../resources/images/left.svg"></div>
    -+    <div style="flex: 0 0 40px;"></div>
    -+    <div id="right"><img src="../resources/images/right.svg"></div>
    -+  </div>
    -  <div id="loading">
    -    <div>
    -      <div>...loading...</div>
    -      <div class="progress"><div id="progressbar"></div></div>
    -    </div>
    -  </div>
    -</body>
    -
    -

    以及一些 CSS 来样式化它

    -
    #ui {
    -  position: absolute;
    -  left: 0;
    -  top: 0;
    -  width: 100%;
    -  height: 100%;
    -  display: flex;
    -  justify-items: center;
    -  align-content: stretch;
    -}
    -#ui>div {
    -  display: flex;
    -  align-items: flex-end;
    -  flex: 1 1 auto;
    -}
    -.bright {
    -  filter: brightness(2);
    -}
    -#left {
    -  justify-content: flex-end;
    -}
    -#right {
    -  justify-content: flex-start;
    -}
    -#ui img {
    -  padding: 10px;
    -  width: 80px;
    -  height: 80px;
    -  display: block;
    -}
    -
    -

    这里的想法是有一个 #ui div 覆盖整个页面。里面有两个 div,#left#right,它们都几乎是页面宽度的一半,高度为整个屏幕。中间有一个 40px 的分隔。如果用户在左侧或右侧滑动手指,我们需要更新 InputManager 中的 keys.leftkeys.right。这使整个屏幕都对触摸敏感,这比仅使用小箭头要好。

    -
    class InputManager {
    -  constructor() {
    -    this.keys = {};
    -    const keyMap = new Map();
    -
    -    const setKey = (keyName, pressed) => {
    -      const keyState = this.keys[keyName];
    -      keyState.justPressed = pressed && !keyState.down;
    -      keyState.down = pressed;
    -    };
    -
    -    const addKey = (keyCode, name) => {
    -      this.keys[name] = { down: false, justPressed: false };
    -      keyMap.set(keyCode, name);
    -    };
    -
    -    const setKeyFromKeyCode = (keyCode, pressed) => {
    -      const keyName = keyMap.get(keyCode);
    -      if (!keyName) {
    -        return;
    -      }
    -      setKey(keyName, pressed);
    -    };
    -
    -    addKey(37, 'left');
    -    addKey(39, 'right');
    -    addKey(38, 'up');
    -    addKey(40, 'down');
    -    addKey(90, 'a');
    -    addKey(88, 'b');
    -
    -    window.addEventListener('keydown', (e) => {
    -      setKeyFromKeyCode(e.keyCode, true);
    -    });
    -    window.addEventListener('keyup', (e) => {
    -      setKeyFromKeyCode(e.keyCode, false);
    -    });
    -
    -+    const sides = [
    -+      { elem: document.querySelector('#left'),  key: 'left'  },
    -+      { elem: document.querySelector('#right'), key: 'right' },
    -+    ];
    -+
    -+    const clearKeys = () => {
    -+      for (const {key} of sides) {
    -+          setKey(key, false);
    -+      }
    -+    };
    -+
    -+    const handleMouseMove = (e) => {
    -+      e.preventDefault();
    -+      // 这是必要的,因为我们调用了 preventDefault();
    -+      // 我们还给 canvas 添加了 tabindex 以便它可以
    -+      // 获得焦点
    -+      canvas.focus();
    -+      window.addEventListener('pointermove', handleMouseMove);
    -+      window.addEventListener('pointerup', handleMouseUp);
    -+
    -+      for (const {elem, key} of sides) {
    -+        let pressed = false;
    -+        const rect = elem.getBoundingClientRect();
    -+        const x = e.clientX;
    -+        const y = e.clientY;
    -+        const inRect = x >= rect.left && x < rect.right &&
    -+                       y >= rect.top && y < rect.bottom;
    -+        if (inRect) {
    -+          pressed = true;
    -+        }
    -+        setKey(key, pressed);
    -+      }
    -+    };
    -+
    -+    function handleMouseUp() {
    -+      clearKeys();
    -+      window.removeEventListener('pointermove', handleMouseMove, {passive: false});
    -+      window.removeEventListener('pointerup', handleMouseUp);
    -+    }
    -+
    -+    const uiElem = document.querySelector('#ui');
    -+    uiElem.addEventListener('pointerdown', handleMouseMove, {passive: false});
    -+
    -+    uiElem.addEventListener('touchstart', (e) => {
    -+      // 阻止滚动
    -+      e.preventDefault();
    -+    }, {passive: false});
    -  }
    -  update() {
    -    for (const keyState of Object.values(this.keys)) {
    -      if (keyState.justPressed) {
    -        keyState.justPressed = false;
    -      }
    -    }
    -  }
    -}
    -
    -

    现在我们应该能用左右方向键或在触摸屏上用手指来控制角色了

    -

    - -

    -

    理想情况下,如果玩家离开屏幕我们会做其他事情,比如移动摄像机或者离开屏幕就死亡,但这篇文章已经够长了,所以目前传送回中心是最简单的方案。

    -

    让我们添加一些动物。我们可以像 Player 类似地开始,创建一个 Animal 组件。

    -
    class Animal extends Component {
    -  constructor(gameObject, model) {
    -    super(gameObject);
    -    const skinInstance = gameObject.addComponent(SkinInstance, model);
    -    skinInstance.mixer.timeScale = globals.moveSpeed / 4;
    -    skinInstance.setAnimation('Idle');
    -  }
    -}
    -
    -

    上面的代码设置 AnimationMixer.timeScale 来设置动画相对于移动速度的播放速度。这样如果我们调整移动速度,动画也会相应加速或减速。

    -

    首先我们可以设置每种动物各一个

    -
    function init() {
    -  // 隐藏加载条
    -  const loadingElem = document.querySelector('#loading');
    -  loadingElem.style.display = 'none';
    -
    -  prepModelsAndAnimations();
    -  {
    -    const gameObject = gameObjectManager.createGameObject(camera, 'camera');
    -    globals.cameraInfo = gameObject.addComponent(CameraInfo);
    -  }
    -
    -  {
    -    const gameObject = gameObjectManager.createGameObject(scene, 'player');
    -    globals.player = gameObject.addComponent(Player);
    -    globals.congaLine = [gameObject];
    -  }
    -
    -+  const animalModelNames = [
    -+    'pig',
    -+    'cow',
    -+    'llama',
    -+    'pug',
    -+    'sheep',
    -+    'zebra',
    -+    'horse',
    -+  ];
    -+  animalModelNames.forEach((name, ndx) => {
    -+    const gameObject = gameObjectManager.createGameObject(scene, name);
    -+    gameObject.addComponent(Animal, models[name]);
    -+    gameObject.transform.position.x = (ndx + 1) * 5;
    -+  });
    -}
    -
    -

    这样我们会得到站在屏幕上的动物,但我们希望它们做些什么。

    -

    让我们让它们在玩家靠近时跟随玩家排成康加舞队列。为此我们需要几种状态。

    -
      -
    • 空闲(Idle):

      -

      动物等待玩家靠近

      -
    • -
    • 等待队尾(Wait for End of Line):

      -

      动物被玩家标记了,但现在需要等待队列末尾的动物过来,这样它才能加入队尾。

      -
    • -
    • 走向队尾(Go to Last):

      -

      动物需要走到它跟随的动物之前所在的位置,同时记录它跟随的动物当前的位置历史。

      -
    • -
    • 跟随(Follow)

      -

      动物需要持续记录它跟随的动物的位置历史,同时移动到它跟随的动物之前所在的位置。

      -
    • -
    -

    处理这样的不同状态有很多方式。一种常见的方式是使用有限状态机(Finite State Machine),并构建一些类来帮助我们管理状态。

    -

    那么,让我们来实现它。

    -
    class FiniteStateMachine {
    -  constructor(states, initialState) {
    -    this.states = states;
    -    this.transition(initialState);
    -  }
    -  get state() {
    -    return this.currentState;
    -  }
    -  transition(state) {
    -    const oldState = this.states[this.currentState];
    -    if (oldState && oldState.exit) {
    -      oldState.exit.call(this);
    -    }
    -    this.currentState = state;
    -    const newState = this.states[state];
    -    if (newState.enter) {
    -      newState.enter.call(this);
    -    }
    -  }
    -  update() {
    -    const state = this.states[this.currentState];
    -    if (state.update) {
    -      state.update.call(this);
    -    }
    -  }
    -}
    -
    -

    这是一个简单的类。我们传给它一个包含一堆状态的对象。每个状态有 3 个可选函数:enterupdateexit。要切换状态,我们调用 FiniteStateMachine.transition 并传入新状态的名称。如果当前状态有 exit 函数就会被调用。然后如果新状态有 enter 函数也会被调用。最后每一帧 FiniteStateMachine.update 会调用当前状态的 update 函数。

    -

    让我们用它来管理动物的状态。

    -
    // 如果 obj1 和 obj2 足够近则返回 true
    -function isClose(obj1, obj1Radius, obj2, obj2Radius) {
    -  const minDist = obj1Radius + obj2Radius;
    -  const dist = obj1.position.distanceTo(obj2.position);
    -  return dist < minDist;
    -}
    -
    -// 将 v 限制在 -min 和 +min 之间
    -function minMagnitude(v, min) {
    -  return Math.abs(v) > min
    -      ? min * Math.sign(v)
    -      : v;
    -}
    -
    -const aimTowardAndGetDistance = function() {
    -  const delta = new THREE.Vector3();
    -
    -  return function aimTowardAndGetDistance(source, targetPos, maxTurn) {
    -    delta.subVectors(targetPos, source.position);
    -    // 计算我们想要面朝的方向
    -    const targetRot = Math.atan2(delta.x, delta.z) + Math.PI * 1.5;
    -    // 沿最短方向旋转
    -    const deltaRot = (targetRot - source.rotation.y + Math.PI * 1.5) % (Math.PI * 2) - Math.PI;
    -    // 确保转向速度不超过 maxTurn
    -    const deltaRotation = minMagnitude(deltaRot, maxTurn);
    -    // 将旋转保持在 0 到 Math.PI * 2 之间
    -    source.rotation.y = THREE.MathUtils.euclideanModulo(
    -        source.rotation.y + deltaRotation, Math.PI * 2);
    -    // 返回到目标的距离
    -    return delta.length();
    -  };
    -}();
    -
    -class Animal extends Component {
    -  constructor(gameObject, model) {
    -    super(gameObject);
    -+    const hitRadius = model.size / 2;
    -    const skinInstance = gameObject.addComponent(SkinInstance, model);
    -    skinInstance.mixer.timeScale = globals.moveSpeed / 4;
    -+    const transform = gameObject.transform;
    -+    const playerTransform = globals.player.gameObject.transform;
    -+    const maxTurnSpeed = Math.PI * (globals.moveSpeed / 4);
    -+    const targetHistory = [];
    -+    let targetNdx = 0;
    -+
    -+    function addHistory() {
    -+      const targetGO = globals.congaLine[targetNdx];
    -+      const newTargetPos = new THREE.Vector3();
    -+      newTargetPos.copy(targetGO.transform.position);
    -+      targetHistory.push(newTargetPos);
    -+    }
    -+
    -+    this.fsm = new FiniteStateMachine({
    -+      idle: {
    -+        enter: () => {
    -+          skinInstance.setAnimation('Idle');
    -+        },
    -+        update: () => {
    -+          // 检查玩家是否靠近
    -+          if (isClose(transform, hitRadius, playerTransform, globals.playerRadius)) {
    -+            this.fsm.transition('waitForEnd');
    -+          }
    -+        },
    -+      },
    -+      waitForEnd: {
    -+        enter: () => {
    -+          skinInstance.setAnimation('Jump');
    -+        },
    -+        update: () => {
    -+          // 获取康加舞队列末尾的游戏对象
    -+          const lastGO = globals.congaLine[globals.congaLine.length - 1];
    -+          const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
    -+          const targetPos = lastGO.transform.position;
    -+          aimTowardAndGetDistance(transform, targetPos, deltaTurnSpeed);
    -+          // 检查康加舞队列的最后一个是否靠近
    -+          if (isClose(transform, hitRadius, lastGO.transform, globals.playerRadius)) {
    -+            this.fsm.transition('goToLast');
    -+          }
    -+        },
    -+      },
    -+      goToLast: {
    -+        enter: () => {
    -+          // 记住我们跟随的是谁
    -+          targetNdx = globals.congaLine.length - 1;
    -+          // 将自己加入康加舞队列
    -+          globals.congaLine.push(gameObject);
    -+          skinInstance.setAnimation('Walk');
    -+        },
    -+        update: () => {
    -+          addHistory();
    -+          // 走向历史记录中最旧的点
    -+          const targetPos = targetHistory[0];
    -+          const maxVelocity = globals.moveSpeed * globals.deltaTime;
    -+          const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
    -+          const distance = aimTowardAndGetDistance(transform, targetPos, deltaTurnSpeed);
    -+          const velocity = distance;
    -+          transform.translateOnAxis(kForward, Math.min(velocity, maxVelocity));
    -+          if (distance <= maxVelocity) {
    -+            this.fsm.transition('follow');
    -+          }
    -+        },
    -+      },
    -+      follow: {
    -+        update: () => {
    -+          addHistory();
    -+          // 移除最旧的历史记录并将自己放到那个位置
    -+          const targetPos = targetHistory.shift();
    -+          transform.position.copy(targetPos);
    -+          const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
    -+          aimTowardAndGetDistance(transform, targetHistory[0], deltaTurnSpeed);
    -+        },
    -+      },
    -+    }, 'idle');
    -+  }
    -+  update() {
    -+    this.fsm.update();
    -+  }
    -}
    -
    -

    这是一大段代码,但它实现了上面描述的功能。希望你逐步浏览每个状态时会觉得很清晰。

    -

    我们还需要添加一些东西。我们需要让玩家将自己添加到 globals 中,以便动物可以找到它,并且我们需要用玩家的 GameObject 来开始康加舞队列。

    -
    function init() {
    -
    -  ...
    -
    -  {
    -    const gameObject = gameObjectManager.createGameObject(scene, 'player');
    -+    globals.player = gameObject.addComponent(Player);
    -+    globals.congaLine = [gameObject];
    -  }
    -
    -}
    -
    -

    我们还需要计算每个模型的大小

    -
    function prepModelsAndAnimations() {
    -+  const box = new THREE.Box3();
    -+  const size = new THREE.Vector3();
    -  Object.values(models).forEach(model => {
    -+    box.setFromObject(model.gltf.scene);
    -+    box.getSize(size);
    -+    model.size = size.length();
    -    const animsByName = {};
    -    model.gltf.animations.forEach((clip) => {
    -      animsByName[clip.name] = clip;
    -      // 这个应该在 .blend 文件中修复
    -      if (clip.name === 'Walk') {
    -        clip.duration /= 2;
    -      }
    -    });
    -    model.animations = animsByName;
    -  });
    -}
    -
    -

    我们还需要让玩家记录自己的大小

    -
    class Player extends Component {
    -  constructor(gameObject) {
    -    super(gameObject);
    -    const model = models.knight;
    -+    globals.playerRadius = model.size / 2;
    -
    -

    现在想想,让动物瞄准康加舞队列的头部而不是特定的玩家可能会更聪明。也许我以后会回来改。

    -

    刚开始时我对所有动物只用一个半径,但这当然不好,因为哈巴狗比马小得多。所以我添加了不同的大小,但我想要能够可视化这些东西。为此我创建了一个 StateDisplayHelper 组件。

    -

    它使用 PolarGridHelper 在每个角色周围画一个圆圈,并使用 HTML 元素让每个角色显示一些状态,使用的是将 HTML 元素对齐到 3D 的文章中介绍的技术。

    -

    首先我们需要添加一些 HTML 来承载这些元素

    -
    <body>
    -  <canvas id="c"></canvas>
    -  <div id="ui">
    -    <div id="left"><img src="../resources/images/left.svg"></div>
    -    <div style="flex: 0 0 40px;"></div>
    -    <div id="right"><img src="../resources/images/right.svg"></div>
    -  </div>
    -  <div id="loading">
    -    <div>
    -      <div>...loading...</div>
    -      <div class="progress"><div id="progressbar"></div></div>
    -    </div>
    -  </div>
    -+  <div id="labels"></div>
    -</body>
    -
    -

    并添加一些 CSS

    -
    #labels {
    -  position: absolute;  /* 让我们可以在容器内定位自己 */
    -  left: 0;             /* 将位置设为容器的左上角 */
    -  top: 0;
    -  color: white;
    -  width: 100%;
    -  height: 100%;
    -  overflow: hidden;
    -  pointer-events: none;
    -}
    -#labels>div {
    -  position: absolute;  /* 让我们可以在容器内定位它们 */
    -  left: 0;             /* 将它们的默认位置设为容器的左上角 */
    -  top: 0;
    -  font-size: large;
    -  font-family: monospace;
    -  user-select: none;   /* 禁止文本被选中 */
    -  text-shadow:         /* 创建黑色描边 */
    -    -1px -1px 0 #000,
    -     0   -1px 0 #000,
    -     1px -1px 0 #000,
    -     1px  0   0 #000,
    -     1px  1px 0 #000,
    -     0    1px 0 #000,
    -    -1px  1px 0 #000,
    -    -1px  0   0 #000;
    -}
    -
    -

    然后这是组件

    -
    const labelContainerElem = document.querySelector('#labels');
    -
    -class StateDisplayHelper extends Component {
    -  constructor(gameObject, size) {
    -    super(gameObject);
    -    this.elem = document.createElement('div');
    -    labelContainerElem.appendChild(this.elem);
    -    this.pos = new THREE.Vector3();
    -
    -    this.helper = new THREE.PolarGridHelper(size / 2, 1, 1, 16);
    -    gameObject.transform.add(this.helper);
    -  }
    -  setState(s) {
    -    this.elem.textContent = s;
    -  }
    -  setColor(cssColor) {
    -    this.elem.style.color = cssColor;
    -    this.helper.material.color.set(cssColor);
    -  }
    -  update() {
    -    const {pos} = this;
    -    const {transform} = this.gameObject;
    -    const {canvas} = globals;
    -    pos.copy(transform.position);
    -
    -    // 获取该位置的归一化屏幕坐标
    -    // x 和 y 的范围在 -1 到 +1 之间,x = -1 在左边
    -    // y = -1 在底部
    -    pos.project(globals.camera);
    -
    -    // 将归一化位置转换为 CSS 坐标
    -    const x = (pos.x *  .5 + .5) * canvas.clientWidth;
    -    const y = (pos.y * -.5 + .5) * canvas.clientHeight;
    -
    -    // 将元素移动到该位置
    -    this.elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
    -  }
    -}
    -
    -

    然后我们可以这样将它们添加到动物上

    -
    class Animal extends Component {
    -  constructor(gameObject, model) {
    -    super(gameObject);
    -+    this.helper = gameObject.addComponent(StateDisplayHelper, model.size);
    -
    -     ...
    -
    -  }
    -  update() {
    -    this.fsm.update();
    -+    const dir = THREE.MathUtils.radToDeg(this.gameObject.transform.rotation.y);
    -+    this.helper.setState(`${this.fsm.state}:${dir.toFixed(0)}`);
    -  }
    -}
    -
    -

    趁此机会让我们也实现用 lil-gui 来开关它们,就像我们在其他地方使用的那样

    -
    import * as THREE from 'three';
    -import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
    -import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
    -+import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
    -
    -
    +const gui = new GUI();
    -+gui.add(globals, 'debug').onChange(showHideDebugInfo);
    -+showHideDebugInfo();
    -
    -const labelContainerElem = document.querySelector('#labels');
    -+function showHideDebugInfo() {
    -+  labelContainerElem.style.display = globals.debug ? '' : 'none';
    -+}
    -+showHideDebugInfo();
    -
    -class StateDisplayHelper extends Component {
    -
    -  ...
    -
    -  update() {
    -+    this.helper.visible = globals.debug;
    -+    if (!globals.debug) {
    -+      return;
    -+    }
    -
    -    ...
    -  }
    -}
    -
    -

    这样我们就有了一个游戏的雏形

    -

    - -

    -

    最初我打算做一个贪吃蛇游戏,随着你将动物添加到队列中,游戏会变得更难,因为你需要避免撞到它们。我还会在场景中放置一些障碍物,也许还有围栏或围绕周边的某种屏障。

    -

    不幸的是,这些动物又长又细。从上面看,这是斑马。

    -
    - -

    目前的代码使用圆形碰撞,这意味着如果我们有像围栏这样的障碍物,那么这将被视为碰撞

    -
    - -

    这不行。即使是动物与动物之间也会有同样的问题。

    -

    我考虑过写一个 2D 矩形对矩形的碰撞系统,但很快意识到这可能需要很多代码。检查两个任意方向的矩形是否重叠本身代码量不大,对于只有少量对象的游戏可能够用,但当对象多了之后你很快就需要优化碰撞检测。首先你可能需要遍历所有可能相互碰撞的对象,检查它们的包围球、包围圆或轴对齐包围盒。一旦你知道哪些对象可能碰撞,你还需要做更多工作来检查它们是否实际碰撞了。通常即使检查包围球也太费劲,你需要某种更好的空间结构来更快地只检查可能彼此靠近的对象。

    -

    然后,一旦你写了检查两个对象是否碰撞的代码,你通常想要做一个碰撞系统,而不是手动询问"我是否与这些对象碰撞"。碰撞系统会发出事件或调用与碰撞相关的回调。优势在于它可以一次检查所有碰撞,这样没有对象会被检查多次,而如果你手动调用某个"我是否碰撞"的函数,对象往往会被多次检查,浪费时间。

    -

    制作这样的碰撞系统可能只需要 100-300 行代码来检查任意方向的矩形,但这仍然是很多额外的代码,所以最好先不做。

    -

    另一个解决方案是尝试找一些从顶部看大致是圆形的其他角色。例如其他人形角色而不是动物,这样圆形检测可能适用于动物之间的碰撞。但对于动物与围栏之间则不行,我们必须添加圆形对矩形的检测。我考虑过把围栏做成灌木丛或柱子,圆形的东西,但那样我可能需要 120 到 200 个来围绕游戏区域,这就会遇到上面提到的优化问题。

    -

    这就是为什么很多游戏使用现有的解决方案。这些解决方案通常是物理库的一部分。物理库需要知道对象是否相互碰撞,所以在提供物理效果的基础上还可以用来检测碰撞。

    -

    如果你在寻找解决方案,一些 three.js 示例使用了 ammo.js,这可能是一个选择。

    -

    另一个解决方案可能是将障碍物放在网格上,让每个动物和玩家只需要查看网格。虽然这样性能会很好,但我觉得这最好留作读者的练习 😜

    -

    还有一件事,很多游戏系统有一种叫做协程(coroutines)的东西。协程是可以在运行时暂停并在之后继续的例程。

    -

    让我们让主角发出音符,就像它在通过唱歌带领队伍一样。我们有很多方式可以实现这个,但现在让我们用协程来做。

    -

    首先,这是一个管理协程的类

    -
    function* waitSeconds(duration) {
    -  while (duration > 0) {
    -    duration -= globals.deltaTime;
    -    yield;
    -  }
    -}
    -
    -class CoroutineRunner {
    -  constructor() {
    -    this.generatorStacks = [];
    -    this.addQueue = [];
    -    this.removeQueue = new Set();
    -  }
    -  isBusy() {
    -    return this.addQueue.length + this.generatorStacks.length > 0;
    -  }
    -  add(generator, delay = 0) {
    -    const genStack = [generator];
    -    if (delay) {
    -      genStack.push(waitSeconds(delay));
    -    }
    -    this.addQueue.push(genStack);
    -  }
    -  remove(generator) {
    -    this.removeQueue.add(generator);
    -  }
    -  update() {
    -    this._addQueued();
    -    this._removeQueued();
    -    for (const genStack of this.generatorStacks) {
    -      const main = genStack[0];
    -      // 处理一个协程移除另一个协程的情况
    -      if (this.removeQueue.has(main)) {
    -        continue;
    -      }
    -      while (genStack.length) {
    -        const topGen = genStack[genStack.length - 1];
    -        const {value, done} = topGen.next();
    -        if (done) {
    -          if (genStack.length === 1) {
    -            this.removeQueue.add(topGen);
    -            break;
    -          }
    -          genStack.pop();
    -        } else if (value) {
    -          genStack.push(value);
    -        } else {
    -          break;
    -        }
    -      }
    -    }
    -    this._removeQueued();
    -  }
    -  _addQueued() {
    -    if (this.addQueue.length) {
    -      this.generatorStacks.splice(this.generatorStacks.length, 0, ...this.addQueue);
    -      this.addQueue = [];
    -    }
    -  }
    -  _removeQueued() {
    -    if (this.removeQueue.size) {
    -      this.generatorStacks = this.generatorStacks.filter(genStack => !this.removeQueue.has(genStack[0]));
    -      this.removeQueue.clear();
    -    }
    -  }
    -}
    -
    -

    它和 SafeArray 做了类似的事情,确保在其他协程运行时添加或移除协程是安全的。它还处理嵌套协程。

    -

    要创建协程,你需要创建一个 JavaScript 生成器函数。生成器函数前面有关键字 function*(星号很重要!)

    -

    生成器函数可以 yield。例如

    -
    function* count0To9() {
    -  for (let i = 0; i < 10; ++i) {
    -    console.log(i);
    -    yield;
    -  }
    -}
    -
    -

    如果我们将这个函数添加到上面的 CoroutineRunner 中,它会每帧打印一个数字(0 到 9),或者更准确地说是每次调用 runner.update 时打印一个。

    -
    const runner = new CoroutineRunner();
    -runner.add(count0To9);
    -while(runner.isBusy()) {
    -  runner.update();
    -}
    -
    -

    协程在完成时会自动被移除。要提前移除一个协程,在它结束之前你需要保持对其生成器的引用,像这样

    -
    const gen = count0To9();
    -runner.add(gen);
    -
    -// 稍后某个时候
    -
    -runner.remove(gen);
    -
    -

    无论如何,在玩家中让我们使用协程每隔 0.5 到 1 秒发出一个音符

    -
    class Player extends Component {
    -  constructor(gameObject) {
    -
    -    ...
    -
    -+    this.runner = new CoroutineRunner();
    -+
    -+    function* emitNotes() {
    -+      for (;;) {
    -+        yield waitSeconds(rand(0.5, 1));
    -+        const noteGO = gameObjectManager.createGameObject(scene, 'note');
    -+        noteGO.transform.position.copy(gameObject.transform.position);
    -+        noteGO.transform.position.y += 5;
    -+        noteGO.addComponent(Note);
    -+      }
    -+    }
    -+
    -+    this.runner.add(emitNotes());
    -  }
    -  update() {
    -+    this.runner.update();
    -
    -  ...
    -
    -  }
    -}
    -
    -function rand(min, max) {
    -  if (max === undefined) {
    -    max = min;
    -    min = 0;
    -  }
    -  return Math.random() * (max - min) + min;
    -}
    -
    -

    你可以看到我们创建了一个 CoroutineRunner 并添加了一个 emitNotes 协程。这个函数会永远运行,等待 0.5 到 1 秒然后创建一个带有 Note 组件的游戏对象。

    -

    对于 Note 组件,首先让我们制作一个带有音符的纹理,我们不加载音符图片,而是像画布纹理文章中介绍的那样使用画布来制作。

    -
    function makeTextTexture(str) {
    -  const ctx = document.createElement('canvas').getContext('2d');
    -  ctx.canvas.width = 64;
    -  ctx.canvas.height = 64;
    -  ctx.font = '60px sans-serif';
    -  ctx.textAlign = 'center';
    -  ctx.textBaseline = 'middle';
    -  ctx.fillStyle = '#FFF';
    -  ctx.fillText(str, ctx.canvas.width / 2, ctx.canvas.height / 2);
    -  return new THREE.CanvasTexture(ctx.canvas);
    -}
    -const noteTexture = makeTextTexture('♪');
    -
    -

    我们创建的纹理是白色的,这意味着使用时我们可以设置材质的颜色来获得任意颜色的音符。

    -

    现在我们有了 noteTexture,这是 Note 组件。它使用了 SpriteMaterialSprite,就像我们在广告牌文章中介绍的那样

    -
    class Note extends Component {
    -  constructor(gameObject) {
    -    super(gameObject);
    -    const {transform} = gameObject;
    -    const noteMaterial = new THREE.SpriteMaterial({
    -      color: new THREE.Color().setHSL(rand(1), 1, 0.5),
    -      map: noteTexture,
    -      side: THREE.DoubleSide,
    -      transparent: true,
    -    });
    -    const note = new THREE.Sprite(noteMaterial);
    -    note.scale.setScalar(3);
    -    transform.add(note);
    -    this.runner = new CoroutineRunner();
    -    const direction = new THREE.Vector3(rand(-0.2, 0.2), 1, rand(-0.2, 0.2));
    -
    -    function* moveAndRemove() {
    -      for (let i = 0; i < 60; ++i) {
    -        transform.translateOnAxis(direction, globals.deltaTime * 10);
    -        noteMaterial.opacity = 1 - (i / 60);
    -        yield;
    -      }
    -      transform.parent.remove(transform);
    -      gameObjectManager.removeGameObject(gameObject);
    -    }
    -
    -    this.runner.add(moveAndRemove());
    -  }
    -  update() {
    -    this.runner.update();
    -  }
    -}
    -
    -

    它所做的就是设置一个 Sprite,然后选择一个随机速度,以该速度移动变换 60 帧,同时通过设置材质的 opacity 使音符淡出。循环结束后,它将变换从场景中移除,并将音符本身从活动游戏对象中移除。

    -

    最后一件事,让我们添加更多动物

    -
    function init() {
    -
    -   ...
    -
    -  const animalModelNames = [
    -    'pig',
    -    'cow',
    -    'llama',
    -    'pug',
    -    'sheep',
    -    'zebra',
    -    'horse',
    -  ];
    -+  const base = new THREE.Object3D();
    -+  const offset = new THREE.Object3D();
    -+  base.add(offset);
    -+
    -+  // 将动物排列成螺旋形
    -+  const numAnimals = 28;
    -+  const arc = 10;
    -+  const b = 10 / (2 * Math.PI);
    -+  let r = 10;
    -+  let phi = r / b;
    -+  for (let i = 0; i < numAnimals; ++i) {
    -+    const name = animalModelNames[rand(animalModelNames.length) | 0];
    -    const gameObject = gameObjectManager.createGameObject(scene, name);
    -    gameObject.addComponent(Animal, models[name]);
    -+    base.rotation.y = phi;
    -+    offset.position.x = r;
    -+    offset.updateWorldMatrix(true, false);
    -+    offset.getWorldPosition(gameObject.transform.position);
    -+    phi += arc / r;
    -+    r = b * phi;
    -  }
    -
    -

    - -

    -

    你可能会问,为什么不用 setTimeoutsetTimeout 的问题是它与游戏时钟无关。例如上面我们将帧之间允许的最大时间设为 1/20 秒。我们的协程系统会遵守这个限制,但 setTimeout 不会。

    -

    当然我们可以自己做一个简单的计时器

    -
    class Player ... {
    -  update() {
    -    this.noteTimer -= globals.deltaTime;
    -    if (this.noteTimer <= 0) {
    -      // 重置计时器
    -      this.noteTimer = rand(0.5, 1);
    -      // 创建一个带有音符组件的游戏对象
    -    }
    -  }
    -
    -

    对于这个特定情况这可能更好,但随着你添加越来越多的东西,你的类中会添加越来越多的变量,而使用协程你通常可以触发后就不用管了

    -

    鉴于我们动物的简单状态,我们也可以用以下形式的协程来实现它们

    -
    // 伪代码!
    -function* animalCoroutine() {
    -   setAnimation('Idle');
    -   while(playerIsTooFar()) {
    -     yield;
    -   }
    -   const target = endOfLine;
    -   setAnimation('Jump');
    -   while(targetIsTooFar()) {
    -     aimAt(target);
    -     yield;
    -   }
    -   setAnimation('Walk')
    -   while(notAtOldestPositionOfTarget()) {
    -     addHistory();
    -     aimAt(target);
    -     yield;
    -   }
    -   for(;;) {
    -     addHistory();
    -     const pos = history.unshift();
    -     transform.position.copy(pos);
    -     aimAt(history[0]);
    -     yield;
    -   }
    -}
    -
    -

    这样做是可行的,但当然一旦我们的状态不再是线性的,我们就不得不切换到 FiniteStateMachine

    -

    我也不确定协程是否应该独立于它们的组件运行。我们可以创建一个全局的 CoroutineRunner 并将所有协程放在上面。但这会使清理变得更难。目前如果游戏对象被移除,它的所有组件都会被移除,因此创建的协程运行器不再被调用,一切都会被垃圾回收。如果我们有一个全局运行器,那么每个组件都有责任移除它添加的任何协程,否则需要某种其他机制将协程注册到特定组件或游戏对象,以便移除一个时也移除其他的。

    -

    一个正常的游戏引擎会处理更多问题。目前游戏对象或其组件的运行没有顺序。它们只是按添加顺序运行。许多游戏系统会添加优先级,以便可以设置或更改顺序。

    -

    我们遇到的另一个问题是 Note 从场景中移除其游戏对象的变换。这似乎应该在 GameObject 中发生,因为最初是 GameObject 添加的变换。也许 GameObject 应该有一个 dispose 方法,由 GameObjectManager.removeGameObject 调用?

    -

    还有一个问题是我们手动调用 gameObjectManager.updateinputManager.update。也许应该有一个 SystemManager,这些全局服务可以将自己添加进去,每个服务的 update 函数都会被调用。这样如果我们添加了像 CollisionManager 这样的新服务,我们只需要将它添加到系统管理器中,而不必编辑渲染循环。

    -

    我会把这些问题留给你。希望这篇文章给了你一些关于制作自己游戏引擎的思路。

    -

    也许我应该搞一个 Game Jam。如果你点击最后一个示例上方的 jsfiddlecodepen 按钮,它们会在这些网站上打开,准备好编辑。添加一些功能,把游戏改成一只哈巴狗带领一群骑士。用骑士的翻滚动画做保龄球,制作一个动物保龄球游戏。制作一个动物接力赛。如果你做出了很酷的游戏,请在下面的评论中发布链接。

    -
    -[1]: 从技术上讲,如果所有父对象都没有任何平移、旋转或缩放,它仍然可以工作 §。 -
    -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/how-to-create-vr-content.html b/manual/zh/how-to-create-vr-content.html deleted file mode 100644 index 374864ef28e421..00000000000000 --- a/manual/zh/how-to-create-vr-content.html +++ /dev/null @@ -1,109 +0,0 @@ - - - 如何创建 VR 内容 - - - - - - - - - - - - - - -
    -
    -

    如何创建 VR 内容

    -
    -
    -
    - -

    - 本指南简要介绍如何使用 three.js 构建一个基于 Web 的 VR 应用, - 以及其中最基础的组成部分。 -

    - -

    工作流程

    - -

    - 首先,在项目中引入 - [link:https://github.com/mrdoob/three.js/blob/master/examples/jsm/webxr/VRButton.js VRButton.js]。 -

    - -
    -import { VRButton } from 'three/addons/webxr/VRButton.js';
    -
    - -

    - *VRButton.createButton()* 会做两件重要的事:它会创建一个用于指示 - VR 兼容性的按钮;此外,如果用户点击该按钮,它会发起 VR 会话。 - 你只需要在应用中添加下面这行代码。 -

    - -
    -document.body.appendChild( VRButton.createButton( renderer ) );
    -
    - -

    - 接下来,需要在 `WebGLRenderer` 实例上启用 XR 渲染。 -

    - -
    -renderer.xr.enabled = true;
    -
    - -

    - 最后,要调整动画循环。VR 场景中不再使用常见的 - *window.requestAnimationFrame()*,而是使用 `renderer.setAnimationLoop()`。 - 最小示例代码如下: -

    - -
    -renderer.setAnimationLoop( function () {
    -
    -  renderer.render( scene, camera );
    -
    -} );
    -
    - -

    下一步

    - -

    - 可以查看官方 WebXR 示例,了解上述流程在实际项目中的用法。

    - - [example:webxr_xr_ballshooter WebXR / XR / ballshooter]
    - [example:webxr_xr_cubes WebXR / XR / cubes]
    - [example:webxr_xr_dragging WebXR / XR / dragging]
    - [example:webxr_xr_marchingcubes WebXR / XR / marching cubes]
    - [example:webxr_xr_paint WebXR / XR / paint]
    - [example:webxr_vr_panorama_depth WebXR / VR / panorama_depth]
    - [example:webxr_vr_panorama WebXR / VR / panorama]
    - [example:webxr_vr_rollercoaster WebXR / VR / rollercoaster]
    - [example:webxr_vr_sandbox WebXR / VR / sandbox]
    - [example:webxr_vr_video WebXR / VR / video] -

    - -
    -
    -
    - - - - - - - - - - - diff --git a/manual/zh/how-to-dispose-of-objects.html b/manual/zh/how-to-dispose-of-objects.html deleted file mode 100644 index 24bab4c56c16a3..00000000000000 --- a/manual/zh/how-to-dispose-of-objects.html +++ /dev/null @@ -1,184 +0,0 @@ - - - 如何释放对象 - - - - - - - - - - - - - - -
    -
    -

    如何释放对象

    -
    -
    -
    - -

    - 为了提升性能并避免内存泄漏,及时释放不再使用的对象非常关键。 - 每当你创建一个 *three.js* 实例,都会分配一定内存。同时,*three.js* - 还会为几何体、材质等对象创建渲染所需的 WebGL 资源(如缓冲区和着色器程序)。 - 这些资源不会自动释放,应用必须通过专用 API 主动清理。 - 本文简要说明这些 API 的使用方式,以及哪些对象需要关注。 -

    - -

    几何体

    - -

    - 几何体通常由一组顶点属性组成。*three.js* 会为每个属性在内部创建 - [link:https://developer.mozilla.org/en-US/docs/Web/API/WebGLBuffer WebGLBuffer]。 - 这些资源只有在调用 `BufferGeometry.dispose()` 后才会被删除。 - 当几何体不再使用时,应执行该方法释放相关资源。 -

    - -

    材质

    - -

    - 材质决定对象如何被渲染。*three.js* 会根据材质信息构建着色器程序。 - 着色器程序只有在对应材质被释放后才可能删除。出于性能考虑, - *three.js* 会尽量复用已存在的着色器程序,因此只有当相关材质都释放后, - 着色器程序才会真正销毁。释放材质请调用 `Material.dispose()`。 -

    - -

    纹理

    - -

    - 释放材质不会影响纹理。纹理需要单独管理,因为一个纹理可能被多个材质共享。 - 每当创建 `Texture`,three.js 会在内部创建 - [link:https://developer.mozilla.org/en-US/docs/Web/API/WebGLTexture WebGLTexture]。 - 与缓冲区一样,它只能通过 `Texture.dispose()` 删除。 -

    - -

    - 如果纹理数据源是 `ImageBitmap`,你还需要在应用层调用 - [link:https://developer.mozilla.org/en-US/docs/Web/API/ImageBitmap/close ImageBitmap.close]() - 来释放 CPU 侧资源。`Texture.dispose()` 无法自动调用该方法, - 因为 `close()` 后图像位图将不可再用,而引擎无法判断它是否还被其他地方使用。 -

    - -

    渲染目标

    - -

    - `WebGLRenderTarget` 不仅会分配 - [link:https://developer.mozilla.org/en-US/docs/Web/API/WebGLTexture WebGLTexture], - 还会分配 [link:https://developer.mozilla.org/en-US/docs/Web/API/WebGLFramebuffer WebGLFramebuffer] - 和 [link:https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderbuffer WebGLRenderbuffer] - 来支持自定义渲染输出。这些资源只能通过 `WebGLRenderTarget.dispose()` 释放。 -

    - -

    蒙皮网格

    - -

    - 蒙皮网格通过骨架(skeleton)表示骨骼层级。若不再需要某个蒙皮网格, - 可以对其骨架调用 `Skeleton.dispose()` 释放内部资源。 - 注意骨架可能被多个蒙皮网格共享,只有在确认未被其他活动对象使用时再释放。 -

    - -

    其他

    - -

    - examples 目录中的其他类(如 controls、后处理 pass)也可能提供 `dispose()`, - 用于移除内部事件监听器或渲染目标。通常建议查看类的 API / 文档, - 只要有 `dispose()`,在清理阶段就应调用。 -

    - -

    常见问题

    - -

    为什么 *three.js* 不能自动释放对象?

    - -

    - 这是社区经常提出的问题。核心原因是:*three.js* 无法知道用户创建对象 - (如几何体、材质)的生命周期与作用域,这属于应用层职责。 - 例如某材质当前帧没被使用,下一帧仍可能需要。 - 因此当应用确认对象可删除时,必须调用对应 `dispose()` 通知引擎。 -

    - -

    把 mesh 从场景移除后,几何体和材质会自动释放吗?

    - -

    - 不会。你需要显式调用 *dispose()* 释放几何体和材质。 - 同时要注意它们可能被多个 3D 对象共享。 -

    - -

    *three.js* 能查看缓存对象数量吗?

    - -

    - 可以。查看渲染器的 `renderer.info` 属性即可,它包含显存与渲染流程的统计信息, - 包括当前内部缓存了多少纹理、几何体、着色器程序等。 - 如果应用出现性能问题,调试该属性有助于快速定位内存泄漏。 -

    - -

    纹理图像尚未加载完成时调用 `dispose()` 会怎样?

    - -

    - 纹理内部资源只有在图像完全加载后才会分配。 - 若在加载前就调用 `dispose()`,通常不会发生任何事; - 因为资源尚未创建,也就无需清理。 -

    - -

    调用 `dispose()` 后又再次使用该对象,会怎样?

    - -

    - 取决于对象类型。对于几何体、材质、纹理、渲染目标和后处理 pass, - 被删除的内部资源通常可以由引擎重新创建,因此不会直接报运行时错误; - 但当前帧可能有性能损耗,尤其在需要重新编译着色器时。 - - controls 和 renderer 属于例外:调用 `dispose()` 后实例不可继续使用, - 需要重新创建。 -

    - -

    应用里该如何管理 *three.js* 对象?什么时候该释放?

    - -

    - 没有唯一标准答案,取决于具体业务场景。需要强调的是,并非任何时候都必须立即释放。 - 例如多关卡游戏,切关通常是做统一清理的好时机:遍历旧场景并释放失效的材质、 - 几何体和纹理。就像上文所说,即使误释放了仍会被使用的对象, - 一般也不会立刻报错,最坏情况通常是某一帧性能下降。 -

    - -

    为什么遍历场景并释放可达资源后,`renderer.info.memory` 仍显示几何体和纹理?

    - -

    - 某些情况下,Three.js 会创建一些内部使用的纹理和几何体, - 它们无法通过遍历场景图直接访问到,因此也无法在遍历时释放。 - 所以即便做了完整场景清理,`renderer.info.memory` 仍可能显示这些对象。 - 这通常不代表泄漏,它们会在后续“清理-重建”循环中被复用。 - - 常见相关场景包括使用 `material.envMap`、`scene.background`、 - `scene.environment` 等,会触发引擎创建内部资源。 -

    - -

    `dispose()` 用法示例

    - -

    - [example:webgl_test_memory WebGL / test / memory]
    - [example:webgl_test_memory2 WebGL / test / memory2]
    -

    - -
    -
    -
    - - - - - - - - - - diff --git a/manual/zh/how-to-update-things.html b/manual/zh/how-to-update-things.html deleted file mode 100644 index a12157df5a21e7..00000000000000 --- a/manual/zh/how-to-update-things.html +++ /dev/null @@ -1,281 +0,0 @@ - - - 如何更新对象 - - - - - - - - - - - - - - -
    -
    -

    如何更新对象

    -
    -
    -
    - -
    -

    默认情况下,只要对象被添加到场景中,就会自动更新其矩阵:

    -
    -const object = new THREE.Object3D();
    -scene.add( object );
    -
    - 或者,它是某个已加入场景对象的子对象: -
    -const object1 = new THREE.Object3D();
    -const object2 = new THREE.Object3D();
    -
    -object1.add( object2 );
    -scene.add( object1 ); // object1 和 object2 都会自动更新它们的矩阵
    -
    -
    - -

    但如果你确定对象是静态的,可以关闭自动更新,仅在需要时手动更新变换矩阵。

    - -
    -object.matrixAutoUpdate = false;
    -object.updateMatrix();
    -
    - -

    BufferGeometry

    -
    -

    - BufferGeometry 把顶点位置、面索引、法线、颜色、UV 以及自定义属性等信息 - 存在属性缓冲中,也就是 - [link:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Typed_arrays 类型化数组]。 - 这种结构通常比旧版 Geometry 更快,但使用起来通常更复杂。 -

    -

    - 更新 BufferGeometry 时最重要的一点是:不能调整缓冲区大小 - (开销很大,基本等同于新建几何体),但可以更新缓冲区已有内容。 -

    -

    - 这意味着如果你知道某个属性会增长(如顶点数增加), - 必须预先分配足够大的缓冲区来容纳新增数据。 - 同时也意味着 BufferGeometry 必然存在最大容量, - 无法做到无限高效扩展。 -

    -

    - 下面以“运行时不断延长的线段”为例: - 先为 500 个顶点分配空间,但起初只绘制 2 个点, - 通过 `BufferGeometry.drawRange` 控制绘制范围。 -

    -
    -const MAX_POINTS = 500;
    -
    -// 几何体
    -const geometry = new THREE.BufferGeometry();
    -
    -// 属性
    -const positions = new Float32Array( MAX_POINTS * 3 ); // 每个点使用 3 个浮点数(x、y、z)
    -geometry.setAttribute( 'position', new THREE.BufferAttribute( positions, 3 ) );
    -
    -// 绘制范围
    -const drawCount = 2; // 仅绘制前 2 个点
    -geometry.setDrawRange( 0, drawCount );
    -
    -// 材质
    -const material = new THREE.LineBasicMaterial( { color: 0xff0000 } );
    -
    -// 线段
    -const line = new THREE.Line( geometry, material );
    -scene.add( line );
    -
    -

    - 接着按如下方式随机写入线段点位: -

    -
    -const positionAttribute = line.geometry.getAttribute( 'position' );
    -
    -let x = 0, y = 0, z = 0;
    -
    -for ( let i = 0; i < positionAttribute.count; i ++ ) {
    -
    -    positionAttribute.setXYZ( i, x, y, z );
    -
    -    x += ( Math.random() - 0.5 ) * 30;
    -    y += ( Math.random() - 0.5 ) * 30;
    -    z += ( Math.random() - 0.5 ) * 30;
    -
    -}
    -
    -

    - 如果要在首次渲染后改变绘制点数量,这样做: -

    -
    -line.geometry.setDrawRange( 0, newValue );
    -
    -

    - 如果要在首次渲染后修改位置数据,需要设置 `needsUpdate`: -

    -
    -positionAttribute.needsUpdate = true; // 首次渲染后修改数据时必须设置
    -
    - -

    - 首次渲染后修改位置数据时,通常还需要重新计算包围体, - 以保证视锥体裁剪、辅助器等功能正常工作。 -

    -
    -line.geometry.computeBoundingBox();
    -line.geometry.computeBoundingSphere();
    -
    - -

    - [link:https://jsfiddle.net/t4m85pLr/1/ 这个 fiddle 示例] - 展示了一个动画线段,你可以按需改造。 -

    - -

    示例

    - -

    - [example:webgl_custom_attributes WebGL / custom / attributes]
    - [example:webgl_buffergeometry_custom_attributes_particles WebGL / buffergeometry / custom / attributes / particles] -

    - -
    - -

    材质

    -
    -

    所有 uniforms 都可以自由修改(如颜色、纹理、不透明度等),并会在每帧传入着色器。

    - -

    GL 状态相关参数也可随时修改(如 depthTest、blending、polygonOffset 等)。

    - -

    以下属性在运行时不易修改(尤其材质至少渲染过一次后):

    -
      -
    • uniform 的数量与类型
    • -
    • 是否启用以下特性 -
        -
      • texture(纹理)
      • -
      • fog(雾)
      • -
      • vertex colors(顶点色)
      • -
      • morphing(变形)
      • -
      • shadow map(阴影贴图)
      • -
      • alpha test(Alpha 测试)
      • -
      • transparent(透明)
      • -
      -
    • -
    - -

    这些变化会触发重建着色器程序,你需要设置:

    - material.needsUpdate = true - -

    注意这一步可能较慢并导致帧率抖动或卡顿(尤其在 Windows 上,DirectX 下编译 shader 往往比 OpenGL 更慢)。

    - -

    为获得更平滑体验,可以通过“占位值”模拟部分开关效果,比如强度为 0 的灯光、纯白纹理或密度为 0 的雾。

    - -

    你可以替换几何体分块所用材质,但无法在运行时改变对象按面材质划分分块的方式。

    - -

    如果你需要在运行时切换多套材质配置:

    -

    若材质/分块数量较少,可提前分块(例如人物:头发/脸/身体/上衣/裤子;汽车:前/侧/顶/玻璃/轮胎/内饰)。

    - -

    若数量很大(例如每个面都可能不同),建议改用属性或纹理驱动每面外观,而非大量分块材质。

    - -

    示例

    -

    - [example:webgl_materials_car WebGL / materials / car]
    - [example:webgl_postprocessing_dof WebGL / webgl_postprocessing / dof] -

    -
    - - -

    纹理

    -
    -

    图像、Canvas、视频和数据纹理若内容有变更,需要设置:

    - - texture.needsUpdate = true; - -

    渲染目标会自动更新。

    - -

    示例

    -

    - [example:webgl_materials_video WebGL / materials / video]
    - [example:webgl_rtt WebGL / rtt] -

    - -
    - -

    相机

    -
    -

    相机位置和目标会自动更新。如果你要修改以下参数:

    -
      -
    • - fov(视野范围) -
    • -
    • - aspect(宽高比) -
    • -
    • - near(近裁剪面) -
    • -
    • - far(远裁剪面) -
    • -
    -

    - 则需要重新计算投影矩阵: -

    -
    -camera.aspect = window.innerWidth / window.innerHeight;
    -camera.updateProjectionMatrix();
    -
    -
    - -

    InstancedMesh

    -
    -

    - `InstancedMesh` 用于在 `three.js` 中便捷地进行实例化渲染。 - 视锥体裁剪、射线检测等功能依赖最新包围体(包围盒和包围球)。 - 由于 `InstancedMesh` 的工作方式,它具有自己的 `boundingBox` 与 `boundingSphere`, - 会覆盖几何体级别的包围体。 -

    -

    - 与几何体类似,只要底层数据变化就应重算包围体。 - 对 `InstancedMesh` 来说,常见场景是通过 `setMatrixAt()` 修改实例变换矩阵后, - 再重算包围体。处理方式与几何体相同。 -

    -
    -instancedMesh.computeBoundingBox();
    -instancedMesh.computeBoundingSphere();
    -
    - -
    - -

    SkinnedMesh

    -
    -

    - 在包围体机制上,`SkinnedMesh` 与 `InstancedMesh` 原则相同: - 它拥有自己的 `boundingBox` 与 `boundingSphere`,用于正确包裹动画中的网格。 - 当调用 `computeBoundingBox()` 与 `computeBoundingSphere()` 时, - 会基于当前骨骼变换(即当前动画状态)计算对应包围体。 -

    -
    - -
    -
    -
    - - - - - - - - - - - diff --git a/manual/zh/how-to-use-post-processing.html b/manual/zh/how-to-use-post-processing.html deleted file mode 100644 index eef2ed827d5663..00000000000000 --- a/manual/zh/how-to-use-post-processing.html +++ /dev/null @@ -1,148 +0,0 @@ - - - 如何使用后处理 - - - - - - - - - - - - - - -
    -
    -

    如何使用后处理

    -
    -
    -
    - -

    - 许多 three.js 应用会把 3D 对象直接渲染到屏幕上。但在一些场景中, - 你会希望叠加景深(DOF)、Bloom、胶片颗粒、抗锯齿等视觉效果。后处理(Post Processing) - 是实现这些效果的常见方案:先把场景渲染到一个渲染目标(显存中的图像缓冲区), - 再通过一个或多个后处理 pass 对该缓冲区应用滤镜与效果,最后输出到屏幕。 -

    -

    - three.js 通过 `EffectComposer` 提供了完整的后处理工作流支持。 -

    - -

    工作流程

    - -

    - 第一步是从 examples 目录导入所需模块。本文默认你使用 three.js 官方 - [link:https://www.npmjs.com/package/three npm 包]。在本教程的基础示例中,我们需要以下文件。 -

    - -
    -import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
    -import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
    -import { GlitchPass } from 'three/addons/postprocessing/GlitchPass.js';
    -import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
    -
    - -

    - 导入完成后,把 `WebGLRenderer` 实例传入,创建 composer。 -

    - -
    -const composer = new EffectComposer( renderer );
    -
    - -

    - 使用 composer 后,需要调整动画循环:不再调用 `WebGLRenderer.render()`, - 而改为调用 `EffectComposer.render()`。 -

    - -
    -function animate() {
    -
    -  requestAnimationFrame( animate );
    -
    -  composer.render();
    -
    -}
    -
    - -

    - 到这里 composer 已准备就绪,可以配置后处理 pass 链。各 pass 按添加顺序依次执行, - 共同决定最终输出结果。在本例中先执行 `RenderPass`,再执行 `GlitchPass`, - 最后执行 `OutputPass`。链路中最后一个启用的 pass 会自动渲染到屏幕。 - 配置如下: -

    - -
    -const renderPass = new RenderPass( scene, camera );
    -composer.addPass( renderPass );
    -
    -const glitchPass = new GlitchPass();
    -composer.addPass( glitchPass );
    -
    -const outputPass = new OutputPass();
    -composer.addPass( outputPass );
    -
    - -

    - `RenderPass` 通常放在链路开头,用于提供场景渲染结果给后续步骤。 - 本例中 `GlitchPass` 会基于图像数据施加故障效果。 - `OutputPass` 一般放在最后,负责 sRGB 色彩空间转换与色调映射(tone mapping)。 - 可参考这个 [link:https://threejs.org/examples/webgl_postprocessing_glitch 在线示例]。 -

    - -

    内置 Pass

    - -

    - 引擎提供了大量预置后处理 pass,可在 - [link:https://github.com/mrdoob/three.js/tree/dev/examples/jsm/postprocessing postprocessing] - 目录中找到。 -

    - -

    自定义 Pass

    - -

    - 如果你要编写自定义后处理着色器并接入 pass 链,可以使用 `ShaderPass`。 - 在导入相关模块和自定义 shader 后,按下述方式添加 pass: -

    - -
    -import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
    -import { LuminosityShader } from 'three/addons/shaders/LuminosityShader.js';
    -
    -// 稍后在你的初始化流程中
    -
    -const luminosityPass = new ShaderPass( LuminosityShader );
    -composer.addPass( luminosityPass );
    -
    - -

    - 仓库中提供的 - [link:https://github.com/mrdoob/three.js/blob/master/examples/jsm/shaders/CopyShader.js CopyShader] - 是编写自定义 shader 的良好起点。`CopyShader` 仅将 `EffectComposer` 的读缓冲内容 - 复制到写缓冲,不附加任何效果。 -

    - -
    -
    -
    - - - - - - - - - - - diff --git a/manual/zh/indexed-textures.html b/manual/zh/indexed-textures.html deleted file mode 100644 index 78108fa3a1651a..00000000000000 --- a/manual/zh/indexed-textures.html +++ /dev/null @@ -1,603 +0,0 @@ - - - - - - 使用纹理索引来拾取和着色 - - - - - - - - - - - - - - - - -
    -
    -

    使用纹理索引来拾取和着色

    -
    -
    -
    -

    这篇文章是 对齐HTML元素到3D对象 的延续。 - 如果你还没有读过上篇文章,你应该先从那里开始,然后再回来继续阅读。

    -

    有时候使用Three.js需要提出一些创造性的解决思路。我不确定这是一个很好的解决方案,但我想我会分享它,你可以看看是否可以为你的需求提供了一些解决思路或方案。

    -

    上一篇文章中,我们在3D地球周围显示了国家名称,那么我们如何做到,让用户选中一个国家并高亮他的选择?

    -

    第一个想法是为每个国家生成几何图形,我们可以 使用射线拾取 ,就像之前介绍的那样。 - 我们将为每个国家构建3D几何对象。如果用户点击代表那个国家的网格对象,我们就会知道对应的国家被点击了。

    -

    所以,为了验证这个解决方案,我尝试生成所有国家的3D网格对象,使用了在上一篇文章中和我生成轮廓一样的数据。 - 结果生成了15.5m的二进制GLTF(.glb)文件,让用户下载15.5m的数据对于我来说实在太多了。 -

    -

    有很多方法可以压缩数据。第一种可能是应用一些算法来降低轮廓的分辨率,但是我没有花时间来研究它。可能出现美国边界变大而加拿大边界变小的情况。

    -

    另一种解决方案是仅使用数据压缩,比如gzip将其降至11m,这减少了30%,但是还不够。

    -

    我们可以将所有数据存储为16位而不是32位浮点值。或者我们也可以使用像draco 压缩 - 这种东西也许就够了。不过我没有去试,我推荐你去试下回来告诉我是怎么回事,因为我很想知道😅

    -

    就我而言,我考虑使用 GPU拾取方案, - 这在上一篇 关于拾取的文章 的最后有提到。这种方案中,我们使用一种独特的颜色代表不同网格对象的ID,然后我们绘制了所有网格,看看哪个颜色被点击了。

    -

    基于这种灵感,我们可以预先生成一张国家的地图,每个国家的颜色是它在国家数组中的索引号。我们可以使用类似GPU拾取技术,我们使用索引纹理绘制一个离屏全局画布,查看颜色会告诉我们用户点击了那个国家ID。

    -

    因此,我 写了一些代码 - 生成这样的一个纹理,在这里:

    -
    - -

    注意:生成这份纹理的数据来源于 这个网站 - ,使用的协议是 CC-BY-SA

    -

    它只有217k,比国家网格对象的15m要好得多,事实上我们可以使用更低的分辨率,但现在217k似乎已经足够了。

    -

    所以让我们试着用它来选择国家。

    -

    GPU拾取案例中 获取代码,我们需要一个场景来做拾取。

    -
    const pickingScene = new THREE.Scene();
    -pickingScene.background = new THREE.Color(0);
    -
    -

    我们需要将带有索引纹理的地球添加到拾取场景中。

    -
    {
    -  const loader = new THREE.TextureLoader();
    -  const geometry = new THREE.SphereGeometry(1, 64, 32);
    -
    -+  const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
    -+  indexTexture.minFilter = THREE.NearestFilter;
    -+  indexTexture.magFilter = THREE.NearestFilter;
    -+
    -+  const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture});
    -+  pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
    -
    -  const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
    -  const material = new THREE.MeshBasicMaterial({map: texture});
    -  scene.add(new THREE.Mesh(geometry, material));
    -}
    -
    -

    然后我们把 GPUPickingHelper这个类拷贝下,在使用前我们需要做一些小改动

    -
    class GPUPickHelper {
    -  constructor() {
    -    // 创造一个 1x1 的渲染对象
    -    this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
    -    this.pixelBuffer = new Uint8Array(4);
    --    this.pickedObject = null;
    --    this.pickedObjectSavedColor = 0;
    -  }
    -  pick(cssPosition, scene, camera) {
    -    const {pickingTexture, pixelBuffer} = this;
    -
    -    // 将视图偏移设置为仅表示鼠标下单个元素
    -    const pixelRatio = renderer.getPixelRatio();
    -    camera.setViewOffset(
    -        renderer.getContext().drawingBufferWidth,   // full width
    -        renderer.getContext().drawingBufferHeight,  // full top
    -        cssPosition.x * pixelRatio | 0,             // rect x
    -        cssPosition.y * pixelRatio | 0,             // rect y
    -        1,                                          // rect width
    -        1,                                          // rect height
    -    );
    -    // 渲染场景
    -    renderer.setRenderTarget(pickingTexture);
    -    renderer.render(scene, camera);
    -    renderer.setRenderTarget(null);
    -    // 清除视图偏移,使渲染恢复正常
    -    camera.clearViewOffset();
    -    // 读取像素
    -    renderer.readRenderTargetPixels(
    -        pickingTexture,
    -        0,   // x
    -        0,   // y
    -        1,   // width
    -        1,   // height
    -        pixelBuffer);
    -
    -+    const id =
    -+        (pixelBuffer[0] << 16) |
    -+        (pixelBuffer[1] <<  8) |
    -+        (pixelBuffer[2] <<  0);
    -+
    -+    return id;
    --    const id =
    --        (pixelBuffer[0] << 16) |
    --        (pixelBuffer[1] <<  8) |
    --        (pixelBuffer[2]      );
    --    const intersectedObject = idToObject[id];
    --    if (intersectedObject) {
    --      // 获取第一个对象,它是离我们最近的
    --      this.pickedObject = intersectedObject;
    --      // 保存它的颜色
    --      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
    --      // 将其自发光颜色设置为闪烁的红色/黄色
    --      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
    --    }
    -  }
    -}
    -
    -

    现在我们可以用它来选择国家了。

    -
    const pickHelper = new GPUPickHelper();
    -
    -function getCanvasRelativePosition(event) {
    -  const rect = canvas.getBoundingClientRect();
    -  return {
    -    x: (event.clientX - rect.left) * canvas.width  / rect.width,
    -    y: (event.clientY - rect.top ) * canvas.height / rect.height,
    -  };
    -}
    -
    -function pickCountry(event) {
    -  // 如果我们还没有加载好数据,退出
    -  if (!countryInfos) {
    -    return;
    -  }
    -
    -  const position = getCanvasRelativePosition(event);
    -  const id = pickHelper.pick(position, pickingScene, camera);
    -  if (id > 0) {
    -    // 我们点击了一个国家,修改它的selected属性
    -    const countryInfo = countryInfos[id - 1];
    -    const selected = !countryInfo.selected;
    -    // 如果我们选中这个国家,并且没有按住控制键,取消所有选中的国家
    -    if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
    -      unselectAllCountries();
    -    }
    -    numCountriesSelected += selected ? 1 : -1;
    -    countryInfo.selected = selected;
    -  } else if (numCountriesSelected) {
    -    // 海洋或者天空被选中了
    -    unselectAllCountries();
    -  }
    -  requestRenderIfNotRequested();
    -}
    -
    -function unselectAllCountries() {
    -  numCountriesSelected = 0;
    -  countryInfos.forEach((countryInfo) => {
    -    countryInfo.selected = false;
    -  });
    -}
    -
    -canvas.addEventListener('pointerup', pickCountry);
    -
    -

    上面的代码,设置/重置了国家数组元素的 selected 属性。如果 shiftctrlcmd - 被按下了,你就可以选择多个国家。

    -

    剩下的就是显示选择的国家,现在让我们更新标签

    -
    function updateLabels() {
    -  // 如果我们还没有加载好数据,退出
    -  if (!countryInfos) {
    -    return;
    -  }
    -
    -  const large = settings.minArea * settings.minArea;
    -  // 获取表示相机正对方向的矩阵
    -  normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
    -  // 获取相机位置
    -  camera.getWorldPosition(cameraPosition);
    -  for (const countryInfo of countryInfos) {
    --    const {position, elem, area} = countryInfo;
    --    // 足够大了?
    --    if (area < large) {
    -+    const {position, elem, area, selected} = countryInfo;
    -+    const largeEnough = area >= large;
    -+    const show = selected || (numCountriesSelected === 0 && largeEnough);
    -+    if (!show) {
    -      elem.style.display = 'none';
    -      continue;
    -    }
    -
    -    ...
    -
    -

    通过上面的代码,我们就有能力拾取对应的国家了。

    -

    - - -

    -

    代码仍然会根据地区显示对应的国家。不过如果你点击一个,只会显示对应的标签。

    -

    所以这似乎是选择国家的有效解决方案,但是如何突出显示选定的国家?

    -

    我们可以从 调色板图形算法 中获取灵感。

    -

    调色板算法 - 或者 索引颜色 - 是被旧的系统,比如Atari 800、Amiga、NES、Super Nintendolder及IBM - PCs所使用的。并非以RGBA的形式给每个颜色存储8位、每个像素至少32字节的位图,他们存储位图是8位或者更少。每个像素都是一个调色板的索引值, - 所以举例一个像素值为3,表示“显示3号颜色值”,而定义3号颜色值的地方就叫“调色板”。

    -

    在JavaScript中就像这样:

    -
    const face7x7PixelImageData = [
    -  0, 1, 1, 1, 1, 1, 0,
    -  1, 0, 0, 0, 0, 0, 1,
    -  1, 0, 2, 0, 2, 0, 1,
    -  1, 0, 0, 0, 0, 0, 1,
    -  1, 0, 3, 3, 3, 0, 1,
    -  1, 0, 0, 0, 0, 0, 1,
    -  0, 1, 1, 1, 1, 1, 1,
    -];
    -
    -const palette = [
    -  [255, 255, 255],  // 白
    -  [  0,   0,   0],  // 黑
    -  [  0, 255, 255],  // 青
    -  [255,   0,   0],  // 红
    -];
    -
    -

    图像数据中每个像素都是调色板的索引,如果你分析上面调色板的数据你会得到这个图像:

    -
    - -

    - 在我们的例子中,上面已经有一个用不同id代表不同国家的纹理了,所以我们可以通过调色板使用相同的纹理赋予每个国家各自的颜色。通过更改调色板颜色我们可以为单独的国家着色。比如通过设置整个调色板纹理为黑色,给某个国家使用不同的颜色,就可以凸显那个国家了。 -

    -

    要做调色板索引的话,需要一些自定义着色器代码,让我们修改Three.js中默认的着色器,这样我们也可以根据需要使用照明或其他特性。

    -

    就像我们在 大量移动物体的优化 这篇文章中提到的,通过 - onBeforeCompile 属性,我们可以通过向材质添加函数来修改默认的着色器。 -

    -

    默认的片元着色器在编译之前看起来就像这样:

    -
    #include <common>
    -#include <color_pars_fragment>
    -#include <uv_pars_fragment>
    -#include <map_pars_fragment>
    -#include <alphamap_pars_fragment>
    -#include <aomap_pars_fragment>
    -#include <lightmap_pars_fragment>
    -#include <envmap_pars_fragment>
    -#include <fog_pars_fragment>
    -#include <specularmap_pars_fragment>
    -#include <logdepthbuf_pars_fragment>
    -#include <clipping_planes_pars_fragment>
    -void main() {
    -    #include <clipping_planes_fragment>
    -    vec4 diffuseColor = vec4( diffuse, opacity );
    -    #include <logdepthbuf_fragment>
    -    #include <map_fragment>
    -    #include <color_fragment>
    -    #include <alphamap_fragment>
    -    #include <alphatest_fragment>
    -    #include <specularmap_fragment>
    -    ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );
    -    #ifdef USE_LIGHTMAP
    -        reflectedLight.indirectDiffuse += texture2D( lightMap, vLightMapUv ).xyz * lightMapIntensity;
    -    #else
    -        reflectedLight.indirectDiffuse += vec3( 1.0 );
    -    #endif
    -    #include <aomap_fragment>
    -    reflectedLight.indirectDiffuse *= diffuseColor.rgb;
    -    vec3 outgoingLight = reflectedLight.indirectDiffuse;
    -    #include <envmap_fragment>
    -    gl_FragColor = vec4( outgoingLight, diffuseColor.a );
    -    #include <premultiplied_alpha_fragment>
    -    #include <tonemapping_fragment>
    -    #include <colorspace_fragment>
    -    #include <fog_fragment>
    -}
    -
    -

    查看所有的片段 - 我们发现THREE.js使用了一个名为 diffuseColor 的变量去管理基本材质颜色。它在这里设置: <color_fragment> 片段 - 所以我们应该能够在这部分之后进行修改。

    -

    diffuseColor - 在这个时刻应该已经是从我们轮廓纹理中获取的颜色了,所以我们应该可以从调色盘中获取颜色,然后把他们和最终颜色混合。

    -

    就像我们 之前做的那样 , - 在Material.onBeforeCompile我们使用一个用来搜索和替换着色器代码的数组。 -

    -
    {
    -  const loader = new THREE.TextureLoader();
    -  const geometry = new THREE.SphereGeometry(1, 64, 32);
    -
    -  const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
    -  indexTexture.minFilter = THREE.NearestFilter;
    -  indexTexture.magFilter = THREE.NearestFilter;
    -
    -  const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture});
    -  pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
    -
    -+  const fragmentShaderReplacements = [
    -+    {
    -+      from: '#include <common>',
    -+      to: `
    -+        #include <common>
    -+        uniform sampler2D indexTexture;
    -+        uniform sampler2D paletteTexture;
    -+        uniform float paletteTextureWidth;
    -+      `,
    -+    },
    -+    {
    -+      from: '#include <color_fragment>',
    -+      to: `
    -+        #include <color_fragment>
    -+        {
    -+          vec4 indexColor = texture2D(indexTexture, vUv);
    -+          float index = indexColor.r * 255.0 + indexColor.g * 255.0 * 256.0;
    -+          vec2 paletteUV = vec2((index + 0.5) / paletteTextureWidth, 0.5);
    -+          vec4 paletteColor = texture2D(paletteTexture, paletteUV);
    -+          // diffuseColor.rgb += paletteColor.rgb;   // 白轮廓
    -+          diffuseColor.rgb = paletteColor.rgb - diffuseColor.rgb;  // 黑轮廓
    -+        }
    -+      `,
    -+    },
    -+  ];
    -
    -  const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
    -  const material = new THREE.MeshBasicMaterial({map: texture});
    -+  material.onBeforeCompile = function(shader) {
    -+    fragmentShaderReplacements.forEach((rep) => {
    -+      shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
    -+    });
    -+  };
    -  scene.add(new THREE.Mesh(geometry, material));
    -}
    -
    -

    在上面可以看到我们添加了3个uniforms变量,indexTexture, paletteTexture, - and paletteTextureWidth。我们从 indexTexture - 获取颜色,并且把它转化成索引下标。 vUv - 是由Three.js提供的纹理坐标。然后我们使用索引下标从调色板中获取颜色。然后我们使用当前的 diffuseColor和最终的结果作混合。 - diffuseColor在此时是我们黑色纹理,而调色盘是白色纹理。所以如果我们相加两个颜色,得出的是白色轮廓。如果我们二者相减,得出的是黑色轮廓。 -

    -

    在我们渲染前,我们还需要设置调色板纹理,以及这3个uniforms变量。

    -

    对于调色板纹理,它只需要足够宽即可。每个国家保留一种颜色 + 一种海洋颜色。这里有240个国家或地区,我们可以等到国家列表加载完成后以获取确切的数字来查找。不过选择一些更大的数字没有危害,所以让我们选择512。

    -

    这里是创建调色板的代码

    -
    const maxNumCountries = 512;
    -const paletteTextureWidth = maxNumCountries;
    -const paletteTextureHeight = 1;
    -const palette = new Uint8Array(paletteTextureWidth * 4);
    -const paletteTexture = new THREE.DataTexture(
    -    palette, paletteTextureWidth, paletteTextureHeight);
    -paletteTexture.minFilter = THREE.NearestFilter;
    -paletteTexture.magFilter = THREE.NearestFilter;
    -
    -

    一个DataTexture 提供原始的纹理数据。在这种情况下,我们给他512个RGBA颜色,每个颜色4字节,每个包含0-255的红、绿、蓝分量。

    -

    让我们用随机颜色填充它,只是为了看看它是否有效

    -
    for (let i = 1; i < palette.length; ++i) {
    -  palette[i] = Math.random() * 256;
    -}
    -// 设置海洋颜色 (索引 #0)
    -palette.set([100, 200, 255, 255], 0);
    -paletteTexture.needsUpdate = true;
    -
    -

    任何时候我们想要Three.js通过 palette 数组的内容来更新调色板上的纹理,我们需要去设置paletteTexture.needsUpdate - 为 true

    -

    然后我们还需要设置材质上的uniforms变量

    -
    const geometry = new THREE.SphereGeometry(1, 64, 32);
    -const material = new THREE.MeshBasicMaterial({map: texture});
    -material.onBeforeCompile = function(shader) {
    -  fragmentShaderReplacements.forEach((rep) => {
    -    shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
    -  });
    -+  shader.uniforms.paletteTexture = {value: paletteTexture};
    -+  shader.uniforms.indexTexture = {value: indexTexture};
    -+  shader.uniforms.paletteTextureWidth = {value: paletteTextureWidth};
    -};
    -scene.add(new THREE.Mesh(geometry, material));
    -
    -

    这样我们就得到了随机着色的国家

    -

    - - -

    -

    现在我们可以看到索引和调色板纹理生效了,让我们控制调色板进行高亮显示

    -

    首先让我们创建一个函数,我们传入一个THREE.js颜色,并格式化为可以放入调色板纹理的值。

    -
    const tempColor = new THREE.Color();
    -function get255BasedColor(color) {
    -  tempColor.set(color);
    -  const base = tempColor.toArray().map(v => v * 255);
    -  base.push(255); // alpha
    -  return base;
    -}
    -
    -

    像这样来调用 color = get255BasedColor('red') 会返回一个像 [255, 0, 0, 255]这样的数组。

    -

    接下来让我们用它来生成一些颜色并填充调色板。

    -
    const selectedColor = get255BasedColor('red');
    -const unselectedColor = get255BasedColor('#444');
    -const oceanColor = get255BasedColor('rgb(100,200,255)');
    -resetPalette();
    -
    -function setPaletteColor(index, color) {
    -  palette.set(color, index * 4);
    -}
    -
    -function resetPalette() {
    -  // 让所有的颜色都是未选择状态的颜色
    -  for (let i = 1; i < maxNumCountries; ++i) {
    -    setPaletteColor(i, unselectedColor);
    -  }
    -
    -  // 设置海洋颜色 (索引 #0)
    -  setPaletteColor(0, oceanColor);
    -  paletteTexture.needsUpdate = true;
    -}
    -
    -

    现在让我们使用这些函数来更新调色板,当一个国家被选中时:

    -
    function getCanvasRelativePosition(event) {
    -  const rect = canvas.getBoundingClientRect();
    -  return {
    -    x: (event.clientX - rect.left) * canvas.width  / rect.width,
    -    y: (event.clientY - rect.top ) * canvas.height / rect.height,
    -  };
    -}
    -
    -function pickCountry(event) {
    -  // 如果我们还没有加载好数据,退出
    -  if (!countryInfos) {
    -    return;
    -  }
    -
    -  const position = getCanvasRelativePosition(event);
    -  const id = pickHelper.pick(position, pickingScene, camera);
    -  if (id > 0) {
    -    const countryInfo = countryInfos[id - 1];
    -    const selected = !countryInfo.selected;
    -    if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
    -      unselectAllCountries();
    -    }
    -    numCountriesSelected += selected ? 1 : -1;
    -    countryInfo.selected = selected;
    -+    setPaletteColor(id, selected ? selectedColor : unselectedColor);
    -+    paletteTexture.needsUpdate = true;
    -  } else if (numCountriesSelected) {
    -    unselectAllCountries();
    -  }
    -  requestRenderIfNotRequested();
    -}
    -
    -function unselectAllCountries() {
    -  numCountriesSelected = 0;
    -  countryInfos.forEach((countryInfo) => {
    -    countryInfo.selected = false;
    -  });
    -+  resetPalette();
    -}
    -
    -

    我们应该能够突出显示1个或多个国家。

    -

    - - -

    -

    这看起来有效!

    -

    一件小事是我们不能不改变选中状态就渲染地球。如果我们选择一个国家然后想要旋转地球,选中状态将改变。

    -

    让我们尝试解决这个问题。我认为,我们可以检查两件事情。点击和松开经过了多少时间;用户是否移动了鼠标。如果时间很短,或者他们没有移动鼠标,那么这个行为可能是点击。否则他们可能正在尝试拖动地球。

    -
    +const maxClickTimeMs = 200;
    -+const maxMoveDeltaSq = 5 * 5;
    -+const startPosition = {};
    -+let startTimeMs;
    -+
    -+function recordStartTimeAndPosition(event) {
    -+  startTimeMs = performance.now();
    -+  const pos = getCanvasRelativePosition(event);
    -+  startPosition.x = pos.x;
    -+  startPosition.y = pos.y;
    -+}
    -
    -function getCanvasRelativePosition(event) {
    -  const rect = canvas.getBoundingClientRect();
    -  return {
    -    x: (event.clientX - rect.left) * canvas.width  / rect.width,
    -    y: (event.clientY - rect.top ) * canvas.height / rect.height,
    -  };
    -}
    -
    -function pickCountry(event) {
    -  // exit if we have not loaded the data yet
    -  if (!countryInfos) {
    -    return;
    -  }
    -
    -+  // 如果用户触发后已经过了一段时间了
    -+  // 就认为这是一个拖动行为
    -+  const clickTimeMs = performance.now() - startTimeMs;
    -+  if (clickTimeMs > maxClickTimeMs) {
    -+    return;
    -+  }
    -+
    -+  // 如果鼠标移动了,就认为这是一个拖动行为
    -+  const position = getCanvasRelativePosition(event);
    -+  const moveDeltaSq = (startPosition.x - position.x) ** 2 +
    -+                      (startPosition.y - position.y) ** 2;
    -+  if (moveDeltaSq > maxMoveDeltaSq) {
    -+    return;
    -+  }
    -
    --  const position = {x: event.clientX, y: event.clientY};
    -  const id = pickHelper.pick(position, pickingScene, camera);
    -  if (id > 0) {
    -    const countryInfo = countryInfos[id - 1];
    -    const selected = !countryInfo.selected;
    -    if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
    -      unselectAllCountries();
    -    }
    -    numCountriesSelected += selected ? 1 : -1;
    -    countryInfo.selected = selected;
    -    setPaletteColor(id, selected ? selectedColor : unselectedColor);
    -    paletteTexture.needsUpdate = true;
    -  } else if (numCountriesSelected) {
    -    unselectAllCountries();
    -  }
    -  requestRenderIfNotRequested();
    -}
    -
    -function unselectAllCountries() {
    -  numCountriesSelected = 0;
    -  countryInfos.forEach((countryInfo) => {
    -    countryInfo.selected = false;
    -  });
    -  resetPalette();
    -}
    -
    -+canvas.addEventListener('pointerdown', recordStartTimeAndPosition);
    -canvas.addEventListener('pointerup', pickCountry);
    -
    -

    添加了这些操作,这看起来 对我有效。

    -

    - - -

    -

    我不是用户交互的专家,所以我很想知道是否有更好的解决方案。

    -

    我希望这能让你了解图形索引的用处,以及如何修改Three.js的着色器以添加简单的功能。对于如何使用GLSL,编写着色器语言对于本文来说太多了,这个链接有一些少量的信息,参考 - 关于后处理的这篇文章。 -

    - -
    -
    -
    - - - - - - - - - - \ No newline at end of file diff --git a/manual/zh/installation.html b/manual/zh/installation.html deleted file mode 100644 index 765b81b6b0f3e5..00000000000000 --- a/manual/zh/installation.html +++ /dev/null @@ -1,295 +0,0 @@ - - - 安装 - - - - - - - - - - - - - -
    -
    -

    安装

    -
    -
    -
    - -

    项目结构

    - -

    - 每个 three.js 项目至少需要一个 HTML 文件来定义网页,以及一个 JavaScript 文件来运行你的 three.js 代码。下面的结构和命名方式并非强制要求,但为了保持一致性,本指南将始终使用这种方式。 -

    - -
      -
    • - index.html -
      -<!DOCTYPE html>
      -<html lang="en">
      -  <head>
      -    <meta charset="utf-8">
      -    <title>My first three.js app</title>
      -    <style>
      -      body { margin: 0; }
      -    </style>
      -  </head>
      -  <body>
      -    <script type="module" src="/main.js"></script>
      -  </body>
      -</html>
      -    
      -
    • -
    • - main.js -
      -import * as THREE from 'three';
      -
      -...
      -
      -
    • -
    • - public/ -
        -
      • - public/ 文件夹有时也被称为"static"(静态)文件夹,因为其中的文件会原封不动地发布到网站上。通常纹理、音频和 3D 模型会放在这里。 -
      • -
      -
    • -
    - -

    - 现在我们已经搭建好了基本的项目结构,接下来需要一种方式在本地运行项目并通过浏览器访问它。安装和本地开发可以通过 npm 和构建工具来完成,也可以通过从 CDN 导入 three.js 来实现。下面将分别介绍这两种方式。 -

    - -

    方式一:使用 NPM 和构建工具安装

    - -

    开发

    - -

    - 从 [link:https://www.npmjs.com/ npm 包注册表] 安装并使用 [link:https://eloquentjavascript.net/10_modules.html#h_zWTXAU93DC 构建工具] 对大多数用户来说是推荐的方式——你的项目依赖越多,就越可能遇到静态托管难以解决的问题。使用构建工具时,导入本地 JavaScript 文件和 npm 包无需 import map 即可直接使用。 -

    - - -
      -
    1. - 安装 [link:https://nodejs.org/ Node.js]。我们需要它来管理依赖和运行构建工具。 -
    2. -
    3. -

      - 在项目文件夹中打开 [link:https://www.joshwcomeau.com/javascript/terminal-for-js-devs/ 终端],安装 three.js 和构建工具 [link:https://vitejs.dev/ Vite]。Vite 仅在开发过程中使用,不会成为最终网页的一部分。如果你更喜欢使用其他构建工具也没问题——我们支持任何能导入 [link:https://eloquentjavascript.net/10_modules.html#h_zWTXAU93DC ES Modules] 的现代构建工具。 -

      -
      -# three.js
      -npm install --save three
      -
      -# vite
      -npm install --save-dev vite
      -
      - -
    4. -
    5. - 在终端中运行: -
      npx vite 
      - -
    6. -
    7. - 如果一切顺利,你会在终端中看到一个类似 http://localhost:5173 的 URL,打开该 URL 即可查看你的 Web 应用。 -
    8. -
    - -

    - 页面将是空白的——你已经准备好创建一个场景了。 -

    - -

    - 如果你想在继续之前了解更多关于这些工具的信息,请参阅: -

    - -
      -
    • - [link:https://threejs-journey.com/lessons/local-server three.js journey: 本地服务器] -
    • -
    • - [link:https://cn.vite.dev/guide/cli Vite: 命令行接口] -
    • -
    • - [link:https://developer.mozilla.org/zh-CN/docs/Learn_web_development/Extensions/Client-side_tools/Package_management MDN: 包管理基础] -
    • -
    - -

    生产环境

    - -

    - 稍后,当你准备部署 Web 应用时,只需让 Vite 执行生产构建——npx vite build。应用使用的所有内容都会被编译、优化并复制到 dist/ 文件夹中。该文件夹的内容即可直接托管到你的网站上。 -

    - -

    方式二:从 CDN 导入

    - -

    开发

    - -

    不使用构建工具进行安装需要对上述项目结构做一些修改。

    - -
      -
    1. -

      - 我们在 main.js 中从 'three'(一个 npm 包)导入了代码,但浏览器并不知道这意味着什么。在 index.html 中,我们需要添加一个 [link:https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap import map] 来定义从哪里获取该包。将以下代码放在 <head></head> 标签内,样式之后。 -

      -
      -<script type="importmap">
      -{
      -  "imports": {
      -    "three": "https://cdn.jsdelivr.net/npm/three@<version>/build/three.module.js",
      -    "three/addons/": "https://cdn.jsdelivr.net/npm/three@<version>/examples/jsm/"
      -  }
      -}
      -</script>
      -
      -

      - 别忘了将 <version> 替换为 three.js 的实际版本号,例如 "v0.149.0"。最新版本可以在 [link:https://www.npmjs.com/package/three?activeTab=versions npm 版本列表] 中找到。 -

      -
    2. -
    3. -

      - 我们还需要运行一个本地服务器来将这些文件托管到浏览器可以访问的 URL 上。虽然技术上可以双击 HTML 文件在浏览器中打开,但出于安全原因,我们后续要实现的重要功能在以这种方式打开页面时无法正常工作。 -

      -

      - 安装 [link:https://nodejs.org/ Node.js],然后在项目目录中运行 [link:https://www.npmjs.com/package/serve serve] 来启动本地服务器: -

      -
      npx serve .
      -
    4. -
    5. - 如果一切顺利,你会在终端中看到一个类似 http://localhost:3000 的 URL,打开该 URL 即可查看你的 Web 应用。 -
    6. -
    - -

    - 页面将是空白的——你已经准备好 [link:#manual/introduction/Creating-a-scene 创建一个场景] 了。 -

    - -

    - 还有许多其他本地静态服务器可供选择——有些使用 Node.js 以外的语言,有些是桌面应用程序。它们的工作方式基本相同,下面列出了一些替代方案。 -

    - -
    - 更多本地服务器 - -

    命令行

    - -

    命令行本地服务器从终端窗口运行。可能需要先安装相应的编程语言。

    - -
      -
    • npx http-server (Node.js)
    • -
    • npx five-server (Node.js)
    • -
    • python -m SimpleHTTPServer (Python 2.x)
    • -
    • python -m http.server (Python 3.x)
    • -
    • php -S localhost:8000 (PHP 5.4+)
    • -
    - - -

    图形界面

    - -

    图形界面本地服务器以应用程序窗口的形式在你的计算机上运行,可能带有用户界面。

    - -
      -
    • [link:https://greggman.github.io/servez Servez]
    • -
    - -

    代码编辑器插件

    - -

    一些代码编辑器提供插件,可按需启动简单服务器。

    - -
      -
    • [link:https://marketplace.visualstudio.com/items?itemName=yandeu.five-server Five Server],适用于 Visual Studio Code
    • -
    • [link:https://marketplace.visualstudio.com/items?itemName=ritwickdey.LiveServer Live Server],适用于 Visual Studio Code
    • -
    • [link:https://atom.io/packages/atom-live-server Live Server],适用于 Atom
    • -
    - - -
    - -

    生产环境

    - -

    - 当你准备部署 Web 应用时,只需将源文件推送到你的网站托管服务商——无需构建或编译任何内容。这种方式的缺点是,你需要小心地确保 import map 与应用所需的所有依赖(以及依赖的依赖!)同步更新。如果托管这些依赖的 CDN 暂时宕机,你的网站也会停止工作。 -

    - -

    - 重要提示:所有依赖都应从同一版本的 three.js 和同一个 CDN 导入。混合使用不同来源的文件可能导致代码被重复引入,甚至以意想不到的方式破坏应用。 -

    - -

    附加组件

    - -

    - three.js 开箱即用地包含了 3D 引擎的基础功能。其他 three.js 组件——如控制器、加载器和后期处理效果——属于 [link:https://github.com/mrdoob/three.js/tree/dev/examples/jsm addons/] 目录的一部分。附加组件不需要单独安装,但需要单独导入。 -

    - -

    - 下面的示例展示了如何导入 three.js 以及 `OrbitControls` 和 `GLTFLoader` 附加组件。在必要时,每个附加组件的文档或示例中也会提到这一点。 -

    - -
    -import * as THREE from 'three';
    -import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
    -import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
    -
    -const controls = new OrbitControls( camera, renderer.domElement );
    -const loader = new GLTFLoader();
    -
    - -

    - 也有一些优秀的第三方项目可用于 three.js。这些需要单独安装——请参阅库和插件。 -

    - -

    下一步

    - -

    - 你现在已经准备好创建一个场景了。 -

    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/lang.css b/manual/zh/lang.css deleted file mode 100644 index 761438e0126911..00000000000000 --- a/manual/zh/lang.css +++ /dev/null @@ -1,4 +0,0 @@ -:root { - --article-font-family: "PingFang SC, Microsoft YaHei"; - --headline-font-family: "PingFang SC, Microsoft YaHei"; -} \ No newline at end of file diff --git a/manual/zh/libraries-and-plugins.html b/manual/zh/libraries-and-plugins.html deleted file mode 100644 index 813cfdfb6ec524..00000000000000 --- a/manual/zh/libraries-and-plugins.html +++ /dev/null @@ -1,142 +0,0 @@ - - - 库与插件 - - - - - - - - - - - - - -
    -
    -

    库与插件

    -
    -
    -
    - -

    - 这里列出由外部开发、与 three.js 兼容的库和插件。此列表及相关包由社区维护,不能保证是最新的。如需更新该列表,请提交 PR! -

    - -

    物理引擎

    - -
      -
    • [link:https://github.com/lo-th/Oimo.js/ Oimo.js]
    • -
    • [link:https://enable3d.io/ enable3d]
    • -
    • [link:https://github.com/kripken/ammo.js/ ammo.js]
    • -
    • [link:https://github.com/pmndrs/cannon-es cannon-es]
    • -
    • [link:https://rapier.rs/ rapier]
    • -
    • [link:https://github.com/jrouwe/JoltPhysics.js Jolt]
    • - -
    - -

    后处理

    - -

    - 除了 [link:https://github.com/mrdoob/three.js/tree/dev/examples/jsm/postprocessing three.js 官方后处理效果]之外,外部库还提供对一些额外效果和框架的支持。 -

    - -
      -
    • [link:https://github.com/vanruesc/postprocessing postprocessing]
    • -
    - -

    相交检测与射线检测性能

    - -
      -
    • [link:https://github.com/gkjohnson/three-mesh-bvh three-mesh-bvh]
    • -
    - -

    路径追踪

    - -
      -
    • [link:https://github.com/gkjohnson/three-gpu-pathtracer three-gpu-pathtracer]
    • -
    - -

    文件格式

    - -

    - 除了 [link:https://github.com/mrdoob/three.js/tree/dev/examples/jsm/loaders three.js 官方加载器]之外,外部库还提供对一些额外格式的支持。 -

    - -
      -
    • [link:https://github.com/gkjohnson/urdf-loaders/tree/master/javascript urdf-loader]
    • -
    • [link:https://github.com/NASA-AMMOS/3DTilesRendererJS 3d-tiles-renderer-js]
    • -
    • [link:https://github.com/kaisalmen/WWOBJLoader WebWorker OBJLoader]
    • -
    • [link:https://github.com/IFCjs/web-ifc-three IFC.js]
    • -
    - -

    几何体

    - -
      -
    • [link:https://github.com/spite/THREE.MeshLine THREE.MeshLine]
    • -
    - -

    3D 文本与布局

    - -
      -
    • [link:https://github.com/protectwise/troika/tree/master/packages/troika-three-text troika-three-text]
    • -
    • [link:https://github.com/felixmariotto/three-mesh-ui three-mesh-ui]
    • -
    - -

    粒子系统

    - -
      -
    • [link:https://github.com/Alchemist0823/three.quarks three.quarks]
    • -
    • [link:https://github.com/creativelifeform/three-nebula three-nebula]
    • -
    - -

    逆运动学

    - -
      -
    • [link:https://github.com/jsantell/THREE.IK THREE.IK]
    • -
    • [link:https://github.com/lo-th/fullik fullik]
    • -
    • [link:https://github.com/gkjohnson/closed-chain-ik-js closed-chain-ik]
    • -
    - -

    游戏 AI

    - -
      -
    • [link:https://mugen87.github.io/yuka/ yuka]
    • -
    • [link:https://github.com/donmccurdy/three-pathfinding three-pathfinding]
    • -
    • [link:https://github.com/isaac-mason/recast-navigation-js recast-navigation-js]
    • -
    - -

    封装与框架

    - -
      -
    • [link:https://aframe.io/ A-Frame]
    • -
    • [link:https://lume.io/ Lume] - 基于 Three 构建的 3D 图形 HTML 元素。
    • -
    • [link:https://github.com/pmndrs/react-three-fiber react-three-fiber] - 基于 Three 构建的 3D 图形 React 组件。
    • -
    • [link:https://threepipe.org/ threepipe] - 使用 three.js 进行渲染的多功能 3D 查看器框架。
    • -
    • [link:https://github.com/ecsyjs/ecsy-three ECSY]
    • -
    • [link:https://threlte.xyz/ Threlte] - 基于 Three 构建的 3D 图形 Svelte 组件。
    • -
    • [link:https://needle.tools/ Needle Engine]
    • -
    • [link:https://tresjs.org/ tresjs] - 基于 Three 构建的 3D 图形 Vue 组件。
    • -
    • [link:https://giro3d.org Giro3D] - 基于 Three 构建的多功能框架,用于可视化和交互地理空间 2D、2.5D 和 3D 数据。
    • -
    • [link:https://zap.works/mattercraft/ Mattercraft] - 基于 three.js 的浏览器端可视化编辑器,用于 AR、WebXR 和 3D 网页内容,支持实时预览和物理引擎。
    • -
    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/lights.html b/manual/zh/lights.html deleted file mode 100644 index 1024b6fff685e4..00000000000000 --- a/manual/zh/lights.html +++ /dev/null @@ -1,400 +0,0 @@ - - - 光照 - - - - - - - - - - - - - - -
    -
    -

    光照

    -
    -
    -
    -

    本文是关于 three.js 系列文章的一部分。第一篇文章是 three.js 基础。如果你还没看过而且对three.js 还不熟悉,那应该从那里开始,并且了解如何设置开发环境。上一篇文章介绍了 three.js 中的 纹理

    -

    接下来我们学习如何在 three.js 中使用各种不同类型的光照。

    -

    在一个基本场景的基础上,我们调整一下相机的设置。将 fov 设置为 45, far 设置为 100,然后移动相机位置到 (0, 10, 20)。

    -
    *const fov = 45;
    -const aspect = 2;  // canvas 的默认宽高 300:150
    -const near = 0.1;
    -*const far = 100;
    -const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -+camera.position.set(0, 10, 20);
    -
    -

    然后我们添加一个 OrbitControlsOrbitControls 让我们可以围绕某一个点旋转控制相机。OrbitControls 是 three.js 的可选模块,所以我们首先需要引入这个模块。

    -
    import * as THREE from 'three';
    -+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -
    -

    然后我们就可以使用了。创建 OrbitControls 时传入两个参数,一个是要控制的相机对象,第二个是检测事件的 DOM 元素。

    -
    const controls = new OrbitControls(camera, canvas);
    -controls.target.set(0, 5, 0);
    -controls.update();
    -
    -

    我们还将 OrbitControls 的观察点设置为 (0, 5, 0) 的位置,设置完需要调用一下 controls.update,这样才真正更新观察点位置。

    -

    下面我们创建一些东西来打光。首先,创建一个地平面,并用下方展示的 2x2 像素的黑白格图片来作为纹理。

    -
    - -
    - -

    首先加载这个纹理,设置重复模式(wrapS, wrapT),采样模式(magFilter)以及重复的次数。因为贴图是 2x2 大小,通过设置成平铺模式,并且重复次数是边长的一半,就可以让每个格子正好是1个单位的大小。

    -
    const planeSize = 40;
    -
    -const loader = new THREE.TextureLoader();
    -const texture = loader.load('resources/images/checker.png');
    -texture.wrapS = THREE.RepeatWrapping;
    -texture.wrapT = THREE.RepeatWrapping;
    -texture.magFilter = THREE.NearestFilter;
    -texture.colorSpace = THREE.SRGBColorSpace;
    -const repeats = planeSize / 2;
    -texture.repeat.set(repeats, repeats);
    -
    -

    接着我们创建一个平面几何体,一个材质,再用这两个作为参数,创建一个 Mesh 对象并且添加到场景中。因为创建的平面默认是在 XY 平面上(竖直平面),我们希望得到一个 XZ 平面(水平平面),所以我们将他旋转 90°。

    -
    const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    -const planeMat = new THREE.MeshPhongMaterial({
    -  map: texture,
    -  side: THREE.DoubleSide,
    -});
    -const mesh = new THREE.Mesh(planeGeo, planeMat);
    -mesh.rotation.x = Math.PI * -.5;
    -scene.add(mesh);
    -
    -

    接着再添加一个立方体和一个球体,这样我们就有三个物体可以打光。

    -
    {
    -  const cubeSize = 4;
    -  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
    -  const cubeMat = new THREE.MeshPhongMaterial({color: '#8AC'});
    -  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
    -  mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
    -  scene.add(mesh);
    -}
    -{
    -  const sphereRadius = 3;
    -  const sphereWidthDivisions = 32;
    -  const sphereHeightDivisions = 16;
    -  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
    -  const sphereMat = new THREE.MeshPhongMaterial({color: '#CA8'});
    -  const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    -  mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
    -  scene.add(mesh);
    -}
    -
    -

    一切准备就绪,我们开始添加光源。

    -

    环境光(AmbientLight

    -

    首先创建一个 AmbientLight

    -
    const color = 0xFFFFFF;
    -const intensity = 1;
    -const light = new THREE.AmbientLight(color, intensity);
    -scene.add(light);
    -
    -

    我们添加一些控制代码,使我们可以动态地改变光照的参数,还是使用 lil-gui 来实现。为了可以通过 lil-gui 调节颜色,我们创建一个辅助对象。对象内有一个 gettersetter,当 lil-gui 从对象内获取 value 值的时候,触发了 getter,会根据创建对象实例时传入的 objectprop,返回一个十六进制色值的字符串,当通过 lil-gui 控制改变这个 value 的时候,就触发了 setter,会用十六进制的色值字符串作为参数调用 object.prop.set

    -

    以下是 helper 类的代码:

    -
    class ColorGUIHelper {
    -  constructor(object, prop) {
    -    this.object = object;
    -    this.prop = prop;
    -  }
    -  get value() {
    -    return '#' + this.object[this.prop].getHexString();
    -  }
    -  set value(hexString) {
    -    this.object[this.prop].set(hexString);
    -  }
    -}
    -
    -

    以及创建 lil-gui 的代码:

    -
    const gui = new GUI();
    -gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
    -gui.add(light, 'intensity', 0, 5, 0.01);
    -
    -

    结果如下所示:

    -

    - -

    -

    可以在场景内点击和拖拽鼠标来改变相机的位置,观察场景。

    -

    可以看到场景内的物体看起来没有立体感。环境光 (AmbientLight)只是简单地将材质的颜色与光照颜色进行叠加(PhotoShop 里的正片叠底模式),再乘以光照强度。

    -
    // 这里的颜色计算是 RBG 通道上的值分别对应相乘
    -// 例: rgb(0.64,0.64,0.64) = rgb(0.8,0.8,0.8) * rgb(0.8,0.8,0.8) * 1
    -color = materialColor * light.color * light.intensity;
    -

    这就是环境光,它没有方向,无法产生阴影,场景内任何一点受到的光照强度都是相同的,除了改变场景内所有物体的颜色以外,不会使物体产生明暗的变化,看起来并不像真正意义上的光照。通常的作用是提亮场景,让暗部不要太暗。

    -

    半球光(HemisphereLight

    -

    接下来介绍半球光(HemisphereLight)。半球光(HemisphereLight)的颜色是从天空到地面两个颜色之间的渐变,与物体材质的颜色作叠加后得到最终的颜色效果。一个点受到的光照颜色是由所在平面的朝向(法向量)决定的 —— 面向正上方就受到天空的光照颜色,面向正下方就受到地面的光照颜色,其他角度则是两个颜色渐变区间的颜色。

    -

    下面是修改后的代码:

    -
    -const color = 0xFFFFFF;
    -+const skyColor = 0xB1E1FF;  // light blue
    -+const groundColor = 0xB97A20;  // brownish orange
    -const intensity = 1;
    --const light = new THREE.AmbientLight(color, intensity);
    -+const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
    -scene.add(light);
    -
    -

    同时修改一下 lil-gui 部分,使得可以控制两种颜色:

    -
    const gui = new GUI();
    --gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
    -+gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('skyColor');
    -+gui.addColor(new ColorGUIHelper(light, 'groundColor'), 'value').name('groundColor');
    -gui.add(light, 'intensity', 0, 5, 0.01);
    -
    -

    结果如下:

    -

    - -

    -

    场景基本上也没有太大的立体感。半球光 (HemisphereLight) 与其他类型光照结合使用,可以很好地表现天空和地面颜色照射到物体上时的效果。所以最好的使用场景就是与其他光照结合使用,或者作为环境光(AmbientLight)的一种替代方案。

    -

    方向光(DirectionalLight

    -

    下面介绍方向光(DirectionalLight)。 -方向光(DirectionalLight)常常用来表现太阳光照的效果。

    -
    const color = 0xFFFFFF;
    -const intensity = 1;
    -const light = new THREE.DirectionalLight(color, intensity);
    -light.position.set(0, 10, 0);
    -light.target.position.set(-5, 0, 0);
    -scene.add(light);
    -scene.add(light.target);
    -
    -

    注意,不仅 light ,我们还把 light.target 也添加到了场景中。方向光(DirectionalLight)的方向是从它的位置照向目标点的位置。

    -

    下面代码是将目标点坐标属性添加到 lil-gui,使得我们可以控制目标位置

    -
    const gui = new GUI();
    -gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
    -gui.add(light, 'intensity', 0, 5, 0.01);
    -gui.add(light.target.position, 'x', -10, 10);
    -gui.add(light.target.position, 'z', -10, 10);
    -gui.add(light.target.position, 'y', 0, 10);
    -
    -

    - -

    -

    目前有点难以观察。Three.js 提供了一些辅助对象,添加到场景中之后就可以显示出场景中的不可见对象(例如光照、相机等)。在这里我们使用 DirectionalLightHelper,它会绘制一个方形的小平面代表方向光的位置,一条连接光源与目标点的直线,代表了光的方向。创建对象时,传入光源对象作为参数,然后添加到场景中,就可以呈现。

    -
    const helper = new THREE.DirectionalLightHelper(light);
    -scene.add(helper);
    -
    -

    我们顺便实现一下对光源位置和目标点位置的控制逻辑。我们创建一个辅助函数,使得可以通过 lil-gui 改变传入的 Vector3 类型对象的 xy,和 z 的值。

    -
    function makeXYZGUI(gui, vector3, name, onChangeFn) {
    -  const folder = gui.addFolder(name);
    -  folder.add(vector3, 'x', -10, 10).onChange(onChangeFn);
    -  folder.add(vector3, 'y', 0, 10).onChange(onChangeFn);
    -  folder.add(vector3, 'z', -10, 10).onChange(onChangeFn);
    -  folder.open();
    -}
    -
    -

    注意,当辅助对象所表示的不可见对象有所改变的时候,我们必须调用辅助对象的 update 方法来更新辅助对象本身的状态。因此我们传入一个 onChangeFn 函数,每当 lil-gui 改变了某个值的时候,就会被调用。

    -

    应用到光照位置与目标点位置的控制,就如下所示:

    -
    +function updateLight() {
    -+  light.target.updateMatrixWorld();
    -+  helper.update();
    -+}
    -+updateLight();
    -
    -const gui = new GUI();
    -gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
    -gui.add(light, 'intensity', 0, 5, 0.01);
    -
    -+makeXYZGUI(gui, light.position, 'position', updateLight);
    -+makeXYZGUI(gui, light.target.position, 'target', updateLight);
    -
    -

    现在我们可以控制光源以及目标点位置了。

    -

    - -

    -

    旋转相机可以看得更清楚。方形的小平面代表了一个方向光(DirectionalLight),方向光表示的是来自一个方向上的光,并不是从某个点发射出来的,而是从一个无限大的平面内,发射出全部相互平行的光线。

    -

    点光源(PointLight

    -

    点光源(PointLight)表示的是从一个点朝各个方向发射出光线的一种光照效果。我们修改一下代码:

    -
    const color = 0xFFFFFF;
    --const intensity = 1;
    -+const intensity = 150;
    --const light = new THREE.DirectionalLight(color, intensity);
    -+const light = new THREE.PointLight(color, intensity);
    -light.position.set(0, 10, 0);
    --light.target.position.set(-5, 0, 0);
    -scene.add(light);
    --scene.add(light.target);
    -
    -

    同时添加一个 PointLightHelper

    -
    -const helper = new THREE.DirectionalLightHelper(light);
    -+const helper = new THREE.PointLightHelper(light);
    -scene.add(helper);
    -
    -

    因为点光源没有 target 属性,所以 onChange 函数可以简化。

    -
    function updateLight() {
    --  light.target.updateMatrixWorld();
    -  helper.update();
    -}
    --updateLight();
    -
    -

    PointLightHelper 不是一个点,而是在光源的位置绘制了一个小小的线框宝石体来代表点光源。也可以使用其他形状来表示点光源,只要给点光源添加一个自定义的 Mesh 子节点即可。

    -

    点光源(PointLight)有额外的一个范围(distance)属性。 -如果 distance 设为 0,则光线可以照射到无限远处。如果大于 0,则只可以照射到指定的范围,光照强度在这个过程中逐渐衰减,在光源位置时,intensity 是设定的大小,在距离光源 distance 位置的时候,intensity 为 0。

    -

    下面是添加对 distance 参数控制的代码:

    -
    const gui = new GUI();
    -gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
    -gui.add(light, 'intensity', 0, 250, 1);
    -+gui.add(light, 'distance', 0, 40).onChange(updateLight);
    -
    -makeXYZGUI(gui, light.position, 'position', updateLight);
    --makeXYZGUI(gui, light.target.position, 'target', updateLight);
    -
    -

    效果如下:

    -

    - -

    -

    注意 distance > 0 时光照强度的衰减现象。

    -

    聚光灯(SpotLight

    -

    聚光灯可以看成是一个点光源被一个圆锥体限制住了光照的范围。实际上有两个圆锥,内圆锥和外圆锥。光照强度在两个锥体之间从设定的强度递减到 0(具体可以看下方 penumbra 参数)。

    -

    聚光灯(SpotLight)类似方向光(DirectionalLight)一样需要一个目标点,光源的位置是圆锥的顶点,目标点处于圆锥的中轴线上。

    -

    修改上面 DirectionalLight 的代码如下:

    -
    const color = 0xFFFFFF;
    --const intensity = 1;
    -+const intensity = 150;
    --const light = new THREE.DirectionalLight(color, intensity);
    -+const light = new THREE.SpotLight(color, intensity);
    -scene.add(light);
    -scene.add(light.target);
    -
    --const helper = new THREE.DirectionalLightHelper(light);
    -+const helper = new THREE.SpotLightHelper(light);
    -scene.add(helper);
    -
    -

    聚光灯的圆锥顶部角度大小通过 angle 属性设置,以弧度作单位。所以我们用介绍 纹理 时用到的 DegRadHelper 来控制。

    -
    gui.add(new DegRadHelper(light, 'angle'), 'value', 0, 90).name('angle').onChange(updateLight);
    -
    -

    内圆锥是通过设置 penumbra 属性来定义的,属性值代表了内圆锥相对外圆锥大小变化的百分比。当 penumbra 为 0 时,内圆锥大小与外圆锥大小一致;当 penumbra 为 1 时,内圆锥大小为 0,光照强度从中轴线就开始往外递减;当 penumbra 为 0.5 时,光照强度从外圆锥半径的中点处开始往外递减。

    -
    gui.add(light, 'penumbra', 0, 1, 0.01);
    -
    -

    - -

    -

    注意观察,当 penumbra 为默认值 0 的时候,聚光灯会有非常清晰的边缘,而当把 penumbra 向 1 调节的时候,边缘会开始模糊。

    -

    示例中有点难以看到聚光灯的整个圆锥体,因为圆锥底部在平面下方。将 distance 减小到 5 左右,就可以看到圆锥的底部。

    -

    矩形区域光(RectAreaLight

    -

    Three.js 中还有一种类型的光照,矩形区域光(RectAreaLight), 顾名思义,表示一个矩形区域的发射出来的光照,例如长条的日光灯或者天花板上磨砂玻璃透进来的自然光。

    -

    RectAreaLight 只能影响 MeshStandardMaterialMeshPhysicalMaterial,所以我们把所有的材质都改为 MeshStandardMaterial

    -
      ...
    -
    -  const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    --  const planeMat = new THREE.MeshPhongMaterial({
    -+  const planeMat = new THREE.MeshStandardMaterial({
    -    map: texture,
    -    side: THREE.DoubleSide,
    -  });
    -  const mesh = new THREE.Mesh(planeGeo, planeMat);
    -  mesh.rotation.x = Math.PI * -.5;
    -  scene.add(mesh);
    -}
    -{
    -  const cubeSize = 4;
    -  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
    -- const cubeMat = new THREE.MeshPhongMaterial({color: '#8AC'});
    -+ const cubeMat = new THREE.MeshStandardMaterial({color: '#8AC'});
    -  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
    -  mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
    -  scene.add(mesh);
    -}
    -{
    -  const sphereRadius = 3;
    -  const sphereWidthDivisions = 32;
    -  const sphereHeightDivisions = 16;
    -  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
    --  const sphereMat = new THREE.MeshPhongMaterial({color: '#CA8'});
    -+ const sphereMat = new THREE.MeshStandardMaterial({color: '#CA8'});
    -  const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    -  mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
    -  scene.add(mesh);
    -}
    -
    -

    为了使用 RectAreaLight,我们需要引入 three.js 的RectAreaLightUniformsLib 模块,同时使用 RectAreaLightHelper 来辅助查看灯光对象。

    -
    import * as THREE from 'three';
    -+import {RectAreaLightUniformsLib} from 'three/addons/lights/RectAreaLightUniformsLib.js';
    -+import {RectAreaLightHelper} from 'three/addons/helpers/RectAreaLightHelper.js';
    -
    -

    我们需要先调用 RectAreaLightUniformsLib.init

    -
    function main() {
    -  const canvas = document.querySelector('#c');
    -  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -+  RectAreaLightUniformsLib.init();
    -
    -

    如果忘了引入和使用 RectAreaLightUniformsLib,光照还是可以显示,但是会看起来很奇怪(译者注:在示例的简单场景中没有发现区别),所以要确保有使用。

    -

    然后我们可以创建光照了

    -
    const color = 0xFFFFFF;
    -*const intensity = 5;
    -+const width = 12;
    -+const height = 4;
    -*const light = new THREE.RectAreaLight(color, intensity, width, height);
    -light.position.set(0, 10, 0);
    -+light.rotation.x = THREE.MathUtils.degToRad(-90);
    -scene.add(light);
    -
    -*const helper = new RectAreaLightHelper(light);
    -*light.add(helper);
    -
    -

    需要注意的是,与方向光(DirectionalLight)和聚光灯(SpotLight)不同,矩形光不是使用目标点(target),而是使用自身的旋转角度来确定光照方向。另外,矩形光的辅助对象(RectAreaLightHelper)应该添加为光照的子节点,而不是添加为场景的子节点。

    -

    同时我们修改一下 GUI 代码,使我们可以旋转光源,调整 widthheight 属性。

    -
    const gui = new GUI();
    -gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
    -gui.add(light, 'intensity', 0, 10, 0.01);
    -gui.add(light, 'width', 0, 20);
    -gui.add(light, 'height', 0, 20);
    -gui.add(new DegRadHelper(light.rotation, 'x'), 'value', -180, 180).name('x rotation');
    -gui.add(new DegRadHelper(light.rotation, 'y'), 'value', -180, 180).name('y rotation');
    -gui.add(new DegRadHelper(light.rotation, 'z'), 'value', -180, 180).name('z rotation');
    -
    -makeXYZGUI(gui, light.position, 'position');
    -
    -

    场景如下所示:

    -

    - -

    - -

    需要注意,每添加一个光源到场景中,都会降低 three.js 渲染场景的速度,所以应该尽量使用最少的资源来实现想要的效果。

    -

    接下来我们学习 three.js 中的 相机

    -

    - - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/load-gltf.html b/manual/zh/load-gltf.html deleted file mode 100644 index ea0dd47d4b74bd..00000000000000 --- a/manual/zh/load-gltf.html +++ /dev/null @@ -1,685 +0,0 @@ - - - - - - 加载 .gltf 文件 - - - - - - - - - - - - - - -
    -
    -

    加载 .gltf 文件

    -
    -
    -
    -

    在上一章中,我们加载 .OBJ 文件. 如果你还没有阅读它,你可能需要先查看一下。

    -

    正如之前所指出的,“.OBJ 文件格式已经非常古老,并且相当简单”。 - 它没有提供任何场景图(scene graph),所以所有的数据都需要加载到一个巨大的Mesh中。 它被设计出来主要是作为3D编辑器之间的一种简单的数据传输方式。

    -

    gLTF 格式 是从头开始设计的一种格式,用于显示图形。 3D 格式可被划分为3~4中基本类型。

    -
      -
    • -

      3D编辑器格式

      -

      用于特定应用程序(主要是3D编辑器):. .blend (Blender), .max (3d Studio Max), .mb and .ma (Maya), etc...

      -
    • -
    • -

      交换格式

      -

      有.OBJ, .DAE (Collada), .FBX.等格式。它们被设计出来用于3D编辑器之间交换信息的。因此,它们通常比所需的大得多(内含3D编辑器内所需要的信息)。

      -
    • -
    • -

      应用程序格式

      -

      用于特定的应用程序:游戏

      -
    • -
    • -

      传输格式

      -

      gLTF可能是第一个真正意义上的传输格式。我猜想VRML可能会被认为是第一个,但是VRML实际上是个相当糟的格式。

      -

      这是gLTF被设计出来擅长做的一些事情,其他格式没有的

      -
        -
      1. -

        体积小,适合用于传输

        -

        例如:这意味着大量它们的大数据量的数据,像顶点(vertices),被存为二进制格式。当你下载.gLTF格式文件后,可以将它零处理的传入GPU。这是它准备好的。相比较之下,VRML, .OBJ, or - .DAE 这些格式 顶点(vertices)的存储和解析都是通过文本(text)。 文本存储的顶点位置(vertex positions)大约有二进制存储的3倍到5倍大。

        -
      2. -
      3. -

        易于被渲染(render)

        -

        - 与其他格式(尤其是应用程序格式)另一个不同是:glTF格式文件中的数据,意味着是用于渲染(render)的,而不是用于编辑的。对渲染不重要的那部分数据大都被删除了。多边形被转为三角形。材质有其应该有的值,可以在任何地方运行。 -

        -
      4. -
      -
    • -
    -

    gLTF是被特别设计的。因此你应该能轻易下载glTF文件,显示它们,很少出麻烦。祝我们好运,真是这种情况,因为没有其他格式已经能够这样做。

    -

    我不确定我要展示什么。在某种程度上加载和显示gLTF文件比.OBJ文件要简单。不像.OBJ文件,材质是格式的一部分。我想我需要至少加载一个.OBJ文件,我遇到的问题可能会提供一些良好的信息

    -

    在网上搜索我发现 这个低模城市 - 作者: antonmoek 这意味着我们可以做一个好的案例。

    -
    - -

    an example from the .OBJ article从这里开始,我删除了加载.OBJ的代码 ,替换为加载.gLTF的

    -

    旧.OBJ代码

    -
    const mtlLoader = new MTLLoader();
    -mtlLoader.loadMtl('resources/models/windmill/windmill-fixed.mtl', (mtl) => {
    -mtl.preload();
    -mtl.materials.Material.side = THREE.DoubleSide;
    -objLoader.setMaterials(mtl);
    -objLoader.load('resources/models/windmill/windmill.obj', (event) => {
    -	const root = event.detail.loaderRootNode;
    -	scene.add(root);
    -	...
    -});
    -});
    -
    -

    新.GLTF代码

    -
    {
    -const gltfLoader = new GLTFLoader();
    -const url = 'resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf';
    -gltfLoader.load(url, (gltf) => {
    -	const root = gltf.scene;
    -	scene.add(root);
    -	...
    -});
    -
    -

    我像以前一样保持自动框架代码

    -

    我们需要包含 GLTFLoader 并删除 OBJLoader.

    -
    -import {LoaderSupport} from 'three/addons/loaders/LoaderSupport.js';
    --import {OBJLoader} from 'three/addons/loaders/OBJLoader.js';
    --import {MTLLoader} from 'three/addons/loaders/MTLLoader.js';
    -+import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
    -
    -

    像这样运行

    -

    - - -

    -

    神奇吧!它工作了,纹理和其他都工作了。

    -

    然后,我想看看我能否给汽车添加绕圈动画(animate),我得检查场景有没有这些汽车的分离的实体,还得检查场景有没有一条路可以用。

    -

    我写了一些代码将场景图(scenegraph)打印到 JavaScript - console.

    -

    打印场景图(scenegraph)代码

    -
    function dumpObject(obj, lines = [], isLast = true, prefix = '') {
    -const localPrefix = isLast ? '└─' : '├─';
    -lines.push(`${prefix}${prefix ? localPrefix : ''}${obj.name || '*no-name*'} [${obj.type}]`);
    -const newPrefix = prefix + (isLast ? '  ' : '│ ');
    -const lastNdx = obj.children.length - 1;
    -obj.children.forEach((child, ndx) => {
    -	const isLast = ndx === lastNdx;
    -	dumpObject(child, lines, isLast, newPrefix);
    -});
    -return lines;
    -}
    -
    -

    我将在场景加载完时调用

    -
    const gltfLoader = new GLTFLoader();
    -gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
    -const root = gltf.scene;
    -scene.add(root);
    -console.log(dumpObject(root).join('\n'));
    -
    -

    Running that 获得清单

    -
    OSG_Scene [Scene]
    -└─RootNode_(gltf_orientation_matrix) [Object3D]
    -	└─RootNode_(model_correction_matrix) [Object3D]
    -		└─4d4100bcb1c640e69699a87140df79d7fbx [Object3D]
    -			└─RootNode [Object3D]
    -				│ ...
    -				├─Cars [Object3D]
    -				│ ├─CAR_03_1 [Object3D]
    -				│ │ └─CAR_03_1_World_ap_0 [Mesh]
    -				│ ├─CAR_03 [Object3D]
    -				│ │ └─CAR_03_World_ap_0 [Mesh]
    -				│ ├─Car_04 [Object3D]
    -				│ │ └─Car_04_World_ap_0 [Mesh]
    -				│ ├─CAR_03_2 [Object3D]
    -				│ │ └─CAR_03_2_World_ap_0 [Mesh]
    -				│ ├─Car_04_1 [Object3D]
    -				│ │ └─Car_04_1_World_ap_0 [Mesh]
    -				│ ├─Car_04_2 [Object3D]
    -				│ │ └─Car_04_2_World_ap_0 [Mesh]
    -				│ ├─Car_04_3 [Object3D]
    -				│ │ └─Car_04_3_World_ap_0 [Mesh]
    -				│ ├─Car_04_4 [Object3D]
    -				│ │ └─Car_04_4_World_ap_0 [Mesh]
    -				│ ├─Car_08_4 [Object3D]
    -				│ │ └─Car_08_4_World_ap8_0 [Mesh]
    -				│ ├─Car_08_3 [Object3D]
    -				│ │ └─Car_08_3_World_ap9_0 [Mesh]
    -				│ ├─Car_04_1_2 [Object3D]
    -				│ │ └─Car_04_1_2_World_ap_0 [Mesh]
    -				│ ├─Car_08_2 [Object3D]
    -				│ │ └─Car_08_2_World_ap11_0 [Mesh]
    -				│ ├─CAR_03_1_2 [Object3D]
    -				│ │ └─CAR_03_1_2_World_ap_0 [Mesh]
    -				│ ├─CAR_03_2_2 [Object3D]
    -				│ │ └─CAR_03_2_2_World_ap_0 [Mesh]
    -				│ ├─Car_04_2_2 [Object3D]
    -				│ │ └─Car_04_2_2_World_ap_0 [Mesh]
    -				...
    -
    -

    从这里我们可以看到所有的汽车,都在它们父节点"Cars"

    -
    *          ├─Cars [Object3D]
    -				│ ├─CAR_03_1 [Object3D]
    -				│ │ └─CAR_03_1_World_ap_0 [Mesh]
    -				│ ├─CAR_03 [Object3D]
    -				│ │ └─CAR_03_World_ap_0 [Mesh]
    -				│ ├─Car_04 [Object3D]
    -				│ │ └─Car_04_World_ap_0 [Mesh]
    -
    -

    做一个简单的测试,我尝试将这些"Cars"节点的子节点绕它们的Y轴旋转。

    -

    在加载场景后,我查找了“Cars”节点并保存了结果。

    -
    +let cars;
    -{
    -const gltfLoader = new GLTFLoader();
    -gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
    -	const root = gltf.scene;
    -	scene.add(root);
    -+    cars = root.getObjectByName('Cars');
    -
    -

    然后在render render函数中我们可以设置carsCars每个子节点旋转。

    -
    +function render(time) {
    -+  time *= 0.001;  // convert to seconds
    -
    -if (resizeRendererToDisplaySize(renderer)) {
    -	const canvas = renderer.domElement;
    -	camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -	camera.updateProjectionMatrix();
    -}
    -
    -+  if (cars) {
    -+    for (const car of cars.children) {
    -+      car.rotation.y = time;
    -+    }
    -+  }
    -
    -renderer.render(scene, camera);
    -
    -requestAnimationFrame(render);
    -}
    -
    -

    于是我们得到

    -

    - - -

    -

    呃~,看起来很不幸,这场景没有设计汽车动画,因为他们当初不是用于此目的的。这卡车在错误的方向旋转。

    -

    这引出一个很重要的点,当你把东西做成3D时,你需要预先计划并设计你的资源(assets),让它们拥有能正确使用的初始功能。所以它们应该有正确的比例...

    -

    因为我不是艺术家,我不懂blender,我只能hack这个例子。我们将把每辆车,和它们的父节点,放到另一个 - Object3D. - 我们将移动这些 Object3D - 的对象去移动车,但是我们可以设置汽车的原始Object3D - 重新定位它,所以它是关于我们真正需要的地方。 -

    -

    回顾下场景图(scene graph)看起来只有三种车,Car_08", "CAR_03", and "Car_04"。每种汽车都会使用相同的调整。

    -

    我给每个汽车都写了代码,将它们的父节点换成新的 - Object3D, 将新 - Object3D 添加到场景, - 应用于每种 类型的车的设置,修正它的朝向。 增加新的 - Object3D a - cars array. -

    -
    -let cars;
    -+const cars = [];
    -{
    -const gltfLoader = new GLTFLoader();
    -gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
    -	const root = gltf.scene;
    -	scene.add(root);
    -
    --    cars = root.getObjectByName('Cars');
    -+    const loadedCars = root.getObjectByName('Cars');
    -+    const fixes = [
    -+      { prefix: 'Car_08', rot: [Math.PI * .5, 0, Math.PI * .5], },
    -+      { prefix: 'CAR_03', rot: [0, Math.PI, 0], },
    -+      { prefix: 'Car_04', rot: [0, Math.PI, 0], },
    -+    ];
    -+
    -+    root.updateMatrixWorld();
    -+    for (const car of loadedCars.children.slice()) {
    -+      const fix = fixes.find(fix => car.name.startsWith(fix.prefix));
    -+      const obj = new THREE.Object3D();
    -+      car.getWorldPosition(obj.position);
    -+      car.position.set(0, 0, 0);
    -+      car.rotation.set(...fix.rot);
    -+      obj.add(car);
    -+      scene.add(obj);
    -+      cars.push(obj);
    -+    }
    -	 ...
    -
    -

    这些修正朝向的车

    -

    - - -

    -

    现在让我们驾驶着它们绕圈

    -

    制作一个简单的驾驶系统对这篇文章来说太多了,但作为代替我们可以做一个基于所有道路做一个弯曲路径,然后放置车到路上。下面的图是blender创建的路径。

    -
    - -

    我需要一种方式在Blender之外花去路径数据的方法 - 幸运的是,我能选择我的路径并通过"write nurbs"导出.OBJ

    -
    - -

    打开.OBJ文件我能够获取点列表,我格式化后

    -
    const controlPoints = [
    -[1.118281, 5.115846, -3.681386],
    -[3.948875, 5.115846, -3.641834],
    -[3.960072, 5.115846, -0.240352],
    -[3.985447, 5.115846, 4.585005],
    -[-3.793631, 5.115846, 4.585006],
    -[-3.826839, 5.115846, -14.736200],
    -[-14.542292, 5.115846, -14.765865],
    -[-14.520929, 5.115846, -3.627002],
    -[-5.452815, 5.115846, -3.634418],
    -[-5.467251, 5.115846, 4.549161],
    -[-13.266233, 5.115846, 4.567083],
    -[-13.250067, 5.115846, -13.499271],
    -[4.081842, 5.115846, -13.435463],
    -[4.125436, 5.115846, -5.334928],
    -[-14.521364, 5.115846, -5.239871],
    -[-14.510466, 5.115846, 5.486727],
    -[5.745666, 5.115846, 5.510492],
    -[5.787942, 5.115846, -14.728308],
    -[-5.423720, 5.115846, -14.761919],
    -[-5.373599, 5.115846, -3.704133],
    -[1.004861, 5.115846, -3.641834],
    -];
    -
    -

    THREE.js有几个曲线(curve)的类。 CatmullRomCurve3 那种曲线的事情是尝试通过这些点进行平滑的曲线

    -

    事实上直接提供这些点会生成这样的曲线

    -
    - -

    但是,我想要更尖锐的转角。如果我们计算了一些额外的点,我们可以得到我们想要的东西。 - 每两对点之间,我们需要在两点之间的10%处和90%处计算额外的两个点,并将它们传递给 - CatmullRomCurve3. -

    -

    这会得到这样的曲线

    -
    - -

    曲线代码

    -
    let curve;
    -let curveObject;
    -{
    -const controlPoints = [
    -	[1.118281, 5.115846, -3.681386],
    -	[3.948875, 5.115846, -3.641834],
    -	[3.960072, 5.115846, -0.240352],
    -	[3.985447, 5.115846, 4.585005],
    -	[-3.793631, 5.115846, 4.585006],
    -	[-3.826839, 5.115846, -14.736200],
    -	[-14.542292, 5.115846, -14.765865],
    -	[-14.520929, 5.115846, -3.627002],
    -	[-5.452815, 5.115846, -3.634418],
    -	[-5.467251, 5.115846, 4.549161],
    -	[-13.266233, 5.115846, 4.567083],
    -	[-13.250067, 5.115846, -13.499271],
    -	[4.081842, 5.115846, -13.435463],
    -	[4.125436, 5.115846, -5.334928],
    -	[-14.521364, 5.115846, -5.239871],
    -	[-14.510466, 5.115846, 5.486727],
    -	[5.745666, 5.115846, 5.510492],
    -	[5.787942, 5.115846, -14.728308],
    -	[-5.423720, 5.115846, -14.761919],
    -	[-5.373599, 5.115846, -3.704133],
    -	[1.004861, 5.115846, -3.641834],
    -];
    -const p0 = new THREE.Vector3();
    -const p1 = new THREE.Vector3();
    -curve = new THREE.CatmullRomCurve3(
    -	controlPoints.map((p, ndx) => {
    -		p0.set(...p);
    -		p1.set(...controlPoints[(ndx + 1) % controlPoints.length]);
    -		return [
    -			(new THREE.Vector3()).copy(p0),
    -			(new THREE.Vector3()).lerpVectors(p0, p1, 0.1),
    -			(new THREE.Vector3()).lerpVectors(p0, p1, 0.9),
    -		];
    -	}).flat(),
    -	true,
    -);
    -{
    -	const points = curve.getPoints(250);
    -	const geometry = new THREE.BufferGeometry().setFromPoints(points);
    -	const material = new THREE.LineBasicMaterial({color: 0xff0000});
    -	curveObject = new THREE.Line(geometry, material);
    -	scene.add(curveObject);
    -}
    -}
    -
    -

    - 代码第一部分是创建了曲线。 - 代码第二部分是从曲线生成了250个点,然后创建了一个对象来显示这些点连起来的线。 -

    -

    运行 the example - 我没有看见曲线。 为了让它可见我关闭了深度测试,并最后渲染它

    -
        curveObject = new THREE.Line(geometry, material);
    -+    material.depthTest = false;
    -+    curveObject.renderOrder = 1;
    -
    -

    我发现路线实在太小了

    -
    - -

    检查Blender中的层次结构我发现艺术家缩放了节点所有汽车。

    -
    - -

    缩放对于实时3D应用程序是十分糟糕的。这会到这各种各样的问题,并且会以无休止的沮丧结束。 - 艺术家经常不知道这些,因为在3D编辑程序中缩放整个场景是非常容易的。 - 但是,如果你决定要做实时3D应用程序,我建议要求你的艺术家/设计人员不要缩放任何东西。 - 如果他们修改了scale,他们必须找到一种方式应用于缩放后的顶点。这样你就能,在导入程序后不用管缩放了。

    -

    还有, 不仅是缩放, 在这个例子中,车辆是通过父节点缩放,偏移的。 Cars 节点. - 这将让在世界空间中实时移动车辆变得非常困难。 - 清楚地说,这个例子中,我们能希望车辆绕着世界空间开,这就会带来问题。 - 如果有些东西地数据处理在局部空间,如月球绕着地球转,就很少带来问题。 -

    -

    - 回到我写地打印场景图地方法。 - 让我们每个节点打印 位置, 旋转, 缩放 。

    -
    +function dumpVec3(v3, precision = 3) {
    -+  return `${v3.x.toFixed(precision)}, ${v3.y.toFixed(precision)}, ${v3.z.toFixed(precision)}`;
    -+}
    -
    -function dumpObject(obj, lines, isLast = true, prefix = '') {
    -const localPrefix = isLast ? '└─' : '├─';
    -lines.push(`${prefix}${prefix ? localPrefix : ''}${obj.name || '*no-name*'} [${obj.type}]`);
    -+  const dataPrefix = obj.children.length
    -+     ? (isLast ? '  │ ' : '│ │ ')
    -+     : (isLast ? '    ' : '│   ');
    -+  lines.push(`${prefix}${dataPrefix}  pos: ${dumpVec3(obj.position)}`);
    -+  lines.push(`${prefix}${dataPrefix}  rot: ${dumpVec3(obj.rotation)}`);
    -+  lines.push(`${prefix}${dataPrefix}  scl: ${dumpVec3(obj.scale)}`);
    -const newPrefix = prefix + (isLast ? '  ' : '│ ');
    -const lastNdx = obj.children.length - 1;
    -obj.children.forEach((child, ndx) => {
    -	const isLast = ndx === lastNdx;
    -	dumpObject(child, lines, isLast, newPrefix);
    -});
    -return lines;
    -}
    -
    -

    运行的结果

    -
    OSG_Scene [Scene]
    -│   pos: 0.000, 0.000, 0.000
    -│   rot: 0.000, 0.000, 0.000
    -│   scl: 1.000, 1.000, 1.000
    -└─RootNode_(gltf_orientation_matrix) [Object3D]
    -	│   pos: 0.000, 0.000, 0.000
    -	│   rot: -1.571, 0.000, 0.000
    -	│   scl: 1.000, 1.000, 1.000
    -	└─RootNode_(model_correction_matrix) [Object3D]
    -		│   pos: 0.000, 0.000, 0.000
    -		│   rot: 0.000, 0.000, 0.000
    -		│   scl: 1.000, 1.000, 1.000
    -		└─4d4100bcb1c640e69699a87140df79d7fbx [Object3D]
    -			│   pos: 0.000, 0.000, 0.000
    -			│   rot: 1.571, 0.000, 0.000
    -			│   scl: 1.000, 1.000, 1.000
    -			└─RootNode [Object3D]
    -				│   pos: 0.000, 0.000, 0.000
    -				│   rot: 0.000, 0.000, 0.000
    -				│   scl: 1.000, 1.000, 1.000
    -				├─Cars [Object3D]
    -*          │ │   pos: -369.069, -90.704, -920.159
    -*          │ │   rot: 0.000, 0.000, 0.000
    -*          │ │   scl: 1.000, 1.000, 1.000
    -				│ ├─CAR_03_1 [Object3D]
    -				│ │ │   pos: 22.131, 14.663, -475.071
    -				│ │ │   rot: -3.142, 0.732, 3.142
    -				│ │ │   scl: 1.500, 1.500, 1.500
    -				│ │ └─CAR_03_1_World_ap_0 [Mesh]
    -				│ │       pos: 0.000, 0.000, 0.000
    -				│ │       rot: 0.000, 0.000, 0.000
    -				│ │       scl: 1.000, 1.000, 1.000
    -
    -

    这向我们展示了原始场景中的 Cars - 已移除其旋转和缩放并应用于其子节点。 - 这表明用于创建 .GLTF 文件的导出器在这里做了一些特殊的处理,或者艺术家更可能导出了与相应 .blend 文件不同的文件版本,这就是它们不匹配的原因。

    -

    这意味着,我可能需要亲自下载这个.blend文件并导出它。导出它之前,我需要检查所有主要节点,并移除它的任何变换。

    -

    顶部的所有节点

    -
    OSG_Scene [Scene]
    -│   pos: 0.000, 0.000, 0.000
    -│   rot: 0.000, 0.000, 0.000
    -│   scl: 1.000, 1.000, 1.000
    -└─RootNode_(gltf_orientation_matrix) [Object3D]
    -	│   pos: 0.000, 0.000, 0.000
    -	│   rot: -1.571, 0.000, 0.000
    -	│   scl: 1.000, 1.000, 1.000
    -	└─RootNode_(model_correction_matrix) [Object3D]
    -		│   pos: 0.000, 0.000, 0.000
    -		│   rot: 0.000, 0.000, 0.000
    -		│   scl: 1.000, 1.000, 1.000
    -		└─4d4100bcb1c640e69699a87140df79d7fbx [Object3D]
    -			│   pos: 0.000, 0.000, 0.000
    -			│   rot: 1.571, 0.000, 0.000
    -			│   scl: 1.000, 1.000, 1.000
    -
    -

    也是浪费。

    -

    理想情况下,场景将由一个没有位置、旋转或缩放的“根”节点组成。 - 在运行时,我可以将所有子节点从该根中拉出,并将它们作为场景本身的父级。 - 可能有像“Cars”这样的根的子节点,它可以帮助我找到所有的汽车,但理想情况下它也没有平移、旋转或缩放,因此我可以用最少的工作将汽车重新设置为场景。 -

    -

    - 在任何情况下,最快速的(虽然可能不是最好的)修复,就是只调整用于显示曲线的对象。

    -

    这是我完成后的

    -

    首先,我调整了曲线的位置,然后发现了可以正常显示的数值,接着我把它隐藏了。

    -
    {
    -const points = curve.getPoints(250);
    -const geometry = new THREE.BufferGeometry().setFromPoints(points);
    -const material = new THREE.LineBasicMaterial({color: 0xff0000});
    -curveObject = new THREE.Line(geometry, material);
    -+  curveObject.scale.set(100, 100, 100);
    -+  curveObject.position.y = -621;
    -+  curveObject.visible = false;
    -material.depthTest = false;
    -curveObject.renderOrder = 1;
    -scene.add(curveObject);
    -}
    -
    -

    然后,我写了代码来沿着曲线移动汽车。 - 给每辆车选取曲线上的从0到1的位置,用 curveObject对象变换计算出一个世界空间的点 - 接下来,我们选取曲线远一点的点。 - 使用lookAt 设置汽车的朝向 并将汽车放置在两点重点。

    -
    -// 创建两个向量用于路径计算
    -const carPosition = new THREE.Vector3();
    -const carTarget = new THREE.Vector3();
    -
    -function render(time) {
    -...
    -
    --  for (const car of cars) {
    --    car.rotation.y = time;
    --  }
    -
    -+  {
    -+    const pathTime = time * .01;
    -+    const targetOffset = 0.01;
    -+    cars.forEach((car, ndx) => {
    -+      // 一个介于 0 和 1 之间的数字,用于均匀间隔汽车
    -+      const u = pathTime + ndx / cars.length;
    -+
    -+      // 获取第一个点
    -+      curve.getPointAt(u % 1, carPosition);
    -+      carPosition.applyMatrix4(curveObject.matrixWorld);
    -+
    -+      // 曲线再远点获取第二个点
    -+      curve.getPointAt((u + targetOffset) % 1, carTarget);
    -+      carTarget.applyMatrix4(curveObject.matrixWorld);
    -+
    -+      // 把汽车放置在第一个点 (暂时的)
    -+      car.position.copy(carPosition);
    -+      // 汽车的第二个点
    -+      car.lookAt(carTarget);
    -+
    -+      // 放置小车在两个点中间
    -+      car.position.lerpVectors(carPosition, carTarget, 0.5);
    -+    });
    -+  }
    -
    -

    - 当我运行它时,我发现每种汽车车,它们的高度都不是固定的,所以我要给每种车调整一下。

    -
    const loadedCars = root.getObjectByName('Cars');
    -const fixes = [
    --  { prefix: 'Car_08', rot: [Math.PI * .5, 0, Math.PI * .5], },
    --  { prefix: 'CAR_03', rot: [0, Math.PI, 0], },
    --  { prefix: 'Car_04', rot: [0, Math.PI, 0], },
    -+  { prefix: 'Car_08', y: 0,  rot: [Math.PI * .5, 0, Math.PI * .5], },
    -+  { prefix: 'CAR_03', y: 33, rot: [0, Math.PI, 0], },
    -+  { prefix: 'Car_04', y: 40, rot: [0, Math.PI, 0], },
    -];
    -
    -root.updateMatrixWorld();
    -for (const car of loadedCars.children.slice()) {
    -const fix = fixes.find(fix => car.name.startsWith(fix.prefix));
    -const obj = new THREE.Object3D();
    -car.getWorldPosition(obj.position);
    --  car.position.set(0, 0, 0);
    -+  car.position.set(0, fix.y, 0);
    -car.rotation.set(...fix.rot);
    -obj.add(car);
    -scene.add(obj);
    -cars.push(obj);
    -}
    -
    -

    结果是

    -

    - - -

    -

    几分钟运行的还不错

    -

    最后我想做的是开启阴影

    -

    我获取了所有 - DirectionalLight的GUI代码, - the article on shadows中的阴影实例 。并粘贴到最新代码 -

    -

    加载完之后,我们需要开启所有物体的阴影

    -
    {
    -const gltfLoader = new GLTFLoader();
    -gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
    -	const root = gltf.scene;
    -	scene.add(root);
    -
    -+    root.traverse((obj) => {
    -+      if (obj.castShadow !== undefined) {
    -+        obj.castShadow = true;
    -+        obj.receiveShadow = true;
    -+      }
    -+    });
    -
    -

    我花了将近4小时,尝试搞清楚为啥shadow helpers不工作。因为我忘记了用以下代码开启阴影

    -
    renderer.shadowMap.enabled = true;
    -
    -

    😭

    -

    I then adjusted the values until our DirectionLight的阴影 - 相机的视锥能覆盖所有场景。 我最后完成的设置

    -
    {
    -const color = 0xFFFFFF;
    -const intensity = 1;
    -const light = new THREE.DirectionalLight(color, intensity);
    -+  light.castShadow = true;
    -*  light.position.set(-250, 800, -850);
    -*  light.target.position.set(-550, 40, -450);
    -
    -+  light.shadow.bias = -0.004;
    -+  light.shadow.mapSize.width = 2048;
    -+  light.shadow.mapSize.height = 2048;
    -
    -scene.add(light);
    -scene.add(light.target);
    -+  const cam = light.shadow.camera;
    -+  cam.near = 1;
    -+  cam.far = 2000;
    -+  cam.left = -1500;
    -+  cam.right = 1500;
    -+  cam.top = 1500;
    -+  cam.bottom = -1500;
    -...
    -
    -

    我设置背景为蓝色

    -
    const scene = new THREE.Scene();
    --scene.background = new THREE.Color('black');
    -+scene.background = new THREE.Color('#DEFEFF');
    -
    -

    阴影

    -

    - - -

    -

    我希望详细讲完这个工程是有用的,并且能作为 在使用场景图(scenegraph)加载文件时 解决一些问题(issues)很好的案例。 -

    -

    - 有一件趣事,当对比.blend文件时与.gltf文件时,.blend 文件有多个灯光(lights),但加载到场景中后它们不是灯光(lights) - 一个.GLTF文件只是JSON文件,因此你能查看它内部。 - 它由几个数组构成(scenes,nodes, meshes,accessors,materials...),数组中每一项都通过索引来引用其他项。 - 虽然工作中有扩展,但它们指出了几乎所有 3d 格式的问题。 - 它们无法涵盖任何情况. -

    -

    - 总是需要更多的数据。 - 例如,我们手动导出的车辆绕着跑的路径。 - 理想的情况是,这些信息可以成为GLTF文件。但是这么做。我们需要自己写导出器,以及一些如何标记节点以了解我们希望它们如何导出或使用命名方案或类似的东西来从我们使用的任何工具获取数据 用于将数据创建到我们的应用程序中。 -

    -

    所有这些练习都留给了读者了。

    - -
    -
    -
    - - - - - - - - - - diff --git a/manual/zh/load-obj.html b/manual/zh/load-obj.html deleted file mode 100644 index ab51172963ad25..00000000000000 --- a/manual/zh/load-obj.html +++ /dev/null @@ -1,496 +0,0 @@ - - - 加载 .OBJ 文件 - - - - - - - - - - - - - - -
    -
    -

    加载 .OBJ 文件

    -
    -
    -
    -

    人们最想用three.js做的事情之一就是加载和显示三维模型。 -一个常见的格式是.OBJ 3D格式,我们现在尝试加载一个。

    -

    在网上搜索,我找到了CC-BY-NC 3.0风车3D模型,作者是阿赫多夫

    -
    - -

    我从那个网站下载了.blend文件,并将其加载到Blender并将其导出为.OBJ文件。

    -
    - -
    -

    注意:如果你从未使用过Blender,你可能会感到意外,Blender里做的事情不同于你用过的其他程序。你可能需要留出一些时间阅读Blender的一些基本操作。

    -

    我还要补充一点,3D程序通常超过1000个功能。它们是最复杂的软件之一。当我1996年第一次学习3D Studio Max时,我阅读了600页的手册70%内容,每天花几个小时,持续了大约3周。几年后当我学习Maya时,以前学过的都适用于Maya。所以,要知道如果你真的希望能够使用三维软件来构建三维资源或者修改现有的3D模型,把学习3D的计划放在你的日程表上,好好学习。

    -
    -

    一般我会使用以下的导出设置:

    -
    - -

    让我们一起来尝试将它展示出来。

    -

    基于光线文章中的定向光线(DirectionalLight)示例,结合半球光线(HemisphereLight)示例。相对于示例,我删除了所有与调整灯光相关的GUI内容,还删除了添加到场景中的立方体和球体。

    -

    第一件要做的事就是将OBJLoader添加到代码中。

    -
    import {OBJLoader} from 'three/addons/loaders/OBJLoader.js';
    -
    -

    然后创建OBJLoader的实例并通过URL加载我们的.OBJ文件,并在回调函数中将已加载完的模型添加到场景(scene)里。

    -
    {
    -  const objLoader = new OBJLoader();
    -  objLoader.load('resources/models/windmill/windmill.obj', (root) => {
    -    scene.add(root);
    -  });
    -}
    -
    -

    如果这样跑一下会怎么样?

    -

    - -

    -

    这已经相当接近,但是材质(material)显示异常。场景里材质显示异常是因为.OBJ文件格式是不包含材质数据的。

    -

    .OBJ加载器可以传入 名称/材质对 的对象。当它加载.OBJ文件时,会加载对应名称的材质,若该材质不存在就会使用默认材质。

    -

    有时.OBJ文件会带有一个.MTL文件来定义材质。在我们的示例里也会创建一个.MTL文件。.MTL文件是一个ASCII码文件,所以可以直接阅读。

    -
    # Blender MTL File: 'windmill_001.blend'
    -# Material Count: 2
    -
    -newmtl Material
    -Ns 0.000000
    -Ka 1.000000 1.000000 1.000000
    -Kd 0.800000 0.800000 0.800000
    -Ks 0.000000 0.000000 0.000000
    -Ke 0.000000 0.000000 0.000000
    -Ni 1.000000
    -d 1.000000
    -illum 1
    -map_Kd windmill_001_lopatky_COL.jpg
    -map_Bump windmill_001_lopatky_NOR.jpg
    -
    -newmtl windmill
    -Ns 0.000000
    -Ka 1.000000 1.000000 1.000000
    -Kd 0.800000 0.800000 0.800000
    -Ks 0.000000 0.000000 0.000000
    -Ke 0.000000 0.000000 0.000000
    -Ni 1.000000
    -d 1.000000
    -illum 1
    -map_Kd windmill_001_base_COL.jpg
    -map_Bump windmill_001_base_NOR.jpg
    -map_Ns windmill_001_base_SPEC.jpg
    -
    -

    我们能看到2个材质,引用了五张纹理图片,但是这些纹理文件在哪里呢?

    -
    - -

    我们只得到一个.OBJ文件以及一个.MTL文件。

    -

    我们所下载的.blend文件是包含了模型的纹理,所以我们可以让blender点击File->External Data->Unpack All Into Files导出这些文件。

    -
    - -

    选择 Write Files to Current Directory

    -
    - -

    这样就会在.blend文件的目录下创建出一个textures文件夹。

    -
    - -

    我复制这些纹理放到我导出的.OBJ目录里。

    -
    - -

    现在.MTL文件就能加载到这些纹理。

    -

    首先要引用 MTLLoader;

    -
    import * as THREE from 'three';
    -import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -import {OBJLoader} from 'three/addons/loaders/OBJLoader.js';
    -+import {MTLLoader} from 'three/addons/loaders/MTLLoader.js';
    -
    -

    -

    然后我们先加载.MTL文件,在它加载完材质后利用MtlObjBridge将材质传给OBJLoader,再加载.OBJ文件。

    -
    {
    -+  const mtlLoader = new MTLLoader();
    -+  mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => {
    -+    mtl.preload();
    -+    objLoader.setMaterials(mtl);
    -    objLoader.load('resources/models/windmill/windmill.obj', (root) => {
    -      scene.add(root);
    -    });
    -+  });
    -}
    -
    -

    尝试一下:

    -

    - -

    -

    注意当旋转模型时,会发现风车布是消失了的。

    -
    - -

    我们需要设置这个材质为双面,在关于材质的文章有详细说明。 -在.MTL里不好修复这个问题,在我脑海里有三个解决办法:

    -
      -
    1. 加载模型后,遍历所有材质,设置成双面。

      -
       const mtlLoader = new MTLLoader();
      - mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => {
      -   mtl.preload();
      -   for (const material of Object.values(mtl.materials)) {
      -     material.side = THREE.DoubleSide;
      -   }
      -   ...
      -

      这样能解决问题,但是最理想的情况下是,仅设置特定的材质为双面,其它无需设置成双面,毕竟渲染双面的效率低于单面。

      -
    2. -
    3. 手动设置一个特定材质。

      -

      看到.MTL里有两个材质,一个是"windmill"另一个是"Material"。经过反复尝试,发现可以单独设置一个材质。

      -
       const mtlLoader = new MTLLoader();
      - mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => {
      -   mtl.preload();
      -   mtl.materials.Material.side = THREE.DoubleSide;
      -   ...
      -
    4. -
    5. 在.MTL文件上无法解决,可以不使用它而使用自行创建的材质。

      -
       objLoader.load('resources/models/windmill/windmill.obj', (root) => {
      -   const materials = {
      -     Material: new THREE.MeshPhongMaterial({...}),
      -     windmill: new THREE.MeshPhongMaterial({...}),
      -   };
      -   root.traverse(node => {
      -     const material = materials[node.material?.name];
      -     if (material) {
      -       node.material = material;
      -     }
      -   })
      -   scene.add(root);
      - });
      -
    6. -
    -

    采用哪个方案取决于你。1是最简单,3是最灵活,2是两者之间,现在我选择2 。

    -

    修改之后,当你从后面看的时候,你仍然可以看到风车布,但是还有一个问题。如果我们放大近看,我们看到的东西正在变成块状。

    -
    - -

    发生了什么?

    -

    看看纹理,有2个纹理命名包含了NOR,这是法线贴图(NORmal map)。法线贴图通常是紫色的,而凹凸贴图则是黑白的。法线贴图表示表面的方向,而凹凸贴图则表示表面的高度。

    -
    - -

    MTLLoader源码norm是标记为法线贴图,我们手动修改一下.MTL文件。

    -
    # Blender MTL File: 'windmill_001.blend'
    -# Material Count: 2
    -
    -newmtl Material
    -Ns 0.000000
    -Ka 1.000000 1.000000 1.000000
    -Kd 0.800000 0.800000 0.800000
    -Ks 0.000000 0.000000 0.000000
    -Ke 0.000000 0.000000 0.000000
    -Ni 1.000000
    -d 1.000000
    -illum 1
    -map_Kd windmill_001_lopatky_COL.jpg
    --map_Bump windmill_001_lopatky_NOR.jpg
    -+norm windmill_001_lopatky_NOR.jpg
    -
    -newmtl windmill
    -Ns 0.000000
    -Ka 1.000000 1.000000 1.000000
    -Kd 0.800000 0.800000 0.800000
    -Ks 0.000000 0.000000 0.000000
    -Ke 0.000000 0.000000 0.000000
    -Ni 1.000000
    -d 1.000000
    -illum 1
    -map_Kd windmill_001_base_COL.jpg
    --map_Bump windmill_001_base_NOR.jpg
    -+norm windmill_001_base_NOR.jpg
    -map_Ns windmill_001_base_SPEC.jpg
    -
    -

    现在当我们加载它时它将使用法线贴图作为法线贴图,我们可以看到叶片的背面。

    -

    - -

    -

    现在尝试加载不同的模型:

    -

    网络搜索我找到CC-BY-NC 风车3D模型,由Roger Gerzner / GERIZ.3D Art制作。

    -
    - -

    它已经有了一个.obj版本。让我们加载它(注意,我现在已经删除了.MTL加载器)

    -
    -  objLoader.load('resources/models/windmill/windmill.obj', ...
    -+  objLoader.load('resources/models/windmill-2/windmill.obj', ...
    -
    -

    - -

    -

    嗯,没有东西出现。是什么问题?我想知道这个模型的尺寸是多少?我们可以问THREE.js模型的大小,并尝试自动设置相机的参数。

    -

    首先,我们可以让THREE.js计算一个包含我们刚刚加载的场景的盒子,并计算出它的大小和中心。

    -
    objLoader.load('resources/models/windmill_2/windmill.obj', (root) => {
    -  scene.add(root);
    -
    -+  const box = new THREE.Box3().setFromObject(root);
    -+  const boxSize = box.getSize(new THREE.Vector3()).length();
    -+  const boxCenter = box.getCenter(new THREE.Vector3());
    -+  console.log(boxSize);
    -+  console.log(boxCenter);
    -
    -

    JavaScript控制台能看到:

    -
    size 2123.6499788469982
    -center p {x: -0.00006103515625, y: 770.0909731090069, z: -3.313507080078125}
    -
    -

    我们的相机目前只显示大约100个单位,最近0.1和最远100。我们的地平面只有40个单位宽,所以这个风车模型是如此之大,达2000个单位,摄像机被包含在模型里面,所有能展示的部分都在截锥体外面。

    -
    - -

    我们可以手动修复该问题,也可以使相机自动对场景进行构图。 让我们尝试一下,我们可以使用刚计算出的框调整摄像机设置以查看整个场景。 请注意,关于将相机放置在哪里,并没有正确的答案。 我们可以从任何方向,在任何高度面对场景,因此我们只需要选择一些东西即可。

    -

    关于相机的文章讨论提到,相机定义了一个截锥体。该截锥体由视场(fov:the field of view)和远(far)近(near)设置定义。我们想知道的是,给定当前的视场,摄像机需要设置多远,才能使包含场景的盒子在截锥体内,假设截锥体永远延伸,即假设near是0.00000001,far是无穷大(infinity)。

    -

    因为我们知道盒子的大小也知道视野(FOV:the field of view),所以我们有了这个三角形。

    -
    - -

    你可以看到在左边的是相机,在它前方投射出蓝色的截锥体。我们只是计算了包含风车的盒子。我们需要计算相机应该离方框有多远,使得盒子在截锥体内。

    -

    利用基本的直角三角形函数和SOHCAHTOA,已知截锥体的视场和方框的大小,就可以计算出距离。

    -
    - -

    根据该图,计算距离的公式为:

    -
    distance = halfSizeToFitOnScreen / tangent(halfFovY)
    -
    -

    我们把它转换成代码。首先做一个函数,计算出距离distance。然后移动相机,离盒子中心distance远。然后把摄像机对准盒子中心。

    -
    function frameArea(sizeToFitOnScreen, boxSize, boxCenter, camera) {
    -  const halfSizeToFitOnScreen = sizeToFitOnScreen * 0.5;
    -  const halfFovY = THREE.MathUtils.degToRad(camera.fov * .5);
    -  const distance = halfSizeToFitOnScreen / Math.tan(halfFovY);
    -
    -  // compute a unit vector that points in the direction the camera is now
    -  // from the center of the box
    -  const direction = (new THREE.Vector3()).subVectors(camera.position, boxCenter).normalize();
    -
    -  // move the camera to a position distance units way from the center
    -  // in whatever direction the camera was from the center already
    -  camera.position.copy(direction.multiplyScalar(distance).add(boxCenter));
    -
    -  // pick some near and far values for the frustum that
    -  // will contain the box.
    -  camera.near = boxSize / 100;
    -  camera.far = boxSize * 100;
    -
    -  camera.updateProjectionMatrix();
    -
    -  // point the camera to look at the center of the box
    -  camera.lookAt(boxCenter.x, boxCenter.y, boxCenter.z);
    -}
    -
    -

    我们传入了两种尺寸:盒子大小boxSize和尺寸sizeToFitOnScreen。如果我们只是传递boxSize并将其用作sizeToFitOnScreen,那么数学运算将使这个盒子完美地嵌入到截锥。我们想要在上面和下面留出一点额外的空间,所以我们要传一个稍微大一点的尺寸。

    -
    {
    -  const objLoader = new OBJLoader();
    -  objLoader.load('resources/models/windmill_2/windmill.obj', (root) => {
    -    scene.add(root);
    -+    // compute the box that contains all the stuff
    -+    // from root and below
    -+    const box = new THREE.Box3().setFromObject(root);
    -+
    -+    const boxSize = box.getSize(new THREE.Vector3()).length();
    -+    const boxCenter = box.getCenter(new THREE.Vector3());
    -+
    -+    // set the camera to frame the box
    -+    frameArea(boxSize * 1.2, boxSize, boxCenter, camera);
    -+
    -+    // update the Trackball controls to handle the new size
    -+    controls.maxDistance = boxSize * 10;
    -+    controls.target.copy(boxCenter);
    -+    controls.update();
    -  });
    -}
    -
    -

    你可以看到在上面,我们传递了boxSize * 1.2,当试图把它放入截锥体时,给了20%额外空间在盒子的上下面。我们还更新了OrbitControls,使相机转动时是围绕它进行转动。

    -

    现在的效果是:

    -

    - -

    -

    这效果已经差不多了。用鼠标旋转照相机,你应该能看到风车。问题是风车很大,盒子中心约为(0,770,0)。所以,当我们把相机从(0,10,20)移到到distance单位距离,摄像头是风车的正下方。

    -
    - -

    修改代码使摄像头不管在哪个方向,都能对准盒子侧面中心。我们要做的就是把盒子到摄像机的y分量归零。然后,当我们标准化这个向量它就会变成一个平行于XZ平面的向量。换句话说,平行于地面。

    -
    -// compute a unit vector that points in the direction the camera is now
    --// from the center of the box
    --const direction = (new THREE.Vector3()).subVectors(camera.position, boxCenter).normalize();
    -+// compute a unit vector that points in the direction the camera is now
    -+// in the xz plane from the center of the box
    -+const direction = (new THREE.Vector3())
    -+    .subVectors(camera.position, boxCenter)
    -+    .multiply(new THREE.Vector3(1, 0, 1))
    -+    .normalize();
    -
    -

    如果你看风车的底部,你会看到一个小正方形,这是我们的地板。

    -
    - -

    它只有40x40个单位,相对于风车来说太小了。由于风车超过2000个单位大,让我们改变地面的大小来适配。我们还需要调整重复次数,否则不能看到棋盘。

    -
    -const planeSize = 40;
    -+const planeSize = 4000;
    -
    -const loader = new THREE.TextureLoader();
    -const texture = loader.load('resources/images/checker.png');
    -texture.wrapS = THREE.RepeatWrapping;
    -texture.wrapT = THREE.RepeatWrapping;
    -texture.magFilter = THREE.NearestFilter;
    --const repeats = planeSize / 2;
    -+const repeats = planeSize / 200;
    -texture.repeat.set(repeats, repeats);
    -
    -

    现在再看看这个风车:

    -

    - -

    -

    现在把材质加回去,就像之前有一个.mtl文件,它引用了一些纹理。但是看了一下这些文件,我很快发现了一个问题。

    -
     $ ls -l windmill
    - -rw-r--r--@ 1 gregg  staff       299 May 20  2009 windmill.mtl
    - -rw-r--r--@ 1 gregg  staff    142989 May 20  2009 windmill.obj
    - -rw-r--r--@ 1 gregg  staff  12582956 Apr 19  2009 windmill_diffuse.tga
    - -rw-r--r--@ 1 gregg  staff  12582956 Apr 20  2009 windmill_normal.tga
    - -rw-r--r--@ 1 gregg  staff  12582956 Apr 19  2009 windmill_spec.tga
    -
    -

    这是TARGA (.tga)文件,并且体积很大!

    -

    THREE.js实际上有一个TGA加载器,但是在大多数情况下使用它是错误的。如果你制作一个查看器,你想让用户查看他们在网上找到的随机3D文件,那么可能,只是可能,你可能想加载TGA文件。(*)

    -

    TGA文件的一个问题是根本不能很好地压缩。TGA只支持非常简单的压缩,在上面我们可以看到文件根本没有被压缩,因为它们的大小完全相同的几率非常低。每个文件12M!!如果我们使用这些文件,用户必须下载36M才能看到风车。

    -

    TGA的另一个问题是,浏览器本身不支持它们,所以加载它们可能比加载.jpg和.png等受支持的格式要慢。

    -

    我很确定将它们转换成.jpg是最好的选择。看里面,我看到他们是3个通道(RGB),没有阿尔法通道(alpha)。JPG只支持3个频道,所以很适合。JPG也支持有损压缩,所以我们可以使文件更小的下载。

    -

    加载文件的大小分别为2048x2048。这对我来说似乎是一种浪费,但当然这取决于您的实际情况。我把它们分别做成1024x1024,并在Photoshop中以50%的质量保存。文件列表:

    -
     $ ls -l ../threejs.org/manual/examples/resources/models/windmill
    - -rw-r--r--@ 1 gregg  staff     299 May 20  2009 windmill.mtl
    - -rw-r--r--@ 1 gregg  staff  142989 May 20  2009 windmill.obj
    - -rw-r--r--@ 1 gregg  staff  259927 Nov  7 18:37 windmill_diffuse.jpg
    - -rw-r--r--@ 1 gregg  staff   98013 Nov  7 18:38 windmill_normal.jpg
    - -rw-r--r--@ 1 gregg  staff  191864 Nov  7 18:39 windmill_spec.jpg
    -
    -

    从36M转到0.55M!当然,艺术家可能不喜欢这种压缩,所以一定要咨询他们,进行权衡。

    -

    现在,为了使用.mtl文件,我们需要编辑它来引用.jpg文件,而不是.tga文件。幸运的是,它是一个简单的文本文件,所以很容易编辑。

    -
    newmtl blinn1SG
    -Ka 0.10 0.10 0.10
    -
    -Kd 0.00 0.00 0.00
    -Ks 0.00 0.00 0.00
    -Ke 0.00 0.00 0.00
    -Ns 0.060000
    -Ni 1.500000
    -d 1.000000
    -Tr 0.000000
    -Tf 1.000000 1.000000 1.000000
    -illum 2
    --map_Kd windmill_diffuse.tga
    -+map_Kd windmill_diffuse.jpg
    -
    --map_Ks windmill_spec.tga
    -+map_Ks windmill_spec.jpg
    -
    --map_bump windmill_normal.tga
    --bump windmill_normal.tga
    -+map_bump windmill_normal.jpg
    -+bump windmill_normal.jpg
    -
    -

    现在。mtl文件指向一些合理大小的纹理,我们需要加载它,所以我们就像上面做的那样,首先加载材质,然后将它们设置在OBJLoader上。

    -
    {
    -+  const mtlLoader = new MTLLoader();
    -+  mtlLoader.load('resources/models/windmill_2/windmill-fixed.mtl', (mtl) => {
    -+    mtl.preload();
    -+    const objLoader = new OBJLoader();
    -+    objLoader.setMaterials(mtl);
    -    objLoader.load('resources/models/windmill/windmill.obj', (root) => {
    -      root.updateMatrixWorld();
    -      scene.add(root);
    -      // compute the box that contains all the stuff
    -      // from root and below
    -      const box = new THREE.Box3().setFromObject(root);
    -
    -      const boxSize = box.getSize(new THREE.Vector3()).length();
    -      const boxCenter = box.getCenter(new THREE.Vector3());
    -
    -      // set the camera to frame the box
    -      frameArea(boxSize * 1.2, boxSize, boxCenter, camera);
    -
    -      // update the Trackball controls to handle the new size
    -      controls.maxDistance = boxSize * 10;
    -      controls.target.copy(boxCenter);
    -      controls.update();
    -    });
    -+  });
    -}
    -
    -

    在我们真正尝试之前,我想到了一些问题,我不想显示出失败,我只是要检查一下。

    -

    问题1:三个MTLLoader创建的材质会将材质的漫反射颜色,乘以漫反射纹理贴图。

    -

    这是一个很有用的特性,但是要查看行上面的.MTL文件

    -
    Kd 0.00 0.00 0.00
    -
    -

    将漫反射颜色设置为0。纹理贴图的值* 0 =黑色! 但可能用于制造风车的建模工具,并没有将漫反射纹理贴图乘以漫反射颜色,所以在艺术家制作模型时仍显示正常。

    -

    为了修复它,这样修改:

    -
    Kd 1.00 1.00 1.00
    -
    -

    因为 纹理贴图的值 * 1 = 纹理贴图的值.

    -

    问题2:高光的颜色也是黑色的

    -

    Ks开头的行指定了高光颜色。跟上面漫反射贴图一样,使用高光贴图的颜色来实现高光效果。这里仍需要设置一个高光颜色。

    -

    跟上面一样,直接修改.MTL文件:

    -
    -Ks 0.00 0.00 0.00
    -+Ks 1.00 1.00 1.00
    -
    -

    问题3: windmill_normal.jpg 是法线贴图而不是凹凸贴图。

    -

    跟上面一样修改.MTL文件:

    -
    -map_bump windmill_normal.jpg
    --bump windmill_normal.jpg
    -+norm windmill_normal.jpg
    -
    -

    现在试一试附带材质的效果:

    -

    - -

    -

    加载模型经常遇到这类问题。常见的问题包括

    -
      -
    • 需要知道大小

      -

      上图中,我们让摄像机试图将场景框起来,但这并不总是合适的做法。一般来说,最合适的做法是自己制作模型或下载模型,加载到一些3D软件中,查看它们的大小,并在需要时进行调整。

      -
    • -
    • 方向错误 -Three.js一般是以Y为上(up)。有些建模包默认是Z为上,有些是Y为上。有些是可设置的。如果你遇到这种情况,你加载一个模型,它在它的一边。你可以让代码在加载后旋转模型(不推荐),或者你可以模型加载到你最喜欢的建模工具里旋转物体至合适的位置,就像你会为您的网站编辑图像而不是使用代码调整它。Blender导出时可以选择改变方向。

      -
    • -
    • 没有.mtl文件、错误的材质或不兼容的参数

      -

      上面我们使用了一个.MTL文件,该文件可以帮助我们加载材料,但是存在问题。 我们手动编辑了.MTL文件进行修复。 -通常,也可以在.OBJ文件中查看是否有什么材质,或者将.OBJ文件加载到THREE.js中,然后打印出所有材质。 -然后,修改代码以制作自定义材质并在适当的位置对其进行调整,比如可以创建一个名称/材质对对象传递给加载器,而不是加载.MTL文件,也可以在场景加载后遍历场景进行调整。

      -
    • -
    • 纹理过大

      -

      大多数3D模型是为建筑、电影和广告或游戏制作的。对于建筑和电影,没有人真正关心纹理的大小。对于游戏来说,因为游戏内存有限,并且大多数游戏都在本地运行,所以文件也不能太多。网页一般要求加载快,所以你需要看纹理,并尽量使他们尽可能小,但仍然看起来很好。事实上,第一个风车模型也应该调整纹理,它们目前总共是10M。

      -

      还记得我们在关于纹理的文章中提到的纹理占用内存,所以一个50k的JPG扩展到4096x4096会下载很快,但仍然需要大量的内存。

      -
    • -
    -

    最后我想展示的是让风车旋转起来。不幸的是. obj文件没有层次结构(hierarchy)。这意味着每个风车模型基本上都是一个单独的网格(mesh)。你不能转动风车的叶片,因为它们没有与建筑物的其他部分分开。

    -

    这就是为什么.obj不是一个好的3D格式的主要原因之一。如果我猜一下,它比其他格式更常见的原因是它很简单,而且不支持很多特性。特别是如果你在做一些静态的物体,比如建筑图像,没必要动起来。

    -

    接下来我们将尝试加载一个gLTF场景。gLTF格式支持更多特性。

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/loading-3d-models.html b/manual/zh/loading-3d-models.html deleted file mode 100644 index be070abffcec43..00000000000000 --- a/manual/zh/loading-3d-models.html +++ /dev/null @@ -1,151 +0,0 @@ - - - 加载 3D 模型 - - - - - - - - - - - - - -
    -
    -

    加载 3D 模型

    -
    -
    -
    - -

    - 3D 模型有数百种文件格式,每种格式都有不同的用途、各异的特性和不同的复杂度。虽然 - - three.js 提供了许多加载器,但选择正确的格式和工作流可以节省大量时间,避免后续的麻烦。某些格式难以使用、不适合实时场景,或者目前尚未完全支持。 -

    - -

    - 本指南提供了适用于大多数用户的推荐工作流,以及在遇到问题时的排查建议。 -

    - -

    开始之前

    - -

    - 如果你是第一次搭建本地服务器,请先阅读安装页面。正确托管文件可以避免许多查看 3D 模型时的常见错误。 -

    - -

    推荐工作流

    - -

    - 我们推荐尽可能使用 glTF(GL Transmission Format)格式。该格式的 .GLB.GLTF 两种版本都得到了良好支持。由于 glTF 专注于运行时资源交付,它体积紧凑、加载速度快。支持的特性包括网格、材质、纹理、蒙皮、骨骼、变形目标、动画、灯光和相机。 -

    - -

    - 公共领域的 glTF 文件可以在 - - Sketchfab 等网站上找到,许多工具也支持 glTF 导出: -

    - - - -

    - 如果你常用的工具不支持 glTF,可以考虑向作者请求添加 glTF 导出功能,或者在 - glTF 路线图讨论帖中发帖。 -

    - -

    - 当 glTF 不可用时,也可以使用 FBX、OBJ 或 COLLADA 等常见格式,它们同样可用且持续维护中。 -

    - -

    加载

    - -

    - three.js 默认只包含少数加载器(如 `ObjectLoader`),其他加载器需要单独添加到你的应用中。 -

    - -
    -import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
    -
    - -

    - 导入加载器后,就可以向场景中添加模型了。不同加载器的语法各不相同——使用其他格式时,请查阅对应加载器的示例和文档。对于 glTF,使用全局脚本的方式如下: -

    - -
    -const loader = new GLTFLoader();
    -
    -loader.load( 'path/to/model.glb', function ( gltf ) {
    -
    -  scene.add( gltf.scene );
    -
    -}, undefined, function ( error ) {
    -
    -  console.error( error );
    -
    -} );
    -
    - -

    故障排查

    - -

    - 你花了好几个小时精心制作了一个模型,将它加载到网页中,结果——天哪!😭 它变形了、颜色不对,或者完全看不到。请按以下步骤排查: -

    - -
      -
    1. - 检查 JavaScript 控制台是否有错误,并确保在调用 `.load()` 时使用了 `onError` 回调来记录错误信息。 -
    2. -
    3. - 在其他应用中查看模型。对于 glTF,可以使用 - three.js 和 - babylon.js 的拖放查看器。如果模型在一个或多个应用中显示正常,请向 three.js 提交 bug。如果模型在所有应用中都无法显示,我们强烈建议向创建该模型的应用提交 bug。 -
    4. -
    5. - 尝试将模型放大或缩小 1000 倍。许多模型的缩放比例不同,如果相机位于模型内部,大型模型可能不会显示。 -
    6. -
    7. - 尝试添加并调整光源位置。模型可能隐藏在黑暗中。 -
    8. -
    9. - 在网络面板中查找失败的纹理请求,例如 `"C:\\Path\To\Model\texture.jpg"`。请使用相对于模型的路径,如 `images/texture.jpg`——这可能需要在文本编辑器中修改模型文件。 -
    10. -
    - -

    寻求帮助

    - -

    - 如果你已经完成了上述排查步骤,模型仍然无法正常工作,正确的求助方式能帮你更快找到解决方案。在 - three.js 论坛上发帖提问,并尽可能附上你的模型(或具有相同问题的简化模型),提供你手头的所有格式。请包含足够的信息以便他人快速复现问题——最好提供一个在线演示。 -

    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/material-table.html b/manual/zh/material-table.html deleted file mode 100644 index aecc2f70eb921c..00000000000000 --- a/manual/zh/material-table.html +++ /dev/null @@ -1,46 +0,0 @@ - - - 材质特性表 - - - - - - - - - - - - - - -
    -
    -

    材质特性表

    -
    -
    -
    -

    在THREE.js中最常见的材质就是网格材质了,这里有一个表格,分别显示了哪些材质支持哪些特性。

    -
    -
    - - -
    -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/materials.html b/manual/zh/materials.html deleted file mode 100644 index 163143acb1a7d5..00000000000000 --- a/manual/zh/materials.html +++ /dev/null @@ -1,229 +0,0 @@ - - - 材质 - - - - - - - - - - - - - - -
    -
    -

    材质

    -
    -
    -
    -

    本文是关于 three.js 系列文章的一部分。第一篇文章是 three.js 基础。 如果你还没有读过它,建议先从那里开始。

    -

    Three.js提供了多种类型的材质(material)。它们定义了对象在场景中的外型。你使用哪种材质取决于你想达到的目的。

    -

    有2种方法可以设置大部分的材质属性。一种是在实例化时设置,也就是我们之前看到的。

    -
    const material = new THREE.MeshPhongMaterial({
    -  color: 0xFF0000,    // 红色 (也可以使用CSS的颜色字符串)
    -  flatShading: true,
    -});
    -
    -

    另一种是在实例化之后再设置

    -
    const material = new THREE.MeshPhongMaterial();
    -material.color.setHSL(0, 1, .5);  // 红色
    -material.flatShading = true;
    -
    -

    注意,THREE.Color 类型的属性有多种设置方式。

    -
    material.color.set(0x00FFFF);    // 同 CSS的 #RRGGBB 风格
    -material.color.set(cssString);   // 任何 CSS 颜色字符串, 比如 'purple', '#F32',
    -                                 // 'rgb(255, 127, 64)',
    -                                 // 'hsl(180, 50%, 25%)'
    -material.color.set(someColor)    // 其他一些 THREE.Color
    -material.color.setHSL(h, s, l)   // 其中 h, s, 和 l 从 0 到 1
    -material.color.setRGB(r, g, b)   // 其中 r, g, 和 b 从 0 到 1
    -
    -

    在实例化时,你可以传递一个十六进制数字或CSS字符串作为参数。

    -
    const m1 = new THREE.MeshBasicMaterial({color: 0xFF0000});         // 红色
    -const m2 = new THREE.MeshBasicMaterial({color: 'red'});            // 红色
    -const m3 = new THREE.MeshBasicMaterial({color: '#F00'});           // 红色
    -const m4 = new THREE.MeshBasicMaterial({color: 'rgb(255,0,0)'});   // 红色
    -const m5 = new THREE.MeshBasicMaterial({color: 'hsl(0,100%,50%)'}); // 红色
    -
    -

    那么,我们就来看看three.js的几个材质。

    -

    MeshBasicMaterial 不受光照的影响。MeshLambertMaterial 只在顶点计算光照,而 MeshPhongMaterial 则在每个像素计算光照。MeshPhongMaterial 还支持镜面高光。

    -
    -
    -
    -
    Basic
    -
    -
    -
    -
    Lambert
    -
    -
    -
    -
    Phong
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    使用相同材质的低多边形
    - -

    MeshPhongMaterialshininess 设置决定了镜面高光的光泽度。它的默认值是30。

    -
    -
    -
    -
    shininess: 0
    -
    -
    -
    -
    shininess: 30
    -
    -
    -
    -
    shininess: 150
    -
    -
    - -

    请注意,将 MeshLambertMaterialMeshPhongMaterialemissive 属性设置为颜色,并将颜色设置为黑色(phong的 shininess 为0),最终看起来就像 MeshBasicMaterial 一样。

    -
    -
    -
    -
    -
    Basic
    -
    color: 'purple'
    -
    -
    -
    -
    -
    -
    Lambert
    -
    color: 'black'
    -
    emissive: 'purple'
    -
    -
    -
    -
    -
    -
    Phong
    -
    color: 'black'
    -
    emissive: 'purple'
    -
    shininess: 0
    -
    -
    -
    - -

    既然MeshBasicMaterialMeshLambertMaterial可以做到的,MeshPhongMaterial也可以做到,那为什么还要有这3种材质呢?原因是更复杂的材质会消耗更多的GPU功耗。在一个较慢的GPU上,比如说手机,你可能想通过使用一个不太复杂的材质来减少绘制场景所需的GPU功耗。同样,如果你不需要额外的功能,那就使用最简单的材质。如果你不需要照明和镜面高光,那么就使用 MeshBasicMaterial

    -

    MeshToonMaterialMeshPhongMaterial 类似,但有一个很大的不同。它不是平滑地着色,而是使用一个渐变图(一个X乘1的纹理(X by 1 texture))来决定如何着色。默认使用的渐变图是前70%的部分使用70%的亮度,之后的部分使用100%的亮度,当然,你可以定义你自己的渐变图。这最终会给人一种2色调的感觉,看起来就像卡通一样。

    -
    -
    -
    - -

    接下来是2种基于物理渲染(Physically Based Rendering)的材质。Physically Based Rendering通常简称为PBR。

    -

    之前提到的材质使用简单的数学来制作,看起来是3D的,但它们并不是现实世界中实际存在的东西。2种PBR材质使用更复杂的数学来接近现实世界中的实际情况。

    -

    第一个是 MeshStandardMaterialMeshPhongMaterialMeshStandardMaterial 最大的区别是它们使用的参数不同。MeshPhongMaterial 有一个参数用来设置 shininess 属性。MeshStandardMaterial 有2个参数用来分别设置 roughnessmetalness 属性。

    -

    在基本层面,roughnessshininess 的对立面。粗糙度(roughness)高的东西,比如棒球,就不会有很强烈的反光,而不粗糙的东西,比如台球,就很有光泽。粗糙度的范围从0到1。

    -

    另一个设定,metalness,说的是材质的金属度。金属与非金属的表现不同。0代表非金属,1代表金属。

    -

    这里是 MeshStandardMaterial 的一个快速示例,从左至右看,粗糙度从0到1,从上至下看,金属度从0到1。

    -
    - -

    MeshPhysicalMaterialMeshStandardMaterial 相同,但它增加了一个clearcoat 参数,该参数从0到1,决定了要涂抹的清漆光亮层的程度,还有一个 clearCoatRoughness 参数,指定光泽层的粗糙程度。

    -

    这里是和上面一样的按 metalness 划分的 roughness 网格,但可以设置 clearcoatclearCoatRoughness

    -
    - -

    各种标准材质的构建速度从最快到最慢:MeshBasicMaterialMeshLambertMaterialMeshPhongMaterial ➡ -MeshStandardMaterialMeshPhysicalMaterial。构建速度越慢的材质,做出的场景越逼真,但在低功率或移动设备上,你可能需要思考代码的设计,使用构建速度较快的材质。

    -

    接下来的3种材质有特殊用途。ShadowMaterial 用于获取阴影创建的数据。我们还没有介绍过阴影。等到我们介绍的时候,我们会使用这个材质来看看其背后的原理。

    -

    MeshDepthMaterial 渲染每个像素的深度,其中处在摄像机负近端面的像素其深度为0,处在摄像机负远端面的像素其深度为1。使用这个属性可以实现一些特殊效果,这在之后我们会再讨论。

    -
    -
    -
    -
    -
    - -

    MeshNormalMaterial 会显示几何体的法线法线是一个特定的三角形或像素所面对的方向。MeshNormalMaterial 会绘制视图空间法线(相对于摄像机的法线)。x 是红色, -y 是绿色, 以及 -z 是蓝色,所以朝向右边的东西是粉红色,朝向左边的是水蓝色,朝上的是浅绿色,朝下的是紫色,朝向屏幕的是淡紫色

    -
    -
    -
    -
    -
    - -

    ShaderMaterial 是通过three.js的着色器系统来制作自定义材质。RawShaderMaterial 则是可以用来制作完全自定义的着色器,不需要three.js的帮助。这两个材质涉及的话题都很广,我们后面会讲到。

    -

    大多数材质都共享一堆由 Material 定义的设置。所有的设置都可以在文档中找到,但我们先来看看两个最常用的属性。

    -

    flatShading:对象是否使用平面着色,默认为false

    -
    -
    -
    -
    flatShading: false
    -
    -
    -
    -
    flatShading: true
    -
    -
    - -

    side:要显示三角形的哪个面。默认值是 THREE.FrontSide,其他选项有 THREE.BackSideTHREE.DoubleSide(正反两面)。Three.js中,大多数3D对象可能都是不透明的实体,所以不需要绘制反面(面向实体内部的面)。设置 side 的最常见的原因是用于绘制平面或其他非实体对象,在这些对象中通常会看到三角形的反面。

    -

    下面是用 THREE.FrontSideTHREE.DoubleSide 绘制的6个平面。

    -
    -
    -
    -
    side: THREE.FrontSide
    -
    -
    -
    -
    side: THREE.DoubleSide
    -
    -
    - -

    关于材质,真的有很多需要考虑的地方,其实我们还有一堆东西要去做。特别是我们几乎忽略了纹理,它为我们提供了大量的选择。在我们介绍纹理之前,我们需要休息一下,介绍一下如何设置你的开发环境

    -
    -

    material.needsUpdate

    -

    -这个话题很少影响大多数three.js应用,但仅供参考......three.js会在使用材质时应用材质设置,其中 "使用 "意味着 "使用该材质的东西被渲染"。有些材质设置只应用一次,因为改变它们需要three.js做很多工作。在这种情况下,你需要设置 material.needsUpdate = true 来告诉 three.js 应用你的材质变化。当你在使用材质后再去更改设置,需要你去设置 needsUpdate的最常见的几种设置是: -

    -
      -
    • flatShading
    • -
    • 添加或删除纹理 -

      - 改变纹理是可以的,但是如果想从使用无纹理切换到使用纹理,或者从使用纹理切换到无纹理,那么你需要设置 needsUpdate = true。 -

      -

      在从有纹理到无纹理的情况下,往往是使用1x1像素的白色纹理更好。

      -
    • -
    -

    如上所述,大多数应用程序从未遇到这些问题。大多数应用程序不会在平面阴影和非平面阴影之间切换。大多数应用程序也要么使用纹理,要么使用纯色给定的材料,他们很少从使用一个切换到使用另一个。 -

    -
    - -

    - - - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/matrix-transformations.html b/manual/zh/matrix-transformations.html deleted file mode 100644 index e4edecface076f..00000000000000 --- a/manual/zh/matrix-transformations.html +++ /dev/null @@ -1,116 +0,0 @@ - - - 矩阵变换 - - - - - - - - - - - - - - -
    -
    -

    矩阵变换

    -
    -
    -
    - -

    - Three.js 使用 `matrices`(矩阵)来表示 3D 变换:平移(position)、旋转和缩放。 - 每个 `Object3D` 实例都包含一个 `matrix`,用于存储该对象的位置、旋转与缩放。 - 本页介绍如何更新对象的变换。 -

    - -

    便捷属性与 `matrixAutoUpdate`

    - -

    - 更新对象变换有两种方式: -

    -
      -
    1. - 修改对象的 `position`、`quaternion`、`scale` 属性, - 由 three.js 根据这些属性重新计算矩阵: -
      -object.position.copy( start_position );
      -object.quaternion.copy( quaternion );
      -
      - 默认情况下 `matrixAutoUpdate = true`,矩阵会自动重算。 - 如果对象是静态的,或你希望手动控制重算时机,可将其设为 `false` 以获得更好性能: -
      -object.matrixAutoUpdate = false;
      -
      - 修改属性后,再手动更新矩阵: -
      -object.updateMatrix();
      -
      -
    2. -
    3. - 直接修改对象矩阵。`Matrix4` 提供了多种矩阵修改方法: -
      -object.matrix.makeRotationFromQuaternion( quaternion );
      -object.matrix.setPosition( start_position );
      -object.matrixAutoUpdate = false;
      -
      - 注意这种方式下 `matrixAutoUpdate` 必须设为 `false`, - 并且要确保不要调用 `updateMatrix`。 - 调用 `updateMatrix` 会根据 `position`、`scale` 等重新计算矩阵, - 从而覆盖你手动写入的矩阵内容。 -
    4. -
    - -

    对象矩阵与世界矩阵

    -

    - 对象的 `matrix` 存储的是相对于父对象的局部变换; - 若要获取对象在世界坐标中的变换,需要访问对象的世界矩阵。 -

    -

    - 当父对象或子对象变换发生变化时,可以调用 `object.updateMatrixWorld()`, - 触发子对象世界矩阵更新。 -

    -

    - 也可以通过 `applyMatrix4()` 对对象施加变换。注意:该方法底层依赖 - `Matrix4.decompose()`,并非所有矩阵都能这样分解。 - 例如父对象存在非均匀缩放时,子对象世界矩阵可能无法正确分解, - 此时该方法并不适用。 -

    - -

    旋转与四元数

    -

    - Three.js 提供两种 3D 旋转表示:欧拉角与四元数,并支持相互转换。 - 欧拉角会受到“万向节锁(gimbal lock)”问题影响, - 在某些姿态下会丢失一个自由度(导致无法绕某一轴旋转)。 - 因此,对象旋转在内部始终存储为四元数。 -

    -

    - 旧版本库曾有 `useQuaternion` 属性,设为 false 时会用欧拉角计算对象矩阵。 - 这种做法已废弃。现在应使用 `object.setRotationFromEuler()`, - 它会同步更新四元数。 -

    - -
    -
    -
    - - - - - - - - - - - diff --git a/manual/zh/multiple-scenes.html b/manual/zh/multiple-scenes.html deleted file mode 100644 index 2fcca250306255..00000000000000 --- a/manual/zh/multiple-scenes.html +++ /dev/null @@ -1,615 +0,0 @@ - - - 多个画布,多个场景 - - - - - - - - - - - - - - -
    -
    -

    多个画布,多个场景

    -
    -
    -
    -

    Three.js中一个老生常谈的问题就是多个场景的渲染。比如当你想制作一个由多个三维图像构成的商业网站时,很容易想到的解决办法就是为每一个三维图像创建一张画布(Canvas),并为每张画布添加一个渲染器(Renderer)

    -

    但是,这样你会遇到两个很明显的问题:

    -
    -
      -
    1. 浏览器限制了WebGL上下文(WebGL contexts)的数量。
    2. -
    -

    通常浏览器将其限制为 8 个,一旦超出这个数量,最先创建的WebGL上下文就会被自动弃用。

    -
      -
    1. 无法在不同的WebGL上下文中共享资源。
    2. -
    -

    不同WebGL上下文无法共享任何资源,这就意味着,假设你想要在两个Canvas中各加载一个10Mb的模型,并且每个模型都20Mb的纹理,那么这个模型和纹理将分别被加载两次。因此,初始化、着色器编译等都将运行两次,随着Canvas数量的增减,情况会变得与来越糟糕。

    -
    -

    那么,我们该如何解决这个问题?

    -

    基本方法

    -

    解决办法就是用一张Canvas在整个背景中填充视口,并利用一些其他元素来代表每个“虚拟画布”(virtual canvas),即只在一张Canvas中加载一个Renderer,并为每个virtual canvas创建一个场景(Scene)。这样我们只需要确保每个virtual canvas正确的位置,THREE.js就会将它们渲染在屏幕上相应的位置。

    -

    利用这个方法,由于我们只添加了一张Canvas,也就仅仅使用了一个WebGL contexts,因此不仅解决了资源共享问题,且不会引发WebGL上下文数量限制问题。

    -

    以一个只有两个Scene的简单demo为例。首先,创建HTML结构:

    -
    <canvas id="c"></canvas>
    -<p>
    -  <span id="box" class="diagram left"></span>
    -  I love boxes. Presents come in boxes.
    -  When I find a new box I'm always excited to find out what's inside.
    -</p>
    -<p>
    -  <span id="pyramid" class="diagram right"></span>
    -  When I was a kid I dreamed of going on an expedition inside a pyramid
    -  and finding a undiscovered tomb full of mummies and treasure.
    -</p>
    -
    -

    接着为它设置一些基本样式:

    -
    #c {
    -  position: fixed;
    -  left: 0;
    -  top: 0;
    -  width: 100%;
    -  height: 100%;
    -  display: block;
    -  z-index: -1;
    -}
    -.diagram {
    -  display: inline-block;
    -  width: 5em;
    -  height: 3em;
    -  border: 1px solid black;
    -}
    -.left {
    -  float: left;
    -  margin-right: .25em;
    -}
    -.right {
    -  float: right;
    -  margin-left: .25em;
    -}
    -
    -

    我们将Canvas画幅设置为充满整个屏幕,并将其z-index设置为-1,使它始终位于其他元素的后面。当然,我们要给virtual canvas设置相应的宽高,因为此时还没有任何内容可以撑起它的大小。

    -

    现在,创建两个Scene,其中一个添加了立方体,另一个为菱形,并分别为这两个Scene添加灯光(Light)和相机(Camera)

    -
    function makeScene(elem) {
    -  const scene = new THREE.Scene();
    -
    -  const fov = 45;
    -  const aspect = 2;  // the canvas default
    -  const near = 0.1;
    -  const far = 5;
    -  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -  camera.position.z = 2;
    -  camera.position.set(0, 1, 2);
    -  camera.lookAt(0, 0, 0);
    -
    -  {
    -    const color = 0xFFFFFF;
    -    const intensity = 1;
    -    const light = new THREE.DirectionalLight(color, intensity);
    -    light.position.set(-1, 2, 4);
    -    scene.add(light);
    -  }
    -
    -  return {scene, camera, elem};
    -}
    -
    -function setupScene1() {
    -  const sceneInfo = makeScene(document.querySelector('#box'));
    -  const geometry = new THREE.BoxGeometry(1, 1, 1);
    -  const material = new THREE.MeshPhongMaterial({color: 'red'});
    -  const mesh = new THREE.Mesh(geometry, material);
    -  sceneInfo.scene.add(mesh);
    -  sceneInfo.mesh = mesh;
    -  return sceneInfo;
    -}
    -
    -function setupScene2() {
    -  const sceneInfo = makeScene(document.querySelector('#pyramid'));
    -  const radius = .8;
    -  const widthSegments = 4;
    -  const heightSegments = 2;
    -  const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
    -  const material = new THREE.MeshPhongMaterial({
    -    color: 'blue',
    -    flatShading: true,
    -  });
    -  const mesh = new THREE.Mesh(geometry, material);
    -  sceneInfo.scene.add(mesh);
    -  sceneInfo.mesh = mesh;
    -  return sceneInfo;
    -}
    -
    -const sceneInfo1 = setupScene1();
    -const sceneInfo2 = setupScene2();
    -
    -

    接着创建一个视图信息获取函数renderSceneInfo()和视图渲染函数render(),用来渲染那些virtual canvas所在的元素出现在了可视区域的Scene。只需调用THREE.js的剪裁区域检测Renderer.setScissorTest方法,THREE.js就能实现仅渲染部分画布内容的功能,同时,我们需要调用Renderer.setViewportRenderer.setScissor来分别设定视口大小和剪裁区域。

    -

    参数说明如下: ->

    -
    -

    Renderer.setScissorTest( boolean : Boolean ) : null; -// 启用或禁用剪裁检测. 若启用,则只有在所定义的裁剪区域内的像素才会受之后的渲染器影响。 -Renderer.setScissor ( x : Integer, y : Integer, width : Integer, height : Integer ) : null; -//将剪裁区域设为(x, y)到(x + width, y + height) -Renderer.### setViewport ( x : Integer, y : Integer, width : Integer, height : Integer ) : null -//将视口大小设置为(x, y)到 (x + width, y + height).

    -
    -

    视图信息获取函数如下:

    -
    function renderSceneInfo(sceneInfo) {
    -  const {scene, camera, elem} = sceneInfo;
    -
    -  // get the viewport relative position of this element
    -  const {left, right, top, bottom, width, height} =
    -      elem.getBoundingClientRect();
    -
    -  const isOffscreen =
    -      bottom < 0 ||
    -      top > renderer.domElement.clientHeight ||
    -      right < 0 ||
    -      left > renderer.domElement.clientWidth;
    -
    -  if (isOffscreen) {
    -    return;
    -  }
    -
    -  camera.aspect = width / height;
    -  camera.updateProjectionMatrix();
    -
    -  const positiveYUpBottom = canvasRect.height - bottom;
    -  renderer.setScissor(left, positiveYUpBottom, width, height);
    -  renderer.setViewport(left, positiveYUpBottom, width, height);
    -
    -  renderer.render(scene, camera);
    -}
    -
    -

    视图渲染函数如下:

    -
    function render(time) {
    -  time *= 0.001;
    -
    -  resizeRendererToDisplaySize(renderer);
    -
    -  renderer.setScissorTest(false);
    -  renderer.clear(true, true);
    -  renderer.setScissorTest(true);
    -
    -  sceneInfo1.mesh.rotation.y = time * .1;
    -  sceneInfo2.mesh.rotation.y = time * .1;
    -
    -  renderSceneInfo(sceneInfo1);
    -  renderSceneInfo(sceneInfo2);
    -
    -  requestAnimationFrame(render);
    -}
    -
    -

    最终效果如下:

    -

    - -

    -

    可以看到,两个物体被分别渲染到了对应的位置。

    -

    同步滚动

    -

    虽然我们已经实现了同时渲染多个场景的功能,但是上面的代码依然存在一个问题,如果Scenes过于复杂、或者由于其他原因需要更长时间渲染,那么画布中Scenes渲染的位置总是会落后于页面的其他元素,如页面滚动时会出现明显的滞后。

    -

    为了更直观的观察这个现象,我们给每个Scene加上边框,并设置背景颜色:

    -
    .diagram {
    -  display: inline-block;
    -  width: 5em;
    -  height: 3em;
    -+  border: 1px solid black;
    -}
    -
    -

    给每个场景设置背景颜色

    -
    const scene = new THREE.Scene();
    -+scene.background = new THREE.Color('red');
    -
    -

    此时,我们快速滚动屏幕,就会发现这个问题。屏幕滚动时的动画放慢十倍后的效果如下:

    -
    -

    为了解决这个问题,先将Canvas的定位方式由position: fixed 改为position: absolute

    -
    #c {
    --  position: fixed;
    -+  position: absolute;
    -
    - -

    然后,我们将设置Canvastransform来移动它,使画布的顶部位于页面当前滚动到的任何部分的顶部。

    -
    function render(time) {
    -  ...
    -
    -  const transform = `translateY(${window.scrollY}px)`;
    -  renderer.domElement.style.transform = transform;
    -
    -

    position: fixed 会完全禁用画布的滚动,无论其他元素是否已经滚动经过它; -position: absolute则会保持画布与页面的其余部分一起滚动,这意味着我们绘制的任何东西都会与页面一起滚动,就算还未完全渲染出来。当场景完成渲染之后,然后移动画布,场景会与页面被滚动后的位置相匹配,并重新渲染,这就意味着,只有窗口的边缘会显示出一些还未被渲染的数据,当时页面中的场景不会出现这种现象。下面时利用以上方法后的效果(动画同样放慢了10倍)。

    -

    让它更加通用

    -

    现在,我们已经实现了在一个Canvas中渲染多个场景的功能,接下来就来处理一下让它更加好用些。

    -

    我们可以封装一个主渲染函数用来管理整个Canvas,并定义一个场景元素列表和他们对应的场景初始化函数。对于每个元素,它将检查该元素是否滚动到了可视区域并调用相应的场景初始化函数。这样我们就构建了一个渲染系统,在这个系统中每个独立的scenes都会在它们各自定义的空间内独立渲染且不互相影响。

    -

    主渲染函数如下:

    -
    const sceneElements = [];
    -function addScene(elem, fn) {
    -  sceneElements.push({elem, fn});
    -}
    -
    -function render(time) {
    -  time *= 0.001;
    -
    -  resizeRendererToDisplaySize(renderer);
    -
    -  renderer.setScissorTest(false);
    -  renderer.setClearColor(clearColor, 0);
    -  renderer.clear(true, true);
    -  renderer.setScissorTest(true);
    -
    -  const transform = `translateY(${window.scrollY}px)`;
    -  renderer.domElement.style.transform = transform;
    -
    -  for (const {elem, fn} of sceneElements) {
    -    // get the viewport relative position of this element
    -    const rect = elem.getBoundingClientRect();
    -    const {left, right, top, bottom, width, height} = rect;
    -
    -    const isOffscreen =
    -        bottom < 0 ||
    -        top > renderer.domElement.clientHeight ||
    -        right < 0 ||
    -        left > renderer.domElement.clientWidth;
    -
    -    if (!isOffscreen) {
    -      const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
    -      renderer.setScissor(left, positiveYUpBottom, width, height);
    -      renderer.setViewport(left, positiveYUpBottom, width, height);
    -
    -      fn(time, rect);
    -    }
    -  }
    -
    -  requestAnimationFrame(render);
    -}
    -
    -

    从中可以看出,这个函数将遍历每一个包含了所有Scene元素的数组对象,且每个元素都由各自的elemfn属性。

    -

    这个函数将检查每个Scene元素是否进入可视区域,一旦进入就会调用它的场景初始化函数,并传给它当前的时间和对应的尺寸位置信息。

    -

    现在,把每个Scene的信息添加到数组列表中:

    -
    {
    -  const elem = document.querySelector('#box');
    -  const {scene, camera} = makeScene();
    -  const geometry = new THREE.BoxGeometry(1, 1, 1);
    -  const material = new THREE.MeshPhongMaterial({color: 'red'});
    -  const mesh = new THREE.Mesh(geometry, material);
    -  scene.add(mesh);
    -  addScene(elem, (time, rect) => {
    -    camera.aspect = rect.width / rect.height;
    -    camera.updateProjectionMatrix();
    -    mesh.rotation.y = time * .1;
    -    renderer.render(scene, camera);
    -  });
    -}
    -
    -{
    -  const elem = document.querySelector('#pyramid');
    -  const {scene, camera} = makeScene();
    -  const radius = .8;
    -  const widthSegments = 4;
    -  const heightSegments = 2;
    -  const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
    -  const material = new THREE.MeshPhongMaterial({
    -    color: 'blue',
    -    flatShading: true,
    -  });
    -  const mesh = new THREE.Mesh(geometry, material);
    -  scene.add(mesh);
    -  addScene(elem, (time, rect) => {
    -    camera.aspect = rect.width / rect.height;
    -    camera.updateProjectionMatrix();
    -    mesh.rotation.y = time * .1;
    -    renderer.render(scene, camera);
    -  });
    -}
    -
    -

    至此,我们不再需要分别定义sceneInfo1sceneInfo2,但每个场景对应的场景初始化函数都已生效。

    -

    - -

    -

    使用HTML Dataset

    -

    更好用的最后一步就是使用HTML dataset,这是一种将自己的数据添加到HTML元素中的方法,我们不再使用id="...",而是使用data-diagram="...",就像这样:

    -
    <canvas id="c"></canvas>
    -<p>
    --  <span id="box" class="diagram left"></span>
    -+  <span data-diagram="box" class="left"></span>
    -  I love boxes. Presents come in boxes.
    -  When I find a new box I'm always excited to find out what's inside.
    -</p>
    -<p>
    --  <span id="pyramid" class="diagram left"></span>
    -+  <span data-diagram="pyramid" class="right"></span>
    -  When I was a kid I dreamed of going on an expedition inside a pyramid
    -  and finding a undiscovered tomb full of mummies and treasure.
    -</p>
    -
    -

    同时修改CSS选择器

    -
    -.diagram
    -+*[data-diagram] {
    -  display: inline-block;
    -  width: 5em;
    -  height: 3em;
    -}
    -
    -

    现在,我们构建一个对象,用来映射每个场景对应的场景初始化函数,并返回一个场景渲染函数。

    -
    const sceneInitFunctionsByName = {
    -  'box': () => {
    -    const {scene, camera} = makeScene();
    -    const geometry = new THREE.BoxGeometry(1, 1, 1);
    -    const material = new THREE.MeshPhongMaterial({color: 'red'});
    -    const mesh = new THREE.Mesh(geometry, material);
    -    scene.add(mesh);
    -    return (time, rect) => {
    -      mesh.rotation.y = time * .1;
    -      camera.aspect = rect.width / rect.height;
    -      camera.updateProjectionMatrix();
    -      renderer.render(scene, camera);
    -    };
    -  },
    -  'pyramid': () => {
    -    const {scene, camera} = makeScene();
    -    const radius = .8;
    -    const widthSegments = 4;
    -    const heightSegments = 2;
    -    const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
    -    const material = new THREE.MeshPhongMaterial({
    -      color: 'blue',
    -      flatShading: true,
    -    });
    -    const mesh = new THREE.Mesh(geometry, material);
    -    scene.add(mesh);
    -    return (time, rect) => {
    -      mesh.rotation.y = time * .1;
    -      camera.aspect = rect.width / rect.height;
    -      camera.updateProjectionMatrix();
    -      renderer.render(scene, camera);
    -    };
    -  },
    -};
    -
    -

    我们还需要获取所有的diagrams,并调用初始化函数。

    -
    document.querySelectorAll('[data-diagram]').forEach((elem) => {
    -  const sceneName = elem.dataset.diagram;
    -  const sceneInitFunction = sceneInitFunctionsByName[sceneName];
    -  const sceneRenderFunction = sceneInitFunction(elem);
    -  addScene(elem, sceneRenderFunction);
    -});
    -
    -

    经过这番改造,页面的呈现效果没有发生变化,但代码更加通用了。

    -

    -

    给每个元素增加控制器

    -

    当需要交互时,我们需要为每个场景分别添加交互控件,如TrackballControls。首先,需要引入该控件。

    -
    import {TrackballControls} from 'three/addons/controls/TrackballControls.js';
    -
    -

    接着给每个元素增加控制器:

    -
    -function makeScene() {
    -+function makeScene(elem) {
    -  const scene = new THREE.Scene();
    -
    -  const fov = 45;
    -  const aspect = 2;  // the canvas default
    -  const near = 0.1;
    -  const far = 5;
    -  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -  camera.position.set(0, 1, 2);
    -  camera.lookAt(0, 0, 0);
    -+  scene.add(camera);
    -
    -+  const controls = new TrackballControls(camera, elem);
    -+  controls.noZoom = true;
    -+  controls.noPan = true;
    -
    -  {
    -    const color = 0xFFFFFF;
    -    const intensity = 1;
    -    const light = new THREE.DirectionalLight(color, intensity);
    -    light.position.set(-1, 2, 4);
    --    scene.add(light);
    -+    camera.add(light);
    -  }
    -
    --  return {scene, camera};
    -+ return {scene, camera, controls};
    -}
    -
    -

    从中可以看到,我们将camera添加到scene中,而light则添加到camera上,这样可以保证light始终与camera相关联。因此,当我们通过控制器旋转camera的视角时,light会始终照亮这个视角。

    -

    我们还需要在渲染函数中更新这些控件:

    -
    const sceneInitFunctionsByName = {
    -- 'box': () => {
    --    const {scene, camera} = makeScene();
    -+ 'box': (elem) => {
    -+    const {scene, camera, controls} = makeScene(elem);
    -    const geometry = new THREE.BoxGeometry(1, 1, 1);
    -    const material = new THREE.MeshPhongMaterial({color: 'red'});
    -    const mesh = new THREE.Mesh(geometry, material);
    -    scene.add(mesh);
    -    return (time, rect) => {
    -      mesh.rotation.y = time * .1;
    -      camera.aspect = rect.width / rect.height;
    -      camera.updateProjectionMatrix();
    -+      controls.handleResize();
    -+      controls.update();
    -      renderer.render(scene, camera);
    -    };
    -  },
    --  'pyramid': () => {
    --    const {scene, camera} = makeScene();
    -+  'pyramid': (elem) => {
    -+    const {scene, camera, controls} = makeScene(elem);
    -    const radius = .8;
    -    const widthSegments = 4;
    -    const heightSegments = 2;
    -    const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
    -    const material = new THREE.MeshPhongMaterial({
    -      color: 'blue',
    -      flatShading: true,
    -    });
    -    const mesh = new THREE.Mesh(geometry, material);
    -    scene.add(mesh);
    -    return (time, rect) => {
    -      mesh.rotation.y = time * .1;
    -      camera.aspect = rect.width / rect.height;
    -      camera.updateProjectionMatrix();
    -+      controls.handleResize();
    -+      controls.update();
    -      renderer.render(scene, camera);
    -    };
    -  },
    -};
    -
    -

    现在,控制器已经生效了,你可以拖动来查看效果:

    -

    - - -上面提到的方法在本网站上可以找到很多实例,比如Three.js 图元Three.js 材质 这两篇文章。

    -

    另一个方法

    -

    还有一个方法也可以实现这种效果,原理是渲染到屏幕外的画布上,并将结果复制到对应的2D画布上。这个方法的优点是对如何组合每个独立区域没有限制,因此只需正常编写HTML即可。而第一种方法则需要在背景设置一个Canvas

    -

    但这个方法的缺点就是速度较慢,因为每个区域都必须进行复制,因此速度快慢取决于浏览器本身和GPU的性能。

    -

    而这种方法所需改动的代码也很少。

    -

    第一步,不再需要HTML上的Canvas元素了:

    -
    <body>
    --  <canvas id="c"></canvas>
    -  ...
    -</body>
    -
    -

    画布的样式也需要改一下:

    -
    -#c {
    --  position: absolute;
    --  left: 0;
    --  top: 0;
    --  width: 100%;
    --  height: 100%;
    --  display: block;
    --  z-index: -1;
    --}
    -canvas {
    -  width: 100%;
    -  height: 100%;
    -  display: block;
    -}
    -*[data-diagram] {
    -  display: inline-block;
    -  width: 5em;
    -  height: 3em;
    -}
    -

    这样可以保证所有的canvas都能填满他们的容器。

    -

    接下来还需要修改一下JavaScript代码,不需要再查找canvas元素了,取而代之的是需要创建一个,并且在一开始就要开启可视区域检测功能:

    -
    function main() {
    --  const canvas = document.querySelector('#c');
    -+  const canvas = document.createElement('canvas');
    -  const renderer = new THREE.WebGLRenderer({antialias: true, canvas, alpha: true});
    -+  renderer.setScissorTest(true);
    -
    -  ...
    -
    -

    然后,对于每个场景,我们创建一个二维渲染上下文,并将其画布添加到该场景对应的元素中:

    -
    const sceneElements = [];
    -function addScene(elem, fn) {
    -+  const ctx = document.createElement('canvas').getContext('2d');
    -+  elem.appendChild(ctx.canvas);
    --  sceneElements.push({elem, fn});
    -+  sceneElements.push({elem, ctx, fn});
    -}
    -
    -

    在渲染时,如果渲染器的画布不够大导致无法渲染在这个区域,就增加其大小;如果这个区域的画布大小错误,就改变它的大小。最后,设置剪裁区域和视口大小、渲染该区域的场景并将结果复制到该区域的画布上。

    -
    function render(time) {
    -  time *= 0.001;
    -
    --  resizeRendererToDisplaySize(renderer);
    --
    --  renderer.setScissorTest(false);
    --  renderer.setClearColor(clearColor, 0);
    --  renderer.clear(true, true);
    --  renderer.setScissorTest(true);
    --
    --  const transform = `translateY(${window.scrollY}px)`;
    --  renderer.domElement.style.transform = transform;
    -
    --  for (const {elem, fn} of sceneElements) {
    -+  for (const {elem, fn, ctx} of sceneElements) {
    -    // get the viewport relative position of this element
    -    const rect = elem.getBoundingClientRect();
    -    const {left, right, top, bottom, width, height} = rect;
    -+    const rendererCanvas = renderer.domElement;
    -
    -    const isOffscreen =
    -        bottom < 0 ||
    --        top > renderer.domElement.clientHeight ||
    -+        top > window.innerHeight ||
    -        right < 0 ||
    --        left > renderer.domElement.clientWidth;
    -+        left > window.innerWidth;
    -
    -    if (!isOffscreen) {
    --      const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
    --      renderer.setScissor(left, positiveYUpBottom, width, height);
    --      renderer.setViewport(left, positiveYUpBottom, width, height);
    -
    -+      // make sure the renderer's canvas is big enough
    -+      if (rendererCanvas.width < width || rendererCanvas.height < height) {
    -+        renderer.setSize(width, height, false);
    -+      }
    -+
    -+      // make sure the canvas for this area is the same size as the area
    -+      if (ctx.canvas.width !== width || ctx.canvas.height !== height) {
    -+        ctx.canvas.width = width;
    -+        ctx.canvas.height = height;
    -+      }
    -+
    -+      renderer.setScissor(0, 0, width, height);
    -+      renderer.setViewport(0, 0, width, height);
    -
    -      fn(time, rect);
    -
    -+      // copy the rendered scene to this element's canvas
    -+      ctx.globalCompositeOperation = 'copy';
    -+      ctx.drawImage(
    -+          rendererCanvas,
    -+          0, rendererCanvas.height - height, width, height,  // src rect
    -+          0, 0, width, height);                              // dst rect
    -    }
    -  }
    -
    -  requestAnimationFrame(render);
    -}
    -
    -

    最终结果与方法一一样:

    -

    - -

    -

    更新的方法

    -

    还有一种方法是利用OffscreenCanvas方法,但是截至2020年7月,只有Chrome支持这个方法,感兴趣的小伙伴可以点击查看文档。

    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/offscreencanvas.html b/manual/zh/offscreencanvas.html deleted file mode 100644 index 7c2315d5c75c79..00000000000000 --- a/manual/zh/offscreencanvas.html +++ /dev/null @@ -1,921 +0,0 @@ - - - - - - 离屏渲染 - - - - - - - - - - - - - - - - -
    -
    -

    离屏渲染

    -
    -
    -
    -

    OffscreenCanvas - 是一个相对较新的浏览器功能,目前仅在Chrome可用,但显然未来会适用到别的浏览器上。 OffscreenCanvas - 允许使用Web Worker去渲染画布,这是一种减轻繁重复杂工作的方法,比如把渲染一个复杂的3D场景交给一个Web - Worker,避免减慢浏览器的响应速度。它也意味着数据在Worker中加载和解析,因此可能会减少页面加载时的卡顿。

    -

    开始 使用它非常的简单。我们从移植 关于响应式的文章中 3个旋转的立方体开始。

    -

    Worker通常会把代码分割到另一个脚本文件中,本网页的大多数示例都有单独的脚本嵌入到他们所在的HTML文件中。

    -

    在我们的例子中,我们会创建一个叫 offscreencanvas-cubes.js 的文件, - 并且复制 响应式例子 中所有的JavaScript到里面。我们会进行一些必要的修改以使其在Worker中运行。

    -

    我们的HTML文件中仍然需要一些JavaScript,第一件事就是我们需要查找画布,然后转移对它的控制。通过调用 canvas.transferControlToOffscreen来使画布脱离屏幕。

    -
    function main() {
    -  const canvas = document.querySelector('#c');
    -  const offscreen = canvas.transferControlToOffscreen();
    -
    -  ...
    -

    然后我们可以用 new Worker(pathToScript, {type: 'module'})来启用我们的Worker。 - 并把 offscreen 对象传入给它。

    -
    function main() {
    -  const canvas = document.querySelector('#c');
    -  const offscreen = canvas.transferControlToOffscreen();
    -  const worker = new Worker('offscreencanvas-cubes.js', {type: 'module'});
    -  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
    -}
    -main();
    -

    特别需要关注一个重点,Worker不能访问 DOM。 - 它们不能查看HTML元素,也不能接受鼠标或者键盘事件。它们通常唯一能做的事情就是响应发送给他们的消息并将消息发送回主页面。

    - -

    想要发送消息给Worker,需要调用 worker.postMessage 并传入1个或2个参数。第一个参数是一个JavaScript对象,它会被 结构化拷贝 - 并发送给Worker。第二个参数是一个可选的对象数组,它是第一个对象的子集,属于我们想 传递 - 给Worker的一部分,这些对象是不会被克隆的。相反他们会被 转移 - 并且不再存在于主页面中。不复存在可能是一个不准确的描述,它们更像是不可访问。只有某些类型的对象可以转移而不是克隆,包括 OffscreenCanvas。 - 所以一旦转移了 offscreen 对象,在主页面它就没用了。

    - -

    Worker从它们的 onmessage 方法获取消息。我们调用 postMessage 传递的对象,在 onmessage 方法中,通过 - event.data 可以获取到。 - 上面的代码在传递给Worker的对象中声明了 type: 'main' - 。这个对象对浏览器完全没有意义,完全是我们自定义的用法。我们会写一个处理函数,基于 type - 参数来调用Worker中的不同方法。然后我们可以按需添加处理函数,并很容易的从主页面中调用它们。 -

    - -
    const handlers = {
    -  main,
    -};
    -
    -self.onmessage = function(e) {
    -  const fn = handlers[e.data.type];
    -  if (typeof fn !== 'function') {
    -    throw new Error('no handler for type: ' + e.data.type);
    -  }
    -  fn(e.data);
    -};
    -

    在上面你可以看到我们只是根据从主页面传入的 data 中的 type 查找处理函数。

    -

    所以现在我们只需要开始修改我们从 响应式文章中粘贴进 - offscreencanvas-cubes.jsmain 函数即可。 -

    -

    我们不会从 DOM 中获取画布,而是从事件数据中获取到它。

    -
    -function main() {
    --  const canvas = document.querySelector('#c');
    -+function main(data) {
    -+  const {canvas} = data;
    -  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -
    -  ...
    -

    记住Worker根本看不见 DOM 结构。我们遇到的第一个问题是 resizeRendererToDisplaySize - 不能获取到 canvas.clientWidth - 和 canvas.clientHeight ,因为它们是DOM属性。这是原始代码

    - -
    function resizeRendererToDisplaySize(renderer) {
    -  const canvas = renderer.domElement;
    -  const width = canvas.clientWidth;
    -  const height = canvas.clientHeight;
    -  const needResize = canvas.width !== width || canvas.height !== height;
    -  if (needResize) {
    -    renderer.setSize(width, height, false);
    -  }
    -  return needResize;
    -}
    -

    相对的,我们需要把尺寸变化发送给Worker。所以,让我们添加一些保存宽度和高度的全局状态。

    -
    const state = {
    -  width: 300,  // canvas default
    -  height: 150,  // canvas default
    -};
    -

    然后我们添加一个 'size' 处理函数来更新这些值。

    -
    +function size(data) {
    -+  state.width = data.width;
    -+  state.height = data.height;
    -+}
    -
    -const handlers = {
    -  main,
    -+  size,
    -};
    -

    现在我们可以修改 resizeRendererToDisplaySize 函数以使用state.widthstate.height

    -
    function resizeRendererToDisplaySize(renderer) {
    -  const canvas = renderer.domElement;
    --  const width = canvas.clientWidth;
    --  const height = canvas.clientHeight;
    -+  const width = state.width;
    -+  const height = state.height;
    -  const needResize = canvas.width !== width || canvas.height !== height;
    -  if (needResize) {
    -    renderer.setSize(width, height, false);
    -  }
    -  return needResize;
    -}
    -

    其他我们需要用到长宽的地方也需要做类似的修改。

    -
    function render(time) {
    -  time *= 0.001;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    --    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -+    camera.aspect = state.width / state.height;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -  ...
    -

    回到主页面,在任何页面尺寸发生变化的时候,我们都需要发送一个size 事件。

    -
    const worker = new Worker('offscreencanvas-picking.js', {type: 'module'});
    -worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
    -
    -+function sendSize() {
    -+  worker.postMessage({
    -+    type: 'size',
    -+    width: canvas.clientWidth,
    -+    height: canvas.clientHeight,
    -+  });
    -+}
    -+
    -+window.addEventListener('resize', sendSize);
    -+sendSize();
    -

    我们也需要调用它一次来初始化大小。

    -

    通过这些细小改动,假设你的浏览器完全支持 OffscreenCanvas - ,它应该是有效的。在我们运行它之前,让我们检查一下浏览器是否真的支持 - OffscreenCanvas 并且不显示错误。首先添加一些HTML片段来展示错误。 -

    -
    <body>
    -  <canvas id="c"></canvas>
    -+  <div id="noOffscreenCanvas" style="display:none;">
    -+    <div>no OffscreenCanvas support</div>
    -+  </div>
    -</body>
    -

    和一些CSS代码

    -
    #noOffscreenCanvas {
    -    display: flex;
    -    width: 100%;
    -    height: 100%;
    -    align-items: center;
    -    justify-content: center;
    -    background: red;
    -    color: white;
    -}
    -

    然后我们可以通过检查transferControlToOffscreen是否存在 - 来判断浏览器对 OffscreenCanvas的兼容性。

    -
    function main() {
    -  const canvas = document.querySelector('#c');
    -+  if (!canvas.transferControlToOffscreen) {
    -+    canvas.style.display = 'none';
    -+    document.querySelector('#noOffscreenCanvas').style.display = '';
    -+    return;
    -+  }
    -  const offscreen = canvas.transferControlToOffscreen();
    -  const worker = new Worker('offscreencanvas-picking.js', {type: 'module});
    -  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
    -
    -  ...
    -

    如上,如果你的浏览器支持 OffscreenCanvas ,这个例子应该会生效。

    -

    - - -

    -

    这很棒,不过现在不是每个浏览器都支持 OffscreenCanvas。 - 让我们更改代码以同时适用 OffscreenCanvas 以及在主页面和通常用法一样的降级方案。

    - -
    -

    - 顺便说一句,如果你需要OffscreenCanvas来使页面具有尺寸自适应调整,降级的意义不大。也许基于你最终是在主页面还是Worker中运行,你可能会调整Worker的数量,以便让Worker运行时做的事情比在主页面可做的事情更多。这些都取决于你。 -

    -
    - -

    我们应该做的第一件事就是分离出THREE.js中特定于Worker相关的代码。这样我们就可以在主页面和Worker中使用相同的代码,换句话说,我们将会有3个文件

    - -
      -
    1. -

      我们的HTML文件

      -

      threejs-offscreencanvas-w-fallback.html

      -
    2. -
    3. -

      一个包含THREE.js的JavaScript代码文件

      -

      shared-cubes.js

      -
    4. -
    5. -

      我们支持Worker的代码文件

      -

      offscreencanvas-worker-cubes.js

      -
    6. -
    -

    shared-cubes.jsoffscreencanvas-worker-cubes.js 基本上都是从我们之前的 offscreencanvas-cubes.js 文件分割而来。 - 第一步我们拷贝所有的 offscreencanvas-cubes.js 代码到 shared-cube.js中。然后 - 我们重命名 maininit 因为我们已经有一个 main - 函数在我们的HTML文件中了,我们还需要导出 init 函数和 state对象。

    -
    import * as THREE from '../../build/three.module.js';
    -
    --const state = {
    -+export const state = {
    -  width: 300,   // canvas default
    -  height: 150,  // canvas default
    -};
    -
    --function main(data) {
    -+export function init(data) {
    -  const {canvas} = data;
    -  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -

    并去掉和THREE.js无关的部分

    -
    -function size(data) {
    --  state.width = data.width;
    --  state.height = data.height;
    --}
    --
    --const handlers = {
    --  main,
    --  size,
    --};
    --
    --self.onmessage = function(e) {
    --  const fn = handlers[e.data.type];
    --  if (typeof fn !== 'function') {
    --    throw new Error('no handler for type: ' + e.data.type);
    --  }
    --  fn(e.data);
    --};
    -

    然后我们需要把刚刚删除的部分拷贝到 offscreencanvas-worker-cubes.js - ,并导入 shared-cubes.js 以及调用 init 而不是 main方法。

    -
    import {init, state} from './shared-cubes.js';
    -
    -function size(data) {
    -  state.width = data.width;
    -  state.height = data.height;
    -}
    -
    -const handlers = {
    --  main,
    -+  init,
    -  size,
    -};
    -
    -self.onmessage = function(e) {
    -  const fn = handlers[e.data.type];
    -  if (typeof fn !== 'function') {
    -    throw new Error('no handler for type: ' + e.data.type);
    -  }
    -  fn(e.data);
    -};
    -

    类似的我们需要在主页面引入 shared-cubes.js 模块 -

    -
    <script type="module">
    -+import {init, state} from './shared-cubes.js';
    -

    我们也可以移除之前添加的HTML

    -
    <body>
    -  <canvas id="c"></canvas>
    --  <div id="noOffscreenCanvas" style="display:none;">
    --    <div>no OffscreenCanvas support</div>
    --  </div>
    -</body>
    -

    以及CSS

    -
    -#noOffscreenCanvas {
    --    display: flex;
    --    width: 100%;
    --    height: 100%;
    --    align-items: center;
    --    justify-content: center;
    --    background: red;
    --    color: white;
    --}
    -

    然后我们把主页面的代码改成调用一次启动函数,启动函数取决于浏览器是否支持 OffscreenCanvas

    -
    function main() {
    -  const canvas = document.querySelector('#c');
    --  if (!canvas.transferControlToOffscreen) {
    --    canvas.style.display = 'none';
    --    document.querySelector('#noOffscreenCanvas').style.display = '';
    --    return;
    --  }
    --  const offscreen = canvas.transferControlToOffscreen();
    --  const worker = new Worker('offscreencanvas-picking.js', {type: 'module'});
    --  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
    -+  if (canvas.transferControlToOffscreen) {
    -+    startWorker(canvas);
    -+  } else {
    -+    startMainPage(canvas);
    -+  }
    -  ...
    -

    我们需要把启动Worker的代码移动到 startWorker函数中

    -
    function startWorker(canvas) {
    -  const offscreen = canvas.transferControlToOffscreen();
    -  const worker = new Worker('offscreencanvas-worker-cubes.js', {type: 'module'});
    -  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
    -
    -  function sendSize() {
    -    worker.postMessage({
    -      type: 'size',
    -      width: canvas.clientWidth,
    -      height: canvas.clientHeight,
    -    });
    -  }
    -
    -  window.addEventListener('resize', sendSize);
    -  sendSize();
    -
    -  console.log('using OffscreenCanvas');
    -}
    -

    然后发送消息类型为 init 而不是 main

    -
    -  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
    -+  worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);
    -

    若是从主页面启动,我们可以这样做

    -
    function startMainPage(canvas) {
    -  init({canvas});
    -
    -  function sendSize() {
    -    state.width = canvas.clientWidth;
    -    state.height = canvas.clientHeight;
    -  }
    -  window.addEventListener('resize', sendSize);
    -  sendSize();
    -
    -  console.log('using regular canvas');
    -}
    -

    这样,我们的示例在离屏画布或者主页面都可以运行了。

    -

    - -

    这应该是比较容易的。我们尝试下拾取,我们会从 关于拾取的文章 中的 射线 案例获取一些代码, - 让它在离屏时也可运行。

    -

    我们现在拷贝 shared-cube.jsshared-picking.js ,然后添加拾取部分。拷贝到 PickHelper 函数中

    -
    class PickHelper {
    -  constructor() {
    -    this.raycaster = new THREE.Raycaster();
    -    this.pickedObject = null;
    -    this.pickedObjectSavedColor = 0;
    -  }
    -  pick(normalizedPosition, scene, camera, time) {
    -    // restore the color if there is a picked object
    -    if (this.pickedObject) {
    -      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
    -      this.pickedObject = undefined;
    -    }
    -
    -    // cast a ray through the frustum
    -    this.raycaster.setFromCamera(normalizedPosition, camera);
    -    // get the list of objects the ray intersected
    -    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
    -    if (intersectedObjects.length) {
    -      // pick the first object. It's the closest one
    -      this.pickedObject = intersectedObjects[0].object;
    -      // save its color
    -      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
    -      // set its emissive color to flashing red/yellow
    -      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
    -    }
    -  }
    -}
    -
    -const pickPosition = {x: 0, y: 0};
    -const pickHelper = new PickHelper();
    -

    我们从鼠标位置中更新 pickPosition,就像这样

    -
    function getCanvasRelativePosition(event) {
    -  const rect = canvas.getBoundingClientRect();
    -  return {
    -    x: (event.clientX - rect.left) * canvas.width  / rect.width,
    -    y: (event.clientY - rect.top ) * canvas.height / rect.height,
    -  };
    -}
    -
    -function setPickPosition(event) {
    -  const pos = getCanvasRelativePosition(event);
    -  pickPosition.x = (pos.x / canvas.width ) *  2 - 1;
    -  pickPosition.y = (pos.y / canvas.height) * -2 + 1;  // note we flip Y
    -}
    -window.addEventListener('mousemove', setPickPosition);
    -        
    -

    Worker不能直接读取鼠标位置,所以就像调整尺寸的代码那样,发送带有鼠标位置的消息。像刚才的代码一样我们发送鼠标位置消息并且更新 pickPosition

    - -
    function size(data) {
    -  state.width = data.width;
    -  state.height = data.height;
    -}
    -
    -+function mouse(data) {
    -+  pickPosition.x = data.x;
    -+  pickPosition.y = data.y;
    -+}
    -
    -const handlers = {
    -  init,
    -+  mouse,
    -  size,
    -};
    -
    -self.onmessage = function(e) {
    -  const fn = handlers[e.data.type];
    -  if (typeof fn !== 'function') {
    -    throw new Error('no handler for type: ' + e.data.type);
    -  }
    -  fn(e.data);
    -};
    -

    回到我们的主页面,我需要添加代码去把鼠标位置传给Worker或者主页面。

    -
    +let sendMouse;
    -
    -function startWorker(canvas) {
    -  const offscreen = canvas.transferControlToOffscreen();
    -  const worker = new Worker('offscreencanvas-worker-picking.js', {type: 'module'});
    -  worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);
    -
    -+  sendMouse = (x, y) => {
    -+    worker.postMessage({
    -+      type: 'mouse',
    -+      x,
    -+      y,
    -+    });
    -+  };
    -
    -  function sendSize() {
    -    worker.postMessage({
    -      type: 'size',
    -      width: canvas.clientWidth,
    -      height: canvas.clientHeight,
    -    });
    -  }
    -
    -  window.addEventListener('resize', sendSize);
    -  sendSize();
    -
    -  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
    -}
    -
    -function startMainPage(canvas) {
    -  init({canvas});
    -
    -+  sendMouse = (x, y) => {
    -+    pickPosition.x = x;
    -+    pickPosition.y = y;
    -+  };
    -
    -  function sendSize() {
    -    state.width = canvas.clientWidth;
    -    state.height = canvas.clientHeight;
    -  }
    -  window.addEventListener('resize', sendSize);
    -  sendSize();
    -
    -  console.log('using regular canvas');  /* eslint-disable-line no-console */
    -}
    -

    然后我们可以将所有鼠标处理代码复制到主页面,只需稍作更改即可调用sendMouse

    -
    function setPickPosition(event) {
    -  const pos = getCanvasRelativePosition(event);
    --  pickPosition.x = (pos.x / canvas.clientWidth ) *  2 - 1;
    --  pickPosition.y = (pos.y / canvas.clientHeight) * -2 + 1;  // note we flip Y
    -+  sendMouse(
    -+      (pos.x / canvas.clientWidth ) *  2 - 1,
    -+      (pos.y / canvas.clientHeight) * -2 + 1);  // note we flip Y
    -}
    -
    -function clearPickPosition() {
    -  // unlike the mouse which always has a position
    -  // if the user stops touching the screen we want
    -  // to stop picking. For now we just pick a value
    -  // unlikely to pick something
    --  pickPosition.x = -100000;
    --  pickPosition.y = -100000;
    -+  sendMouse(-100000, -100000);
    -}
    -window.addEventListener('mousemove', setPickPosition);
    -window.addEventListener('mouseout', clearPickPosition);
    -window.addEventListener('mouseleave', clearPickPosition);
    -
    -window.addEventListener('touchstart', (event) => {
    -  // prevent the window from scrolling
    -  event.preventDefault();
    -  setPickPosition(event.touches[0]);
    -}, {passive: false});
    -
    -window.addEventListener('touchmove', (event) => {
    -  setPickPosition(event.touches[0]);
    -});
    -
    -window.addEventListener('touchend', clearPickPosition);
    -

    通过这种方式, OffscreenCanvas 的拾取应该也是有效的。 -

    -

    - - -

    -

    我们更进一步,添加进 OrbitControls。 - 这会有一些复杂。 OrbitControls利用了很多DOM特性,比如鼠标、触摸、键盘等等。

    -

    与我们现在的代码不同,我们不能真正使用全局 state 对象,不重写所有的OrbitControls代码是无法做到的。 - OrbitControls附加绑定了一个 HTMLElement - 的DOM事件。也许我们可以通过自行实现与DOM元素相同API签名的对象,我们只需要支持OrbitControls需要的功能即可。

    -

    挖掘了一下 OrbitControls - 源代码 - ,看起来我们需要处理以下事件:

    -
      -
    • contextmenu
    • -
    • pointerdown
    • -
    • pointermove
    • -
    • pointerup
    • -
    • touchstart
    • -
    • touchmove
    • -
    • touchend
    • -
    • wheel
    • -
    • keydown
    • -
    -

    对于点击事件,我们需要 ctrlKeymetaKeyshiftKey, - buttonpointerTypeclientXclientYpageX, - 和 pageY 这些属性。 -

    -

    对于键盘事件,我们需要 ctrlKeymetaKeyshiftKey, - 和 keyCode 这些属性。

    - -

    对于滚轮事件,我们只需要 deltaY 属性。

    -

    最后对于点击事件,我们只需要 pageXpageY ,来自 - touches 属性。 -

    -

    - 所以,让我们做一个代理的键值对,一部分会运行在主页面,获取所有这些事件,然后传递相关属性值给Worker。另一部分将在Worker中运行,接收事件并使用和原始DOM事件相同的事件参数。因此OrbitControls无法分辨其中的不同。 -

    -

    这里是Worker部分的代码。

    -
    import {EventDispatcher} from '../../build/three.module.js';
    -
    -class ElementProxyReceiver extends EventDispatcher {
    -  constructor() {
    -    super();
    -  }
    -  handleEvent(data) {
    -    this.dispatchEvent(data);
    -  }
    -}
    -

    它所做的就是接收到一条消息,就把它分发出去。它继承自EventDispatcher,这提供了一些方法,像addEventListenerremoveEventListener,就像一个DOM元素一样,我们把它传给OrbitControls的话,应该能行。 -

    ElementProxyReceiver - 接受一个元素,在我们的例子中,只需要一个。不过最好还是好好思考下,让Manager来管理多个。

    - -
    class ProxyManager {
    -  constructor() {
    -    this.targets = {};
    -    this.handleEvent = this.handleEvent.bind(this);
    -  }
    -  makeProxy(data) {
    -    const {id} = data;
    -    const proxy = new ElementProxyReceiver();
    -    this.targets[id] = proxy;
    -  }
    -  getProxy(id) {
    -    return this.targets[id];
    -  }
    -  handleEvent(data) {
    -    this.targets[data.id].handleEvent(data.data);
    -  }
    -}
    -

    我们可以创建一个 ProxyManager 实例,然后调用它的 makeProxy - 方法,通过一个id,可以生成一个响应对应id信息的 ElementProxyReceiver 对象。

    -

    让我们将它关联到Worker的消息处理函数上

    -
    const proxyManager = new ProxyManager();
    -
    -function start(data) {
    -  const proxy = proxyManager.getProxy(data.canvasId);
    -  init({
    -    canvas: data.canvas,
    -    inputElement: proxy,
    -  });
    -}
    -
    -function makeProxy(data) {
    -  proxyManager.makeProxy(data);
    -}
    -
    -...
    -
    -const handlers = {
    --  init,
    --  mouse,
    -+  start,
    -+  makeProxy,
    -+  event: proxyManager.handleEvent,
    -    size,
    -};
    -
    -self.onmessage = function(e) {
    -  const fn = handlers[e.data.type];
    -  if (typeof fn !== 'function') {
    -    throw new Error('no handler for type: ' + e.data.type);
    -  }
    -  fn(e.data);
    -};
    -

    在共享的THREE.js代码中,我们需要导入 OrbitControls 并且设置它。

    - -
    import * as THREE from '../../build/three.module.js';
    -+import {OrbitControls} from '/examples/jsm/controls/OrbitControls.js';
    -
    -export function init(data) {
    --  const {canvas} = data;
    -+  const {canvas, inputElement} = data;
    -  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -
    -+  const controls = new OrbitControls(camera, inputElement);
    -+  controls.target.set(0, 0, 0);
    -+  controls.update();
    -

    注意,我们通过传入代理的 inputElement 给了OrbitControls,而不是 - 像我们在其他非离屏渲染的例子中那样。

    -

    接下来我们可以从HTML文件中移动所有的拾取事件代码,把 canvas 修改为 inputElement。 -

    -
    function getCanvasRelativePosition(event) {
    --  const rect = canvas.getBoundingClientRect();
    -+  const rect = inputElement.getBoundingClientRect();
    -  return {
    -    x: event.clientX - rect.left,
    -    y: event.clientY - rect.top,
    -  };
    -}
    -
    -function setPickPosition(event) {
    -  const pos = getCanvasRelativePosition(event);
    --  sendMouse(
    --      (pos.x / canvas.clientWidth ) *  2 - 1,
    --      (pos.y / canvas.clientHeight) * -2 + 1);  // note we flip Y
    -+  pickPosition.x = (pos.x / inputElement.clientWidth ) *  2 - 1;
    -+  pickPosition.y = (pos.y / inputElement.clientHeight) * -2 + 1;  // note we flip Y
    -}
    -
    -function clearPickPosition() {
    -  // unlike the mouse which always has a position
    -  // if the user stops touching the screen we want
    -  // to stop picking. For now we just pick a value
    -  // unlikely to pick something
    --  sendMouse(-100000, -100000);
    -+  pickPosition.x = -100000;
    -+  pickPosition.y = -100000;
    -}
    -
    -*inputElement.addEventListener('mousemove', setPickPosition);
    -*inputElement.addEventListener('mouseout', clearPickPosition);
    -*inputElement.addEventListener('mouseleave', clearPickPosition);
    -
    -*inputElement.addEventListener('touchstart', (event) => {
    -  // prevent the window from scrolling
    -  event.preventDefault();
    -  setPickPosition(event.touches[0]);
    -}, {passive: false});
    -
    -*inputElement.addEventListener('touchmove', (event) => {
    -  setPickPosition(event.touches[0]);
    -});
    -
    -*inputElement.addEventListener('touchend', clearPickPosition);
    -

    回到主页面,我们需要写一些代码来发送包含上面列举所有事件的消息。

    -
    let nextProxyId = 0;
    -class ElementProxy {
    -  constructor(element, worker, eventHandlers) {
    -    this.id = nextProxyId++;
    -    this.worker = worker;
    -    const sendEvent = (data) => {
    -      this.worker.postMessage({
    -        type: 'event',
    -        id: this.id,
    -        data,
    -      });
    -    };
    -
    -    // register an id
    -    worker.postMessage({
    -      type: 'makeProxy',
    -      id: this.id,
    -    });
    -    for (const [eventName, handler] of Object.entries(eventHandlers)) {
    -      element.addEventListener(eventName, function(event) {
    -        handler(event, sendEvent);
    -      });
    -    }
    -  }
    -}
    -

    ElementProxy 代理了事件需要被代理的元素, - 它向Worker注册了一个ID,通过选取和发送 - 我们早先注册的 makeProxy消息,Worker会生成一个 ElementProxyReceiver 并使用这个ID注册。

    - -

    然后我们又一个注册事件处理的对,这样我们可以对特定事件应用处理函数,并转发给Worker。

    -

    当我们启动Worker时,我们先创建一个代理,并传给我们的事件处理函数

    -
    function startWorker(canvas) {
    -  const offscreen = canvas.transferControlToOffscreen();
    -  const worker = new Worker('offscreencanvas-worker-orbitcontrols.js', {type: 'module'});
    -
    -+  const eventHandlers = {
    -+    contextmenu: preventDefaultHandler,
    -+    mousedown: mouseEventHandler,
    -+    mousemove: mouseEventHandler,
    -+    mouseup: mouseEventHandler,
    -+    pointerdown: mouseEventHandler,
    -+    pointermove: mouseEventHandler,
    -+    pointerup: mouseEventHandler,
    -+    touchstart: touchEventHandler,
    -+    touchmove: touchEventHandler,
    -+    touchend: touchEventHandler,
    -+    wheel: wheelEventHandler,
    -+    keydown: filteredKeydownEventHandler,
    -+  };
    -+  const proxy = new ElementProxy(canvas, worker, eventHandlers);
    -  worker.postMessage({
    -    type: 'start',
    -    canvas: offscreen,
    -+    canvasId: proxy.id,
    -  }, [offscreen]);
    -  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
    -}
    -

    下面是事件处理函数。他们所做的只是从接收到的时间中复制属性列表。它们应用了一个 sendEvent函数 - ,这个函数会包含事件的数据,添加正确的ID,以及发送给Worker。

    - -
    class ElementProxy {
    -  constructor(element, worker, eventHandlers) {
    -    this.id = nextProxyId++;
    -    this.worker = worker;
    -    const sendEvent = (data) => {
    -      this.worker.postMessage({
    -        type: 'event',
    -        id: this.id,
    -        data,
    -      });
    -    };
    -
    -    // register an id
    -    worker.postMessage({
    -      type: 'makeProxy',
    -      id: this.id,
    -    });
    -+    sendSize();
    -    for (const [eventName, handler] of Object.entries(eventHandlers)) {
    -      element.addEventListener(eventName, function(event) {
    -        handler(event, sendEvent);
    -      });
    -    }
    -
    -+    function sendSize() {
    -+      const rect = element.getBoundingClientRect();
    -+      sendEvent({
    -+        type: 'size',
    -+        left: rect.left,
    -+        top: rect.top,
    -+        width: element.clientWidth,
    -+        height: element.clientHeight,
    -+      });
    -+    }
    -+
    -+    window.addEventListener('resize', sendSize);
    -  }
    -}
    -

    在我们共享的THREE.js代码中,我们不再需要 state

    -
    -export const state = {
    --  width: 300,   // canvas default
    --  height: 150,  // canvas default
    --};
    -
    -...
    -
    -function resizeRendererToDisplaySize(renderer) {
    -  const canvas = renderer.domElement;
    --  const width = state.width;
    --  const height = state.height;
    -+  const width = inputElement.clientWidth;
    -+  const height = inputElement.clientHeight;
    -  const needResize = canvas.width !== width || canvas.height !== height;
    -  if (needResize) {
    -    renderer.setSize(width, height, false);
    -  }
    -  return needResize;
    -}
    -
    -function render(time) {
    -  time *= 0.001;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    --    camera.aspect = state.width / state.height;
    -+    camera.aspect = inputElement.clientWidth / inputElement.clientHeight;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -  ...
    -

    还有一些黑科技。OrbitControls 监听了 pointermovepointerup 事件到元素的 - ownerDocument 属性上,这样可以处理鼠标捕获(当鼠标离开窗口时) -

    -

    此外,代码引用了全局document 不过在Worker中没有全局document对象。

    - -

    我们可以通过2个小hack来快速解决这些问题。在我们Worker的代码中,我们会使用Proxy来解决这两个问题。

    -
    function start(data) {
    -  const proxy = proxyManager.getProxy(data.canvasId);
    -+  proxy.ownerDocument = proxy; // HACK!
    -+  self.document = {} // HACK!
    -  init({
    -    canvas: data.canvas,
    -    inputElement: proxy,
    -  });
    -}
    -

    这会给 OrbitControls 检查到一些符合它期望的东西。

    - -

    我知道这会有点难以理解。简单来说就是: - ElementProxy 在主页面运行,并转发 DOM 事件给Worker中的 - ElementProxyReceiver, - 它会伪装成一个 HTMLElement ,这样我们可以同时使用 - OrbitControls 和我们自己的代码。 -

    -

    最后一件事是我们在不使用离屏渲染时的降级。我们所要做的就是将画布本身作为 inputElement 即可。

    - -
    function startMainPage(canvas) {
    --  init({canvas});
    -+  init({canvas, inputElement: canvas});
    -  console.log('using regular canvas');
    -}
    -

    现在我们应该可以让OrbitControls在离屏渲染时正常工作了。

    -

    - - -

    -

    这可能是站点上目前为止最复杂的示例,可能会有点难以理解,因为每个案例都有3个文件:HTML文件、Worker文件、共享的THREE.js代码。

    -

    我希望它不会太难理解。希望它可以提供一些使用THREE.js、OffscreenCanvas和Web Worker有用的示例。

    -
    -
    -
    - - - - - - - - - - \ No newline at end of file diff --git a/manual/zh/optimize-lots-of-objects-animated.html b/manual/zh/optimize-lots-of-objects-animated.html deleted file mode 100644 index deafc87f7c7abf..00000000000000 --- a/manual/zh/optimize-lots-of-objects-animated.html +++ /dev/null @@ -1,417 +0,0 @@ - - - 优化对象的同时保持动画效果 - - - - - - - - - - - - - - -
    -
    -

    优化对象的同时保持动画效果

    -
    -
    -
    -

    本文是关于 three.js 系列文章的一部分. 第一篇文章是 three.js 基础. 如果你还没看过而且对three.js 还不熟悉,那应该从那里开始.

    -

    在上一章中, 我们合并了19000个对象到一个几何体中. 这带来的好处是优化掉19000次绘制操作但是缺点是没有办法再单独操作某一个了.

    -

    根据我们想达成的目标的不同, 有不同的解决方案可选. 本例中我们绘制大量的数据, 然后还能在这些数据集间设置动画

    -

    第一件事是获取数据集. 理想中我们可能需要预处理这些数据, 但是我们现在只需要载入两个数据集然后产生更多的.

    -

    这是我们之前的载入代码

    -
    loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
    -  .then(parseData)
    -  .then(addBoxes)
    -  .then(render);
    -
    -

    稍微改成这样

    -
    async function loadData(info) {
    -  const text = await loadFile(info.url);
    -  info.file = parseData(text);
    -}
    -
    -async function loadAll() {
    -  const fileInfos = [
    -    {name: 'men',   hueRange: [0.7, 0.3], url: 'resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc' },
    -    {name: 'women', hueRange: [0.9, 1.1], url: 'resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014ft_2010_cntm_1_deg.asc' },
    -  ];
    -
    -  await Promise.all(fileInfos.map(loadData));
    -
    -  ...
    -}
    -loadAll();
    -
    -

    上面的代码将会加载fileInfos中的所有文件, 加载完成后每一个fileInfos中的对象都会有一个带着载入文件的file属性. 我们稍后使用namehueRange. name是显示在界面上的字段, hueRange是色调.

    -

    上面的两个文件显然是每个地区2010年男人和女人的数量. 注意了, 我不知道这些数据对不对, 但是不影响好吧. 重要的是如何去展示这些不同的数据.

    -

    让我们再产生两组数据. 一组是男人数量比女人多的, 另一组反过来.

    -

    首先,让我们编写一个函数,在给定一个二维数组的情况下,像以前一样映射生成一个新的二维数组

    -
    function mapValues(data, fn) {
    -  return data.map((row, rowNdx) => {
    -    return row.map((value, colNdx) => {
    -      return fn(value, rowNdx, colNdx);
    -    });
    -  });
    -}
    -
    -

    就像普通的Array.map函数, mapValues函数对数组的数组每一个值调用了fn. 这将会将每个值和它的索引传进去.

    -

    现在让我们编写一些代码来生成一个新文件,它是两个文件之间的比较

    -
    function makeDiffFile(baseFile, otherFile, compareFn) {
    -  let min;
    -  let max;
    -  const baseData = baseFile.data;
    -  const otherData = otherFile.data;
    -  const data = mapValues(baseData, (base, rowNdx, colNdx) => {
    -    const other = otherData[rowNdx][colNdx];
    -      if (base === undefined || other === undefined) {
    -        return undefined;
    -      }
    -      const value = compareFn(base, other);
    -      min = Math.min(min === undefined ? value : min, value);
    -      max = Math.max(max === undefined ? value : max, value);
    -      return value;
    -  });
    -  // 生成baseFile的一个副本, 然后用新文件的min max 和 data替代原来的
    -  return {...baseFile, min, max, data};
    -}
    -
    -

    上面的代码基于传入的compareFnmapValues生成一个新的数据集. 这同样追踪minmax的比较结果. 最后这将会生成一个新文件, 除了min, maxdata所有的属性都和baseFile一样.

    -

    然后我们用上面的代码生成两个新数据集

    -
    {
    -  const menInfo = fileInfos[0];
    -  const womenInfo = fileInfos[1];
    -  const menFile = menInfo.file;
    -  const womenFile = womenInfo.file;
    -
    -  function amountGreaterThan(a, b) {
    -    return Math.max(a - b, 0);
    -  }
    -  fileInfos.push({
    -    name: '>50%men',
    -    hueRange: [0.6, 1.1],
    -    file: makeDiffFile(menFile, womenFile, (men, women) => {
    -      return amountGreaterThan(men, women);
    -    }),
    -  });
    -  fileInfos.push({
    -    name: '>50% women',
    -    hueRange: [0.0, 0.4],
    -    file: makeDiffFile(womenFile, menFile, (women, men) => {
    -      return amountGreaterThan(women, men);
    -    }),
    -  });
    -}
    -
    -

    现在我们写一个UI来选择数据集. 首先是html部分

    -
    <body>
    -  <canvas id="c"></canvas>
    -+  <div id="ui"></div>
    -</body>
    -
    -

    CSS部分, 让其显示在左侧

    -
    #ui {
    -  position: absolute;
    -  left: 1em;
    -  top: 1em;
    -}
    -#ui>div {
    -  font-size: 20pt;
    -  padding: 1em;
    -  display: inline-block;
    -}
    -#ui>div.selected {
    -  color: red;
    -}
    -
    -

    我们遍历整个文件, 对于每一个数据集都生成了合并了的box,

    -

    然后我们可以遍历每个文件, 并为每组数据生成合并了的box和一个元素, 当鼠标悬停在上面时, 该元素将显示该集合并隐藏所有其他元素

    -
    // 展示选中的元素, 隐藏其他的
    -function showFileInfo(fileInfos, fileInfo) {
    -  fileInfos.forEach((info) => {
    -    const visible = fileInfo === info;
    -    info.root.visible = visible;
    -    info.elem.className = visible ? 'selected' : '';
    -  });
    -  requestRenderIfNotRequested();
    -}
    -
    -const uiElem = document.querySelector('#ui');
    -fileInfos.forEach((info) => {
    -  const boxes = addBoxes(info.file, info.hueRange);
    -  info.root = boxes;
    -  const div = document.createElement('div');
    -  info.elem = div;
    -  div.textContent = info.name;
    -  uiElem.appendChild(div);
    -  div.addEventListener('mouseover', () => {
    -    showFileInfo(fileInfos, info);
    -  });
    -});
    -// 起始展示第一组数据
    -showFileInfo(fileInfos, fileInfos[0]);
    -
    -

    和之前例子有所不同的是, 我们还需要让addBoxes获取hueRange

    -
    -function addBoxes(file) {
    -+function addBoxes(file, hueRange) {
    -
    -  ...
    -
    -    // compute a color
    --    const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
    -+    const hue = THREE.MathUtils.lerp(...hueRange, amount);
    -
    -  ...
    -
    -

    把鼠标放到标签上可以在四组不同的数据集之间切换.

    -

    - -

    -

    咋回事, 怎么还有一些点非常突出??!! 而且切换得很生硬也没有动画啊

    -

    有这么一些想法

    -
      -
    • 通过使用Material.opacity做消失过渡

      -

      这个解决方案的问题是立方体完全重叠了, 意思是在Z轴方向冲突. 我们可以通过改变depth函数和使用blending来修复. 我们应该试一试

      -
    • -
    • 放大我们想看到的集合,缩小其他集合

      -

      因为所有盒子的原点都在地球的中心, 如果我们把它们缩小到1.0以下, 它们就会沉入地球. 这听起来是个好主意, 但问题是所有的较低的盒子几乎会立即消失,直到新的数据集扩展到1.0才被替换. 这使得过渡非常不漂亮. 我们可以用一个神奇的自定义着色器来解决这个问题.

      -
    • -
    • 使用Morphtargets

      -

      所谓变形目标morphtargets是一种给每个顶点提供多个值, 以及使他们进行变形或者说lerp(线性插值)的方法. morphtargets通常用于3D角色的面部动画, 但这并不是唯一的用途.

      -
    • -
    -

    我们试试morphtargets

    -

    我们还是给每一个数据集做一个几何体, 但这次我们提取position属性, 把他们作为morphtargets.

    -

    首先我们改动一下addBoxes来生成并返回一个合并的几何体.

    -
    -function addBoxes(file, hueRange) {
    -+function makeBoxes(file, hueRange) {
    -  const {min, max, data} = file;
    -  const range = max - min;
    -
    -  ...
    -
    --  const mergedGeometry = BufferGeometryUtils.mergeGeometries(
    --      geometries, false);
    --  const material = new THREE.MeshBasicMaterial({
    --    vertexColors: true,
    --  });
    --  const mesh = new THREE.Mesh(mergedGeometry, material);
    --  scene.add(mesh);
    --  return mesh;
    -+  return BufferGeometryUtils.mergeGeometries(
    -+     geometries, false);
    -}
    -
    -

    不过, 我们还有一件事要做. 变形目标的顶点数必须完全相同. 一个目标中的顶点#123需要在所有其他目标中有一个对应的顶点#123. 但是, 由于现在不同的数据集可能有一些没有数据的数据点, 因此不会为该点生成几何体, 这意味着另一个数据集没有相应的顶点. 所以, 我们需要检查所有的数据集,如果任何一个数据集中有数据, 就总是生成一些东西; 或者如果任何一个数据集中缺少数据, 就什么也不生成. 让我们以后者为准.

    -
    +function dataMissingInAnySet(fileInfos, latNdx, lonNdx) {
    -+  for (const fileInfo of fileInfos) {
    -+    if (fileInfo.file.data[latNdx][lonNdx] === undefined) {
    -+      return true;
    -+    }
    -+  }
    -+  return false;
    -+}
    -
    --function makeBoxes(file, hueRange) {
    -+function makeBoxes(file, hueRange, fileInfos) {
    -  const {min, max, data} = file;
    -  const range = max - min;
    -
    -  ...
    -
    -  const geometries = [];
    -  data.forEach((row, latNdx) => {
    -    row.forEach((value, lonNdx) => {
    -+      if (dataMissingInAnySet(fileInfos, latNdx, lonNdx)) {
    -+        return;
    -+      }
    -      const amount = (value - min) / range;
    -
    -  ...
    -
    -

    现在我们改动一下代码, 把调用addBoxes的改成使用makeBoxes生成变形目标.

    -
    +// 对每一个数据集生成几何体
    -+const geometries = fileInfos.map((info) => {
    -+  return makeBoxes(info.file, info.hueRange, fileInfos);
    -+});
    -+
    -+// 以第一个几何体作为基准, 将其他的作为变形目标
    -+const baseGeometry = geometries[0];
    -+baseGeometry.morphAttributes.position = geometries.map((geometry, ndx) => {
    -+  const attribute = geometry.getAttribute('position');
    -+  const name = `target${ndx}`;
    -+  attribute.name = name;
    -+  return attribute;
    -+});
    -+baseGeometry.morphAttributes.color = geometries.map((geometry, ndx) => {
    -+  const attribute = geometry.getAttribute('color');
    -+  const name = `target${ndx}`;
    -+  attribute.name = name;
    -+  return attribute;
    -+});
    -+const material = new THREE.MeshBasicMaterial({
    -+  vertexColors: true,
    -+});
    -+const mesh = new THREE.Mesh(baseGeometry, material);
    -+scene.add(mesh);
    -
    -const uiElem = document.querySelector('#ui');
    -fileInfos.forEach((info) => {
    --  const boxes = addBoxes(info.file, info.hueRange);
    --  info.root = boxes;
    -  const div = document.createElement('div');
    -  info.elem = div;
    -  div.textContent = info.name;
    -  uiElem.appendChild(div);
    -  function show() {
    -    showFileInfo(fileInfos, info);
    -  }
    -  div.addEventListener('mouseover', show);
    -  div.addEventListener('touchstart', show);
    -});
    -// 展示第一组数据集
    -showFileInfo(fileInfos, fileInfos[0]);
    -
    -

    以上我们为每一组数据集创建了几何体, 以第一个作为基准, 获取了position属性, 将其他的几何体作为其变形目标

    -

    现在我们需要改变显示和隐藏各种数据集的方式. 我们需要改动变形目标的influence, 而不是简单地显示和隐藏mesh. 对于我们我们想看到的数据集, influence应该是1, 不想看到的是0. 但是我们又不能直接将他们设置成1和0, 这将会显示开与闭的两种情况, 和现在这种没有区别. 我们也可以写一段自定义的动画效果, 听起来不难. 但是我们模仿的WebGL globe用了一个动画库, 我们也用这一个.

    -

    我们这里首先引入它

    -
    import * as THREE from 'three';
    -import {BufferGeometryUtils} from 'three/addons/utils/BufferGeometryUtils.js';
    -import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -+import TWEEN from 'three/addons/libs/tween.module.js';
    -
    -

    然后创建一个Tween来使influence变化

    -
    // show the selected data, hide the rest
    -function showFileInfo(fileInfos, fileInfo) {
    -  fileInfos.forEach((info) => {
    -    const visible = fileInfo === info;
    --    info.root.visible = visible;
    -    info.elem.className = visible ? 'selected' : '';
    -+    const targets = {};
    -+    fileInfos.forEach((info, i) => {
    -+      targets[i] = info === fileInfo ? 1 : 0;
    -+    });
    -+    const durationInMs = 1000;
    -+    new TWEEN.Tween(mesh.morphTargetInfluences)
    -+      .to(targets, durationInMs)
    -+      .start();
    -  });
    -  requestRenderIfNotRequested();
    -}
    -
    -

    我们也可以在每一帧的render函数中调用TWEEN.update, 但这会带来一个问题. "tween.js"是为了连续渲染而设计的, 但是我们采用的是按需渲染. 我们可以再切换回连续渲染的方式, 但是为了省电和省资源起见, 还是按需渲染比较好. 所以我们看看是否能让它在按需渲染下工作.

    -

    我们需要TweenManaget来完成这件事. 我们将用它来创建Tween并追踪他们. 这里会有一个update方法, 如果我们二次调用它的时候返回true, 如果所有动画结束后则会返回false.

    -
    class TweenManger {
    -  constructor() {
    -    this.numTweensRunning = 0;
    -  }
    -  _handleComplete() {
    -    --this.numTweensRunning;
    -    console.assert(this.numTweensRunning >= 0);
    -  }
    -  createTween(targetObject) {
    -    const self = this;
    -    ++this.numTweensRunning;
    -    let userCompleteFn = () => {};
    -    // 创建一个新的Tween, 并应用我们自己的回调函数
    -    const tween = new TWEEN.Tween(targetObject).onComplete(function(...args) {
    -      self._handleComplete();
    -      userCompleteFn.call(this, ...args);
    -    });
    -    // 用我们自己的onComplete代替它的,
    -    // 因此, 如果用户提供回调, 我们可以调用用户的回调
    -    tween.onComplete = (fn) => {
    -      userCompleteFn = fn;
    -      return tween;
    -    };
    -    return tween;
    -  }
    -  update() {
    -    TWEEN.update();
    -    return this.numTweensRunning > 0;
    -  }
    -}
    -
    -

    我们需要以下代码来使用

    -
    function main() {
    -  const canvas = document.querySelector('#c');
    -  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -+  const tweenManager = new TweenManger();
    -
    -  ...
    -
    -

    这是如何创建Tween

    -
    // show the selected data, hide the rest
    -function showFileInfo(fileInfos, fileInfo) {
    -  fileInfos.forEach((info) => {
    -    const visible = fileInfo === info;
    -    info.elem.className = visible ? 'selected' : '';
    -    const targets = {};
    -    fileInfos.forEach((info, i) => {
    -      targets[i] = info === fileInfo ? 1 : 0;
    -    });
    -    const durationInMs = 1000;
    --    new TWEEN.Tween(mesh.morphTargetInfluences)
    -+    tweenManager.createTween(mesh.morphTargetInfluences)
    -      .to(targets, durationInMs)
    -      .start();
    -  });
    -  requestRenderIfNotRequested();
    -}
    -
    -

    我们需要改动render函数来更新tween, 让动画还在跑的时候保持渲染

    -
    function render() {
    -  renderRequested = false;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -+  if (tweenManager.update()) {
    -+    requestRenderIfNotRequested();
    -+  }
    -
    -  controls.update();
    -  renderer.render(scene, camera);
    -}
    -render();
    -
    -

    这样我们就可以在数据集中以动画的方式过渡了

    -

    - -

    -

    我希望上面讲的这些能有用. 通过threejs提供的方法或者自己写着色器来使用变形对象是一种常见的移动大量对象的手段. 作为一个例子, 我们可以给每一个立方体一个随机目标, 然后从这个位置变换到另一个位置. 这可能是一种超酷的介绍地球的方法.

    -

    接下来你可能感兴趣的是给地球上的一个位置添加标签, 这将在3D中排布HTML元素中涉及.

    -

    注: 我们可以试着用图表表示男性的百分比或女性的百分比或原始差异. 但根据我们显示信息的方式, 也就是从地球表面生长出来的立方体的显示方式, 我们希望大多数立方体都是矮的. 如果我们使用其中一个做基准, 大多数立方体的高度大约是它们最大高度的1/2. 效果会很差. 自己动手改一下amountGreaterThan中的Math.max(a - b, 0)(a - b) "原始差异"或者 a / (a + b)"百分比", 你就会明白我什么意思了.

    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/optimize-lots-of-objects.html b/manual/zh/optimize-lots-of-objects.html deleted file mode 100644 index 5130419aa86523..00000000000000 --- a/manual/zh/optimize-lots-of-objects.html +++ /dev/null @@ -1,438 +0,0 @@ - - - 大量对象的优化 - - - - - - - - - - - - - - -
    -
    -

    大量对象的优化

    -
    -
    -
    -

    本文是关于 three.js 系列文章的一部分. 第一篇文章是 three.js 基础. 如果你还没看过而且对three.js 还不熟悉,那应该从那里开始.

    -

    three.js的优化有很多种方式. 常见的一种叫做合并几何体. 每一个你创建的Mesh代表一个(或多个)请求系统渲染的命令. 即便是画出来的结果一样, 画两个几何体总是比画一个要费时费力. 所以最好的方式就是将这些mesh合并起来.

    -

    让我们来展示一个应用这种优化方式的优秀范例. 让我们来重新创建一个WebGL Globe.

    -

    第一件事是获取一些数据. WebGL Globe说他们的数据是来自SEDAC. 点开这个网站我们可以看到网格化的人口统计学数据. 我这里下载的是以60分为解析度的数据. 打开可以看到

    -
     ncols         360
    - nrows         145
    - xllcorner     -180
    - yllcorner     -60
    - cellsize      0.99999999999994
    - NODATA_value  -9999
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    - 9.241768 8.790958 2.095345 -9999 0.05114867 -9999 -9999 -9999 -9999 -999...
    - 1.287993 0.4395509 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999...
    - -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
    -
    -

    上面的数据首先是几行键值对, 然后是网格化的数据.

    -

    为了保证我们的理解没有偏差, 我们先做个2D图

    -

    先用这么几行代码载入数据

    -
    async function loadFile(url) {
    -  const res = await fetch(url);
    -  return res.text();
    -}
    -
    -

    上面的代码返回了一个带有指定url下文件内容的Promise

    -

    然后写几行数据来解析文件内容

    -
    function parseData(text) {
    -  const data = [];
    -  const settings = {data};
    -  let max;
    -  let min;
    -  // 对每一行进行切分
    -  text.split('\n').forEach((line) => {
    -    // split the line by whitespace
    -    const parts = line.trim().split(/\s+/);
    -    if (parts.length === 2) {
    -      // 长度为2的必定是键值对
    -      settings[parts[0]] = parseFloat(parts[1]);
    -    } else if (parts.length > 2) {
    -      // 长度超过2的肯定是网格数据
    -      const values = parts.map((v) => {
    -        const value = parseFloat(v);
    -        if (value === settings.NODATA_value) {
    -          return undefined;
    -        }
    -        max = Math.max(max === undefined ? value : max, value);
    -        min = Math.min(min === undefined ? value : min, value);
    -        return value;
    -      });
    -      data.push(values);
    -    }
    -  });
    -  return Object.assign(settings, {min, max});
    -}
    -
    -

    上面的代码返回了一个有着全部键值对的对象, 然后data属性是网格化的数据. minmax 中是 data 中的极值

    -

    下面是绘图函数

    -
    function drawData(file) {
    -  const {min, max, data} = file;
    -  const range = max - min;
    -  const ctx = document.querySelector('canvas').getContext('2d');
    -  // 新建一个和网格数据尺寸相等的canvas
    -  ctx.canvas.width = ncols;
    -  ctx.canvas.height = nrows;
    -  // 但是以两倍大小绘制防止太小
    -  ctx.canvas.style.width = px(ncols * 2);
    -  ctx.canvas.style.height = px(nrows * 2);
    -  // 用黑灰色填充
    -  ctx.fillStyle = '#444';
    -  ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    -  // 绘制数据点
    -  data.forEach((row, latNdx) => {
    -    row.forEach((value, lonNdx) => {
    -      if (value === undefined) {
    -        return;
    -      }
    -      const amount = (value - min) / range;
    -      const hue = 1;
    -      const saturation = 1;
    -      const lightness = amount;
    -      ctx.fillStyle = hsl(hue, saturation, lightness);
    -      ctx.fillRect(lonNdx, latNdx, 1, 1);
    -    });
    -  });
    -}
    -
    -function px(v) {
    -  return `${v | 0}px`;
    -}
    -
    -function hsl(h, s, l) {
    -  return `hsl(${h * 360 | 0},${s * 100 | 0}%,${l * 100 | 0}%)`;
    -}
    -
    -

    然后把上面的代码都合并起来

    -
    loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
    -  .then(parseData)
    -  .then(drawData);
    -
    -

    得到了下面的结果

    -

    - -

    -

    嗯... 看起来没什么问题

    -

    试试3D效果. 从按需渲染出发, 我们让每一个数据都画成一个box

    -

    首先先画一个地球, 这是sphere表面的贴图

    -
    - -

    用这些代码生成地球

    -
    {
    -  const loader = new THREE.TextureLoader();
    -  const texture = loader.load('resources/images/world.jpg', render);
    -  const geometry = new THREE.SphereGeometry(1, 64, 32);
    -  const material = new THREE.MeshBasicMaterial({map: texture});
    -  scene.add(new THREE.Mesh(geometry, material));
    -}
    -
    -

    看过来, 当材质加载完成后才调用render方法. 我们这么做是因为使用了按需渲染中的方法, 而不是连续渲染. 这样我们仅仅需要在材质加载后渲染一遍就好.

    -

    然后我们需要修改上面每个数据点画一个点的代码, 改为每个数据点画一个框.

    -
    function addBoxes(file) {
    -  const {min, max, data} = file;
    -  const range = max - min;
    -
    -  // 新建一个box geometry
    -  const boxWidth = 1;
    -  const boxHeight = 1;
    -  const boxDepth = 1;
    -  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    -  // 沿着z轴缩放
    -  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));
    -
    -  // 位置辅助器可以方便地在球面上定位
    -  // 经度辅助器可以在XZ平面的法向旋转
    -  const lonHelper = new THREE.Object3D();
    -  scene.add(lonHelper);
    -  // 纬度辅助器可以在XZ平面旋转
    -  const latHelper = new THREE.Object3D();
    -  lonHelper.add(latHelper);
    -  // 组合起来得到的位置辅助器可以在球面上定位
    -  const positionHelper = new THREE.Object3D();
    -  positionHelper.position.z = 1;
    -  latHelper.add(positionHelper);
    -
    -  const lonFudge = Math.PI * .5;
    -  const latFudge = Math.PI * -0.135;
    -  data.forEach((row, latNdx) => {
    -    row.forEach((value, lonNdx) => {
    -      if (value === undefined) {
    -        return;
    -      }
    -      const amount = (value - min) / range;
    -      const material = new THREE.MeshBasicMaterial();
    -      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
    -      const saturation = 1;
    -      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
    -      material.color.setHSL(hue, saturation, lightness);
    -      const mesh = new THREE.Mesh(geometry, material);
    -      scene.add(mesh);
    -
    -      // 调整辅助器使其指向经纬度
    -      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
    -      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
    -
    -      // 使用world matrix来操作辅助器
    -      positionHelper.updateWorldMatrix(true, false);
    -      mesh.applyMatrix4(positionHelper.matrixWorld);
    -
    -      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
    -    });
    -  });
    -}
    -
    -

    上面的代码直截了当得从2D测试方法中改动过来

    -

    我们新建一个长方体, 然后沿着Z轴缩放. 如果我们不这么做, 它就会以中心为参照放大, 使得根部不在球面上. 我们这么做之后, 就可以达到从球面上长出来的效果.

    -
    -
    -
    -
    default
    -
    -
    -
    -
    adjusted
    -
    -
    - -

    当然, 我们可以像场景图一章中讲得, 通过添加到一个父对象来解决上面的问题. 但是要考虑到我们体系几何体非常得多, 所以会大大拖累运行的速度.

    -

    上面的位置辅助器positionHelper是由lonHelper, latHelper逐级组合而来. 这个小东西可以帮助我们计算球面上的经纬度来放置几何体.

    -
    -
    -
    - -

    上面的绿条条代表lonHelper, 在赤道上以经度的变化旋转. The -蓝条条代表 latHelper, 在赤道上下以纬度的变化旋转. 红球球 就是位置辅助器实际指向的位置.

    -

    我们倒是可以计算所有的球面位置, 但是需要涉及到很多数学和库的调用, 所以就...可以但没必要.

    -

    每一个数据我们都创建了一个MeshBasicMaterial和一个Mesh, 然后我们从位置辅助器中取得world matrix并应用到新的Mesh上. 最后, 我们在它的新位置上缩放.

    -

    上面, 我们给每一个新box都创建了一个位置辅助器, 但是这将会使运行速度大大下降.

    -

    这最多有360x145=52000个盒子需要被创建. 有些点数据被标为 “NO_DATA” 所以实际的盒子数大概是19000左右. 如果我们每个盒子加上三个辅助器, 全局就大概80000个节点. 使用一组辅助器来调整mesh的位置我们可以节约60000个节点的计算.

    -

    注意lonFudge是π/2也就是四分之一圈, 也就是说在在一周上是以不同的偏移开始. 也能说得通. 但是我不知道为什么latFudge要乘以个 π * -0.135, 似乎就是一个能让盒子和材质对齐的数.

    -

    最后一步是调用loader

    -
    loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
    -  .then(parseData)
    --  .then(drawData)
    -+  .then(addBoxes)
    -+  .then(render);
    -

    当数据载入和解析完成, 我们再进行渲染

    -

    - -

    -

    拖拽一下这个球你就会发现很卡

    -

    我们在开启调试工具中提到过怎么打开帧率监视器

    -
    - -

    在我机器上大概是20帧每秒

    -
    - -

    这不太行, 我寻思很多人机器上会更慢. 我们得想办法优化它一下子.

    -

    此时此景, 我们可以通过合并所有的盒子到一个geometry来实现, 一下子就可以省下18999个操作

    -
    function addBoxes(file) {
    -  const {min, max, data} = file;
    -  const range = max - min;
    -
    --  // 新建一个几何体
    --  const boxWidth = 1;
    --  const boxHeight = 1;
    --  const boxDepth = 1;
    --  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    --  // 沿着Z轴缩放
    --  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));
    -
    -  // 位置辅助器可以方便地在球面上定位
    -  // 经度辅助器可以在XZ平面的法向旋转
    -  const lonHelper = new THREE.Object3D();
    -  scene.add(lonHelper);
    -  // 纬度辅助器可以在XZ平面旋转
    -  const latHelper = new THREE.Object3D();
    -  lonHelper.add(latHelper);
    -  // 组合起来得到的位置辅助器可以在球面上定位
    -  const positionHelper = new THREE.Object3D();
    -  positionHelper.position.z = 1;
    -  latHelper.add(positionHelper);
    -+  // 用来定位盒子的中心, 以便接下来沿着Z轴缩放
    -+  const originHelper = new THREE.Object3D();
    -+  originHelper.position.z = 0.5;
    -+  positionHelper.add(originHelper);
    -
    -  const lonFudge = Math.PI * .5;
    -  const latFudge = Math.PI * -0.135;
    -+  const geometries = [];
    -  data.forEach((row, latNdx) => {
    -    row.forEach((value, lonNdx) => {
    -      if (value === undefined) {
    -        return;
    -      }
    -      const amount = (value - min) / range;
    -
    --      const material = new THREE.MeshBasicMaterial();
    --      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
    --      const saturation = 1;
    --      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
    --      material.color.setHSL(hue, saturation, lightness);
    --      const mesh = new THREE.Mesh(geometry, material);
    --      scene.add(mesh);
    -
    -+      const boxWidth = 1;
    -+      const boxHeight = 1;
    -+      const boxDepth = 1;
    -+      const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    -
    -      // 调整位置辅助器的指向
    -      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
    -      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
    -
    -      // 使用world matrix来操作辅助器
    -      positionHelper.updateWorldMatrix(true, false);
    -      mesh.applyMatrix4(positionHelper.matrixWorld);
    -      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
    -
    -+      // 使用位置辅助器和world matrix 来定位
    -+      positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
    -+      originHelper.updateWorldMatrix(true, false);
    -+      geometry.applyMatrix4(originHelper.matrixWorld);
    -+
    -+      geometries.push(geometry);
    -    });
    -  });
    -
    -+  const mergedGeometry = BufferGeometryUtils.mergeGeometries(
    -+      geometries, false);
    -+  const material = new THREE.MeshBasicMaterial({color:'red'});
    -+  const mesh = new THREE.Mesh(mergedGeometry, material);
    -+  scene.add(mesh);
    -
    -}
    -
    -

    我们移除了之前用来改变盒子几何中心的代码, 取而代之的是originHelper. 这次我们要为每个长方体创建新的几何体, 因为我们要使用“applyMatrix”来移动每个长方体几何体的顶点, 所以我们最好只移动一次, 而不是两次.

    -

    最后, 我们将所有几何体的数组传入BufferGeometryUtils.mergeGeometries, 这个方法将会将其合并到一个mesh中

    -

    别忘了引入BufferGeometryUtils

    -
    import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
    -
    -

    现在, 至少在我的机器上, 可以跑到60帧每秒了

    -

    - -

    -

    虽然可以了, 但是我们这是一整个mesh, 所以我们只能应用一个材质, 意味着我们只能有一种颜色的盒子. 我们之前可是能有不同颜色的盒子. 我们可以通过使用顶点着色法来解决.

    -

    顶点着色向每个顶点添加一种颜色. 通过设定每个盒子的每个顶点的所有颜色来指定每个盒子的颜色.

    -
    +const color = new THREE.Color();
    -
    -const lonFudge = Math.PI * .5;
    -const latFudge = Math.PI * -0.135;
    -const geometries = [];
    -data.forEach((row, latNdx) => {
    -  row.forEach((value, lonNdx) => {
    -    if (value === undefined) {
    -      return;
    -    }
    -    const amount = (value - min) / range;
    -
    -    const boxWidth = 1;
    -    const boxHeight = 1;
    -    const boxDepth = 1;
    -    const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    -
    -    lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
    -    latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
    -
    -    positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
    -    originHelper.updateWorldMatrix(true, false);
    -    geometry.applyMatrix4(originHelper.matrixWorld);
    -
    -+    // 计算颜色
    -+    const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
    -+    const saturation = 1;
    -+    const lightness = THREE.MathUtils.lerp(0.4, 1.0, amount);
    -+    color.setHSL(hue, saturation, lightness);
    -+    // 以0到255之间的值数组形式获取颜色
    -+    const rgb = color.toArray().map(v => v * 255);
    -+
    -+    // 创建一个数组来存储每个顶点的颜色
    -+    const numVerts = geometry.getAttribute('position').count;
    -+    const itemSize = 3;  // r, g, b
    -+    const colors = new Uint8Array(itemSize * numVerts);
    -+
    -+    // 将颜色复制到每个顶点的颜色数组中
    -+    colors.forEach((v, ndx) => {
    -+      colors[ndx] = rgb[ndx % 3];
    -+    });
    -+
    -+    const normalized = true;
    -+    const colorAttrib = new THREE.BufferAttribute(colors, itemSize, normalized);
    -+    geometry.setAttribute('color', colorAttrib);
    -
    -    geometries.push(geometry);
    -  });
    -});
    -
    -

    上面的代码中, 我们查找几何体中的position属性来获取所需的数量和顶点. 然后创建一个Uint8Array来输入颜色. 接下来通过调用geometry.setAttribute来将其设定为一个属性.

    -

    最后告诉three.js使用顶点上色.

    -
    const mergedGeometry = BufferGeometryUtils.mergeGeometries(
    -    geometries, false);
    --const material = new THREE.MeshBasicMaterial({color:'red'});
    -+const material = new THREE.MeshBasicMaterial({
    -+  vertexColors: true,
    -+});
    -const mesh = new THREE.Mesh(mergedGeometry, material);
    -scene.add(mesh);
    -
    -

    我们的彩色世界回来啦!

    -

    - -

    -

    合并几何体是一个常见的优化手段. 比如, 可以将一百多棵树合并成一个几何体, 一堆石头合并成一块石头, 零零碎碎的栅栏合并成一个栅栏的mesh. 另一个在 Minecraft 中的例子是, 它不太可能单独绘制每个立方体, 而是创建一组合并的立方体, 并且选择性地删除那些永远不可见的面.

    -

    这么做带来的问题是, 合并起来简单, 分离难. 接下来我们再引入一种优化方案 -优化大量动画对象.

    -

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/physics.html b/manual/zh/physics.html deleted file mode 100644 index a9a4e53eeb1bff..00000000000000 --- a/manual/zh/physics.html +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - 物理 - - - - - - - - - - - - - - - -
    -
    -

    物理

    -
    -
    -
    - -

    - 物理引擎可以在 3D 场景中模拟重力、碰撞、受力等物理现象。 - 在常规 three.js 场景中,我们通常直接修改对象位置和旋转; - 而在使用物理引擎时,会额外维护一个并行的“物理世界”, - 刚体在其中响应力和碰撞。然后每帧把 three.js 网格与物理刚体状态同步, - 从而呈现出“真实物理”效果。 -

    - -

    - 需要注意的是,物理引擎不一定要每帧更新。为了保持模拟稳定, - 通常会采用固定时间步。比如游戏循环运行于 60fps, - 物理更新运行于 30fps(1/30 秒), - 同时每帧使用物理引擎的最新状态更新 three.js 网格变换(位置、旋转等)。 -

    - -

    - 物理模拟尤其适用于游戏、交互可视化,以及任何需要真实对象行为的应用, - 例如下落、弹跳、滑动等效果。 -

    - -

    集成方式

    - -

    - 在 three.js 项目中集成物理引擎,主要有三种方式: -

    - -

    1. 使用 three.js 物理插件

    - -

    - Three.js 在 examples/jsm/physics 目录中为多个常见物理引擎提供了封装类。 - 这些插件可简化接入流程,完成物理世界初始化与网格同步。 -

    - -

    - 可用插件包括: -

    - -
      -
    • AmmoPhysics:Ammo.js(Bullet 物理)的封装。
    • -
    • JoltPhysics:Jolt Physics 的封装。
    • -
    • RapierPhysics:Rapier 的封装。
    • -
    - -

    - 这些插件屏蔽了大量底层复杂性。对于常规需求,它们是最快的入门路径之一。 -

    - -

    - 示例 -

    - - -

    2. 使用第三方 JS/TS 物理库

    - -

    - 许多物理引擎直接由 JavaScript / TypeScript 编写, - 与 Web 生态集成较容易。像 cannon-es 这类库因轻量且接入简单而常被采用。 -

    - -

    - 使用这类库时,你需要自己创建物理世界和刚体, - 并在动画循环中手动把刚体的位置、四元数同步到 three.js 网格。 -

    - - -

    - 项目 -

    -
      -
    • cannon-es:纯 JS/TS 的轻量 3D 物理引擎,MIT 协议。看起来维护已不活跃(最近提交距今较久)。
    • -
    • cannon.js:纯 JavaScript 的轻量 3D 物理引擎,MIT 协议。已基本停止维护。建议优先使用其较新的分支 cannon-es。
    • -
    • phy:面向 three.js 的纯 JavaScript 物理引擎,MIT 协议。当前仍在维护。
    • -
    • Oimo.js:纯 JavaScript 轻量 3D 物理引擎,已不再维护。作者建议改用 phy。
    • -
    -

    - 另外还有一类“看似 JS/TS、实则调用外部引擎”的方案,例如: -

    -
      -
    • Physijs:底层调用 ammo.js,并借助 Web Worker 在独立线程处理物理,MIT 协议。维护不活跃(最近提交距今多年)。
    • -
    • enable3d:基于 ammo.js 的 three.js 3D 物理框架,LGPL-3.0 协议。看起来仍在维护。
    • -
    - -

    3. 引入基于 WASM 的引擎

    - -

    - 如果你需要更高性能、稳定性和精度(尤其复杂模拟), - 可以选择 C++/Rust 等语言编写并编译为 WebAssembly(WASM)的物理引擎。 - 例如 Ammo.js(Bullet 的移植版)和 Rapier 都属于这一类。 -

    - -

    - 这种方式通常功能最完整、性能最好,但接入成本更高, - 需要处理 WASM 内存管理及与物理 API 的直接交互。 -

    - -

    - 示例 -

    - - -

    - 项目 -

    -
      -
    • JoltPhysics:面向多核的刚体物理与碰撞检测库,C++ 编写,MIT 协议,活跃维护。已在《Horizon Forbidden West》《Death Stranding 2》等知名作品中得到验证,并获得 Godot 游戏引擎官方支持。
    • -
    • PhysX:NVIDIA 提供的工业级实时 3D 物理引擎,BSD-3-Clause 协议,稳定且持续维护。
    • -
    • Rapier:注重性能的 2D/3D 物理引擎,Rust 编写,MIT 协议,活跃维护。
    • -
    • Bullet: - 用于 VR、游戏、视觉特效、机器人、机器学习等场景的实时碰撞检测与多物理模拟库,C++ 编写,ZLIB 协议。维护状态可能不活跃。
    • -
    -

    - 其中一些跨平台 3D 物理引擎已有可直接使用的 WASM 版本,例如: -

    -
      -
    • JoltPhysics.js:使用 Emscripten 将 JoltPhysics 移植到 JavaScript,MIT 协议,当前维护中。
    • -
    • physx-js-webidl:NVIDIA PhysX 的 JavaScript WASM 绑定,MIT 协议,当前维护中。
    • -
    • Rapier.js:Rapier 的官方 JavaScript 绑定,Apache-2.0 协议,活跃维护。
    • -
    • Ammo.js:使用 Emscripten 将 Bullet 直接移植到 JavaScript,维护不活跃(最近提交距今多年),采用类似 MIT 的宽松自定义协议。
    • -
    - -
    -
    -
    - - - - - - - - - diff --git a/manual/zh/picking.html b/manual/zh/picking.html deleted file mode 100644 index 0e618074dec069..00000000000000 --- a/manual/zh/picking.html +++ /dev/null @@ -1,390 +0,0 @@ - - - 拾取 - - - - - - - - - - - - - - -
    -
    -

    拾取

    -
    -
    -
    -

    拾取 指代推断用户点击或触碰了哪个对象的过程。有很多方式实现拾取,但是,每一种都有相应的成本,使用时需有所取舍。下面是最常用的两种方式:

    -

    射线追踪法(raycasting)很可能是最常用的方法,其基本原理是:从鼠标处发射一条射线,穿透场景的视椎体,通过计算,找出视锥体中哪些对象与射线相交。

    -

    首先,获取鼠标的屏幕坐标.其次,对其应用摄像机的投影和方向的矩阵变换,得到其在世界空间的坐标。然后,计算出一条射线,从视锥体的近端平面射向远端平面。再然后,对于场景中每一个对象的每一个三角,检查其是否与射线相交。假设你的场景中有1000个对象,每个对象有1000个三角,那么就需要检查一百万个三角。

    -

    对此,可以做一些优化,先检查对象的包围球或包围盒是否与射线相交,包围球或包围盒是指包含整个对象的球体或者立方体,如果射线未相交,就不需要检查组成该对象的三角们了。

    -

    THREE.js 提供了 RayCaster 类来做这些事情。

    -

    接下来,让我们先创建一个包含100个对象的场景,然后试着去拾取这些对象。可以从样例threejs-响应式开始。

    -

    改动一些代码 -使摄像机成为一个对象的子元素,旋转这个对象时,摄像机会像绑定在自拍杆上一样,在场景中游弋。

    -
    *const fov = 60;
    -const aspect = 2;  // 画布默认纵横比为2
    -const near = 0.1;
    -*const far = 200;
    -const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -*camera.position.z = 30;
    -
    -const scene = new THREE.Scene();
    -+scene.background = new THREE.Color('white');
    -
    -+// 把摄像机放到自拍杆上 (把它添加为一个对象的子元素)
    -+// 如此,我们就能通过旋转自拍杆,来移动摄像机
    -+const cameraPole = new THREE.Object3D();
    -+scene.add(cameraPole);
    -+cameraPole.add(camera);
    -
    -

    render 函数中旋转摄像机端点。

    -
    cameraPole.rotation.y = time * .1;
    -
    -

    把光源也绑定到摄像机上,这样光源就会随着摄像机移动。

    -
    -scene.add(light);
    -+camera.add(light);
    -
    -

    生成100个立方体,每个立方体的颜色,位置,朝向,缩放都随机。

    -
    const boxWidth = 1;
    -const boxHeight = 1;
    -const boxDepth = 1;
    -const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    -
    -function rand(min, max) {
    -  if (max === undefined) {
    -    max = min;
    -    min = 0;
    -  }
    -  return min + (max - min) * Math.random();
    -}
    -
    -function randomColor() {
    -  return `hsl(${rand(360) | 0}, ${rand(50, 100) | 0}%, 50%)`;
    -}
    -
    -const numObjects = 100;
    -for (let i = 0; i < numObjects; ++i) {
    -  const material = new THREE.MeshPhongMaterial({
    -    color: randomColor(),
    -  });
    -
    -  const cube = new THREE.Mesh(geometry, material);
    -  scene.add(cube);
    -
    -  cube.position.set(rand(-20, 20), rand(-20, 20), rand(-20, 20));
    -  cube.rotation.set(rand(Math.PI), rand(Math.PI), 0);
    -  cube.scale.set(rand(3, 6), rand(3, 6), rand(3, 6));
    -}
    -
    -

    最后,让我们来完成拾取功能。 -写一个简单的类来管理拾取操作

    -
    class PickHelper {
    -  constructor() {
    -    this.raycaster = new THREE.Raycaster();
    -    this.pickedObject = null;
    -    this.pickedObjectSavedColor = 0;
    -  }
    -  pick(normalizedPosition, scene, camera, time) {
    -    // 恢复上一个被拾取对象的颜色
    -    if (this.pickedObject) {
    -      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
    -      this.pickedObject = undefined;
    -    }
    -
    -    // 发出射线
    -    this.raycaster.setFromCamera(normalizedPosition, camera);
    -    // 获取与射线相交的对象
    -    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
    -    if (intersectedObjects.length) {
    -      // 找到第一个对象,它是离鼠标最近的对象
    -      this.pickedObject = intersectedObjects[0].object;
    -      // 保存它的颜色
    -      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
    -      // 设置它的发光为 黄色/红色闪烁
    -      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
    -    }
    -  }
    -}
    -
    -

    可以看到,我们创建了一个 RayCaster 实例,调用该实例的 pick 方法可以在场景中发出一条射线。如果,射线有撞击到场景中的物体,修改撞击到的第一个物体的颜色。

    -

    当然,也可以在用户点击鼠标时,调用这个方法,这恐怕是最常见的应用场景。但是,在本范例中,不管在鼠标下方是什么,在每一帧中都会进行拾取操作,为此,需要跟踪鼠标的位置。

    -
    const pickPosition = {x: 0, y: 0};
    -clearPickPosition();
    -
    -...
    -
    -function getCanvasRelativePosition(event) {
    -  const rect = canvas.getBoundingClientRect();
    -  return {
    -    x: (event.clientX - rect.left) * canvas.width  / rect.width,
    -    y: (event.clientY - rect.top ) * canvas.height / rect.height,
    -  };
    -}
    -
    -function setPickPosition(event) {
    -  const pos = getCanvasRelativePosition(event);
    -  pickPosition.x = (pos.x / canvas.width ) *  2 - 1;
    -  pickPosition.y = (pos.y / canvas.height) * -2 + 1;  // note we flip Y
    -}
    -
    -function clearPickPosition() {
    -  // 对于触屏,不像鼠标总是能有一个位置坐标,
    -  // 如果用户不在触摸屏幕,我们希望停止拾取操作。
    -  // 因此,我们选取一个特别的值,表明什么都没选中
    -  pickPosition.x = -100000;
    -  pickPosition.y = -100000;
    -}
    -
    -window.addEventListener('mousemove', setPickPosition);
    -window.addEventListener('mouseout', clearPickPosition);
    -window.addEventListener('mouseleave', clearPickPosition);
    -
    -

    需要注意的是,我们记录了归一化的鼠标位置。无论画布的尺寸,我们需要一个从左到右,落入区间(-1,1)的值,类似的,也需要一个从下到上,落入区间(-1,1)的值。

    -

    完成以后,我们再添加对移动端的支持:

    -
    window.addEventListener('touchstart', (event) => {
    -  // 阻止窗口滚动行为
    -  event.preventDefault();
    -  setPickPosition(event.touches[0]);
    -}, {passive: false});
    -
    -window.addEventListener('touchmove', (event) => {
    -  setPickPosition(event.touches[0]);
    -});
    -
    -window.addEventListener('touchend', clearPickPosition);
    -
    -

    最终,在 render 方法中,我们调用了 PickHelperpick 方法

    -
    +const pickHelper = new PickHelper();
    -
    -function render(time) {
    -  time *= 0.001;  //将毫秒单位转换为秒单位;
    -
    -  ...
    -
    -+  pickHelper.pick(pickPosition, scene, camera, time);
    -
    -  renderer.render(scene, camera);
    -
    -  ...
    -
    -

    这是最终结果 -

    - -

    -

    这种方式看起来效果不错,而且能处理很多用户场景,但是也存在几个问题:

    -
      -
    1. 这是基于CPU运算的 -Javascript遍历每一个对象,检查其包围盒或包围球是否与射线相交,如果相交,它必须遍历组成该对象的每一个三角,检查它们是否与射线相交。
      好处是,JavaScript能够很容易计算出射线在哪里与三角相交,并为我们提供相关数据。举个例子,如果你想要在相交的位置放置一个标记。
      缺点是,CPU要做大量的工作,当你的对象由大量的三角组成时,这个过程会有些慢。
    2. -
    3. 它无法处理一些奇怪的着色器或者位移 -如果,你有一个变形或者拟态几何形状的着色器,Javascript无法理解这个变形,它会给出错误的答案。举例:据我所知,你不能对有皮肤的对象使用这种方式。
    4. -
    5. 无法处理透明的孔洞 -举个例子,为立方体应用这个纹理
      - -
    6. -
    -

    改动代码如下:

    -
    +const loader = new THREE.TextureLoader();
    -+const texture = loader.load('resources/images/frame.png');
    -
    -const numObjects = 100;
    -for (let i = 0; i < numObjects; ++i) {
    -  const material = new THREE.MeshPhongMaterial({
    -    color: randomColor(),
    -    +map: texture,
    -    +transparent: true,
    -    +side: THREE.DoubleSide,
    -    +alphaTest: 0.1,
    -  });
    -
    -  const cube = new THREE.Mesh(geometry, material);
    -  scene.add(cube);
    -
    -  ...
    -
    -

    运行后,你就能看到问题所在。 -

    - -

    -

    试着透过盒子拾取一些物体,但是你无法做到

    -
    - -

    这是因为 JavaScript 无法通过简单的查看纹理和材质,就推测出你的对象是否存在一部分是透明的或者不透明。

    -

    对于这些问题的解决方案,就是使用基于GPU的拾取方法。可惜,该方法概念上简单,但是相比于射线追踪法,用起来就复杂了。

    -

    为了完成GPU拾取,对每一个对象使用唯一的颜色进行离屏渲染。然后,检查鼠标位置关联的像素的颜色。这个颜色就能告诉我们哪个对象被选中。

    -

    这能解决上面的问题2,3。至于问题1的速度问题,这取决于业务场景。每个对象会被绘制两次,一次用于观看,一次用于拾取。也许存在开脑洞的解决方案,可以只绘制一次就完成查看和拾取,此处我们不会尝试。

    -

    但是有一件事值得去做,因为拾取时我们只需读取1px,所以我们可以设置摄像机,只绘制1px,通过 PerspectiveCamera.setViewOffset 方法,可以告诉THREE.js 计算出一个摄像机 只呈现一个大矩形的一个很小的部分。这应该能节省一些运行时间。

    -

    此时,要在THREE.js中实现这种拾取方式,需要创建两个场景。一个使用正常的网格对象填充。另外一个使用“拾取材质”的网格对象填充。

    -

    因此,首先创建第二个场景,并将其清理为黑色背景。

    -
    const scene = new THREE.Scene();
    -scene.background = new THREE.Color('white');
    -const pickingScene = new THREE.Scene();
    -pickingScene.background = new THREE.Color(0);
    -
    -

    然后,对于在主场景中的每一个立方体,在 pickingScene 中,在同样的位置,创建一个与原立方体相似的,相关联的“可拾取立方体”,用对象的id生成颜色值,去设置对象的材质。

    -
    const idToObject = {};
    -+const numObjects = 100;
    -for (let i = 0; i < numObjects; ++i) {
    -+  const id = i + 1;
    -  const material = new THREE.MeshPhongMaterial({
    -    color: randomColor(),
    -    map: texture,
    -    transparent: true,
    -    side: THREE.DoubleSide,
    -    alphaTest: 0.1,
    -  });
    -
    -  const cube = new THREE.Mesh(geometry, material);
    -  scene.add(cube);
    -+  idToObject[id] = cube;
    -
    -  cube.position.set(rand(-20, 20), rand(-20, 20), rand(-20, 20));
    -  cube.rotation.set(rand(Math.PI), rand(Math.PI), 0);
    -  cube.scale.set(rand(3, 6), rand(3, 6), rand(3, 6));
    -
    -+  const pickingMaterial = new THREE.MeshPhongMaterial({
    -+    emissive: new THREE.Color().setHex(id, THREE.NoColorSpace),
    -+    color: new THREE.Color(0, 0, 0),
    -+    specular: new THREE.Color(0, 0, 0),
    -+    map: texture,
    -+    transparent: true,
    -+    side: THREE.DoubleSide,
    -+    alphaTest: 0.5,
    -+    blending: THREE.NoBlending,
    -+  });
    -+  const pickingCube = new THREE.Mesh(geometry, pickingMaterial);
    -+  pickingScene.add(pickingCube);
    -+  pickingCube.position.copy(cube.position);
    -+  pickingCube.rotation.copy(cube.rotation);
    -+  pickingCube.scale.copy(cube.scale);
    -}
    -
    -

    注意到,此时,我们利用 MeshPhongMaterial 创建材质,使用id生成颜色,设置到它的emissive属性,colorspecular属性设置为0,设置 alphaTest 属性,只渲染纹理的alpha值大于该属性值的部分,还需要将blending 设置为 NoBlending,这样alpha通道不会作用到id生成色

    -

    注意到,利用 MeshPhongMaterial 可能并不是最优的解决方案,因为,在绘制拾取场景时,仍然需要计算所有的光线,尽管我们不需要这些计算。一个更优的方案是使用自定义的着色器,只为纹理alpha值大于 alphaTest 属性值的部分,输出id生成色

    -

    由于我们是从像素点拾取,而不是射线追踪,只需将代码修改为使用像素拾取方式,获取拾取位置。

    -
    function setPickPosition(event) {
    -  const pos = getCanvasRelativePosition(event);
    --  pickPosition.x = (pos.x / canvas.clientWidth ) *  2 - 1;
    --  pickPosition.y = (pos.y / canvas.clientHeight) * -2 + 1;  // 注意,翻转了y轴
    -+  pickPosition.x = pos.x;
    -+  pickPosition.y = pos.y;
    -}
    -
    -

    首先,我们将 PickHelper 修改为 GPUPickHelper。这里使用了 WebGLRenderTarget,如同我们在 多个渲染目标中介绍的一样,此处,我们的渲染目标只有1像素的尺寸,1×1。

    -
    -class PickHelper {
    -+class GPUPickHelper {
    -  constructor() {
    --    this.raycaster = new THREE.Raycaster();
    -+    // 创建一个1px的渲染目标
    -+    this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
    -+    this.pixelBuffer = new Uint8Array(4);
    -    this.pickedObject = null;
    -    this.pickedObjectSavedColor = 0;
    -  }
    -  pick(cssPosition, scene, camera, time) {
    -+    const {pickingTexture, pixelBuffer} = this;
    -
    -    // 如果已经存在拾取的对象,将其颜色恢复
    -    if (this.pickedObject) {
    -      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
    -      this.pickedObject = undefined;
    -    }
    -
    -+    // 设置视野偏移来表现鼠标下的1px
    -+    const pixelRatio = renderer.getPixelRatio();
    -+    camera.setViewOffset(
    -+        renderer.getContext().drawingBufferWidth,   // 全宽
    -+        renderer.getContext().drawingBufferHeight,  // 全高
    -+        cssPosition.x * pixelRatio | 0,             // rect x
    -+        cssPosition.y * pixelRatio | 0,             // rect y
    -+        1,                                          // rect width
    -+        1,                                          // rect height
    -+    );
    -+    // 渲染场景
    -+    renderer.setRenderTarget(pickingTexture)
    -+    renderer.render(scene, camera);
    -+    renderer.setRenderTarget(null);
    -+
    -+    // 清理视野偏移,回归正常
    -+    camera.clearViewOffset();
    -+    // 读取像素
    -+    renderer.readRenderTargetPixels(
    -+        pickingTexture,
    -+        0,   // x
    -+        0,   // y
    -+        1,   // width
    -+        1,   // height
    -+        pixelBuffer);
    -+
    -+    const id =
    -+        (pixelBuffer[0] << 16) |
    -+        (pixelBuffer[1] <<  8) |
    -+        (pixelBuffer[2]      );
    -
    --    // 射线穿越视锥体
    --    this.raycaster.setFromCamera(normalizedPosition, camera);
    --    // 获取与射线相交的对象
    --    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
    --    if (intersectedObjects.length) {
    --      // 获取第一个对象,他是离鼠标最近的一个
    --      this.pickedObject = intersectedObjects[0].object;
    -
    -+    const intersectedObject = idToObject[id];
    -+    if (intersectedObject) {
    -+      //获取第一个对象,他是离鼠标最近的一个
    -+      this.pickedObject = intersectedObject;
    -      // 保存颜色
    -      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
    -      // 设置对象在黄/红两色间闪烁
    -      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
    -    }
    -  }
    -}
    -
    -

    接下来,我们就可以使用它了

    -
    -const pickHelper = new PickHelper();
    -+const pickHelper = new GPUPickHelper();
    -
    -

    这里是将 pickScene 传给helper,而不是scene

    -
    -  pickHelper.pick(pickPosition, scene, camera, time);
    -+  pickHelper.pick(pickPosition, pickScene, camera, time);
    -
    -

    现在,你应该可以透过透明的部分进行拾取操作了

    -

    - -

    -

    至此,对于如何实现拾取,希望此文能给你一些灵感。在后续的文章中,也许,我们可以看看如何使用鼠标操作对象。

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/post-processing.html b/manual/zh/post-processing.html deleted file mode 100644 index 479724ea2841a8..00000000000000 --- a/manual/zh/post-processing.html +++ /dev/null @@ -1,218 +0,0 @@ - - - 后期处理 - - - - - - - - - - - - - - -
    -
    -

    后期处理

    -
    -
    -
    -

    后置处理通常是指应用到2d图像上的某种特效或者是滤镜。在ThreeJs的场景中,我们有的是由很多网格(mesh)构成的场景(scene)。我们将其渲染成2d图像。一般来说,图像被直接渲染成canvas然后在浏览器中被展示,然而在结果被输出到canvas之前,我们也可以通过另外的一个render target并应用一些后置效果。这被称为Post Processing,因为它发生在主场景渲染过程之后。

    -

    后置处理的示例 比如 Instagram 的滤镜,photoshop的滤镜。

    -

    ThreeJs拥有一些案例课程去帮助建立一个后置处理管道。工作方式是你需要创建EffectComposer然后增加一些Pass对象。

    -

    每一个Pass阶段都可以增加一些后置处理特效,添加小插图,模糊,添加光晕,添加噪点,调整色相,饱和度,对比度等等。最终把效果渲染到canvas。

    -

    理解EffectComposer是如何工作的是有一点重要的。它创建两个render targets。让我们称他们为rtArtB

    -

    然后你调用EffectComposer.addPass按照你想要应用它们的顺序增加pass。然后它们就被向下图所示的被应用。

    -
    - -

    首先 你传入RenderPass的场景被渲染到rtA,不管rta的内容是啥,它继续向下一个pass传递。下一个pass将它作为输入做一些操作然后将其写入到rtB。然后rtB传到下一个pass,将rtB作为输入作一些操作然后在写回rtA。这个过程在整个pass过程中持续发生。

    -

    每个pass都有4个基础选项

    -

    enabled

    -

    → 是否使用这个pass

    -

    needsSwap

    -

    → 完成这个pass后是否交换rtA和rtB

    -

    clear

    -

    → 在渲染这个pass之前是否需要清除

    -

    renderToScreen

    -

    → 是否将当前的内容渲染到画布上。通常来说你需要在你最后添加的pass设置这一项为true

    -

    让我们将他们结合起来写一个简单的例子。 我们将从这个例子开始 the article on responsiveness

    -

    第一步,我们创建一个EffectComposer

    -
    const composer = new EffectComposer(renderer);
    -
    -

    然后,作为第一个pass,我们添加一个RenderPass,它会将我们的场景scene和我们的相机camera渲染到第一个渲染目标

    -
    composer.addPass(new RenderPass(scene, camera));
    -
    -

    接下来,我们添加一个BloomPassBloomPass将它的输入放入一个通常来说更小的render target然后对这个结果的表面进行模糊处理。这使得scene产生辉光效果。

    -
    const bloomPass = new BloomPass(
    -    1,    // strength
    -    25,   // kernel size
    -    4,    // sigma ?
    -    256,  // blur render target resolution
    -);
    -composer.addPass(bloomPass);
    -
    -

    最后,我们用FilmPass来添加噪点和扫描线。

    -
    const filmPass = new FilmPass(
    -    0.5,   // intensity
    -    false,  // grayscale
    -);
    -composer.addPass(filmPass);
    -
    -

    为了使用这些类,我们需要导入一些js模块

    -
    import {EffectComposer} from 'three/addons/postprocessing/EffectComposer.js';
    -import {RenderPass} from 'three/addons/postprocessing/RenderPass.js';
    -import {BloomPass} from 'three/addons/postprocessing/BloomPass.js';
    -import {FilmPass} from 'three/addons/postprocessing/FilmPass.js';
    -import {OutputPass} from 'three/addons/postprocessing/OutputPass.js';
    -
    -

    对于几乎所有的后期处理EffectComposer.js,RenderPass.js 都是必需的。

    -

    们需要做的最后一件事是使用EffectComposer.render 替代 WebGLRenderer.render 并告诉EffectComposer来匹配画布的大小

    -
    -function render(now) {
    --  time *= 0.001;
    -+let then = 0;
    -+function render(now) {
    -+  now *= 0.001;  // convert to seconds
    -+  const deltaTime = now - then;
    -+  then = now;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -+    composer.setSize(canvas.width, canvas.height);
    -  }
    -
    -  cubes.forEach((cube, ndx) => {
    -    const speed = 1 + ndx * .1;
    --    const rot = time * speed;
    -+    const rot = now * speed;
    -    cube.rotation.x = rot;
    -    cube.rotation.y = rot;
    -  });
    -
    --  renderer.render(scene, camera);
    -+  composer.render(deltaTime);
    -
    -  requestAnimationFrame(render);
    -}
    -
    -

    EffectComposer.render 需要花费deltaTime自最后一帧渲染后的数秒时间。如果任何一个有动画,它将各种效果传递下去。在这个示例中FilmPass被动画化了。

    -

    - -

    -

    要在运行时更改效果参数,通常需要设置统一的值。让我们添加一个GUI来调整一些参数。为了您可以轻松调整哪些值以及如何调整它们您需要深入了解该效果的代码。

    -

    查看BloomPass.js,我找到了这一行

    -
    this.copyUniforms[ "opacity" ].value = strength;
    -
    -

    所以我们设置strength

    -
    bloomPass.copyUniforms.opacity.value = someValue;
    -
    -

    类似地,在FilmPass.js中我发现这些代码

    -
    this.uniforms.intensity.value = intensity;
    -this.uniforms.grayscale.value = grayscale;
    -
    -

    这样就很清楚如何设置它们。

    -

    让我们快速创建一个GUI来设置这些值

    -
    import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
    -
    -

    -
    const gui = new GUI();
    -{
    -  const folder = gui.addFolder('BloomPass');
    -  folder.add(bloomPass.copyUniforms.opacity, 'value', 0, 2).name('strength');
    -  folder.open();
    -}
    -{
    -  const folder = gui.addFolder('FilmPass');
    -  folder.add(filmPass.uniforms.grayscale, 'value').name('grayscale');
    -  folder.add(filmPass.uniforms.intensity, 'value', 0, 1).name('intensity');
    -  folder.open();
    -}
    -
    -

    现在我们可以调整这些设置

    -

    - -

    -

    这是实现我们自己后期效果的一小步。

    -

    后期效果需要使用着色器。着色器用称为GLSL(图形库着色语言)的语言编写 。对于这些文章,讲述整个是一个太大的话题。一些入门的资料 ,可以是这篇文章 也可以是这本书 the books of shaders

    -

    我认为用一个例子去帮助你开始是有帮助的,因此让我们创建一个简单的GLSL后处理着色器。我们将制作一个使用某个颜色去改变图像的例子。

    -

    对于后期处理,THREE.js提供了一个有用的帮助器,称为ShaderPass。它需要一个对象,该对象的信息定义了顶点着色器,片段着色器和默认输入。它将处理设置要读取的纹理以获取上一遍的结果以及要渲染到 EffectComposers渲染目标之一或画布上的位置。

    -

    这是一个简单的后期处理着色器,它将之前的结果乘以颜色。

    -
    const colorShader = {
    -  uniforms: {
    -    tDiffuse: { value: null },
    -    color:    { value: new THREE.Color(0x88CCFF) },
    -  },
    -  vertexShader: `
    -    varying vec2 vUv;
    -    void main() {
    -      vUv = uv;
    -      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1);
    -    }
    -  `,
    -  fragmentShader: `
    -    varying vec2 vUv;
    -    uniform sampler2D tDiffuse;
    -    uniform vec3 color;
    -    void main() {
    -      vec4 previousPassColor = texture2D(tDiffuse, vUv);
    -      gl_FragColor = vec4(
    -          previousPassColor.rgb * color,
    -          previousPassColor.a);
    -    }
    -  `,
    -};
    -
    -

    上面tDiffuseShaderPass用来传递上一个pass纹理的名称,因此我们几乎总是需要它。然后,我们声明color 为一个THREE.js Color。

    -

    接下来,我们需要一个顶点着色器。对于后期处理,此处显示的顶点着色器几乎是标准的,几乎不需要更改。变量uv没有进入太多细节(见上面链接文章),projectionMatrixmodelViewMatrixposition都奇迹般地被three.js所增加。

    -

    最后,我们创建一个片段着色器。在此行中,我们从上一个pass获得了该行的像素颜色

    -
    vec4 previousPassColor =  texture2D(tDiffuse,vUv);
    -
    -

    我们用我们的颜色乘它然后设置gl_FragColor为计算的结果

    -
    gl_FragColor = vec4(
    -    previousPassColor.rgb * color,
    -    previousPassColor.a);
    -
    -

    添加一些简单的GUI来设置颜色的3个值

    -
    const gui = new GUI();
    -gui.add(colorPass.uniforms.color.value, 'r', 0, 4).name('red');
    -gui.add(colorPass.uniforms.color.value, 'g', 0, 4).name('green');
    -gui.add(colorPass.uniforms.color.value, 'b', 0, 4).name('blue');
    -
    -

    上述代码帮我们做了一个简单的后处理效果,乘以一种颜色

    -

    - -

    -

    如前所述,对于这些文章来说,要讲述如何编写GLSL和自定义着色器的所有细节太多了。如果您真的想知道WebGL本身是如何工作的,请查看这些文章。另一个很棒的资源是read through the existing post processing shaders in the THREE.js repo。有些是较为复杂的,但是如果你从简单开始,你会大概率能够了解它们是如何工作的。

    -

    不幸的是,THREE.js存储库中的大多数后期处理效果都没有记录在案,因此要使用它们,你必须通读示例或者源码。希望这些简单的示例以及有关render targets的文章 提供足够的上下文来帮助你开始

    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/prerequisites.html b/manual/zh/prerequisites.html deleted file mode 100644 index 191840381c79cc..00000000000000 --- a/manual/zh/prerequisites.html +++ /dev/null @@ -1,266 +0,0 @@ - - - 先决条件 - - - - - - - - - - - - - - -
    -
    -

    先决条件

    -
    -
    -
    -

    这些文章意在帮助你学习如何使用three.js。 -假设你知道怎么使用JavaScript编程。假设 -你知道DOM是什么,怎么写HTML以及使用JavaScript创建 -DOM元素。假设你知道如何使用 <script>标签来 -引用外部的JavaScript文件以及行内脚本。 -假设你了解CSS并且知道 -CSS选择器. -还假设你了解ES5、 ES6或者一些ES7。 -假设你知道浏览器通过事件和回调函数来运行JavaScript。 -假设你知道什么是闭包。

    -

    这有一些简单的复习和笔记。

    -

    document.querySelector and document.querySelectorAll

    -

    你可以使用document.querySelector来选择和CSS选择器 -匹配的第一个元素。 document.querySelectorAll返回 -所有和CSS选择器匹配的元素。

    -

    You don't need onbody

    -

    很多20年前的页面像这样使用HTML

    -
    <body onload="somefunction()">
    -

    这种风格已经被弃用了。将你的脚本放在 -页面的底部。

    -
    <html>
    -  <head>
    -    ...
    -  </head>
    -  <body>
    -     ...
    -  </body>
    -  <script>
    -    // inline javascript
    -  </script>
    -</html>
    -
    -

    or use the defer property.

    -

    了解闭包如何工作

    -
    function a(v) {
    -  const foo = v;
    -  return function() {
    -     return foo;
    -  };
    -}
    -
    -const f = a(123);
    -const g = a(456);
    -console.log(f());  // prints 123
    -console.log(g());  // prints 456
    -
    -

    在上面的代码中函数a每次被调用都会创建一个新的函数。新函数 -会封存变量foo. 这里有 更多信息.

    -

    理解this的工作原理

    -

    this并不是什么魔法。它实际上像是一个像被自动传给函数的参数一样的变量。 -简单的说就是像这样直接调用函数

    -
    somefunction(a, b, c);
    -

    this将会为null (使用严格模式时) 当你使用.操作符像这样调用函数时

    -
    someobject.somefunction(a, b, c);
    -

    this将会被设置为someobject

    -

    令人困惑的部分是使用回调函数。

    -
     const callback = someobject.somefunction;
    - loader.load(callback);
    -

    并没有像不熟悉的所期望的那样工作,因为当 -loader.load调用回调函数时并没有使用.操作符。 -所以默认this将会为null (除非loader明确将他设置为某些东西)。 -如果你希望thissomeobject当回调函数执行时你需要 -通过将this绑定到函数来告诉JavaScript。

    -
     const callback = someobject.somefunction.bind(someobject);
    - loader.load(callback);
    -

    this article might help explain this.

    -

    ES5/ES6/ES7 特性

    -

    var已经被弃用,使用constlet

    -

    没有任何理由再使用var,基于此使用它被认为是 -坏习惯。大所数时间如果变量不会被重新分配使用const。 -变量会改变的情况下使用let。这将会帮助避免大量bug。

    -

    使用for(elem of collection)而不是for(elem in collection)

    -

    for of是新的,for in是旧的。 for of解决了for in的问题。

    -

    举个例子,你可以像这样迭代一个对象的所有键/值对

    -
    for (const [key, value] of Object.entries(someObject)) {
    -  console.log(key, value);
    -}
    -
    -

    使用 forEach, map, 和 filter

    -

    数组新增的函数forEach、 -map和 -filter -在现代JavaScript中使用都是相当广泛的。

    -

    使用解构赋值

    -

    假设有一个对象const dims = {width: 300, height: 150}

    -

    老的代码

    -
     const width = dims.width;
    - const height = dims.height;
    -

    新代码

    -
     const {width, height} = dims;
    -

    使用对象声明简写

    -

    老的代码

    -
     const width = 300;
    - const height = 150;
    - const obj = {
    -   width: width,
    -   height: height,
    -   area: function() {
    -     return this.width * this.height
    -   },
    - };
    -
    -

    新代码

    -
     const width = 300;
    - const height = 150;
    - const obj = {
    -   width,
    -   height,
    -   area() {
    -     return this.width * this.height;
    -   },
    - };
    -
    -

    使用扩展运算符...

    -

    扩展运算符有大量的用途。例如

    -
     function log(className, ...args) {
    -   const elem = document.createElement('div');
    -   elem.className = className;
    -   elem.textContent = [...args].join(' ');
    -   document.body.appendChild(elem);
    - }
    -
    -

    另一个例子

    -
    const position = [1, 2, 3];
    -somemesh.position.set(...position);
    -
    -

    使用class

    -

    大多数人都不熟悉在ES5之前生成类对象的语法。 -ES5之后你现在可以使用class -关键字 -接近于C++/C#/Java的语法。

    -

    理解 getters 和 setters

    -

    Getters和 -setters是 -在大多数现代语言中常见的。ES6class语法 -让他们比ES6之前的更容易。

    -

    合理使用箭头函数

    -

    回调函数和promise使用箭头函数非常有用

    -
    loader.load((texture) => {
    -  // use textrue
    -});
    -
    -

    箭头函数会绑定this,它是下面的简写

    -
    (function(args) {/* code */}).bind(this))
    -
    -

    Promises 以及 async/await

    -

    Promises改善异步代码的处理。Async/await改善 -promise的使用。

    -

    这是一个需要深入了解的话题你可以在这里 -细读promises -和async/await.

    -

    使用模板字符串

    -

    模板字符串是使用反引号代替引号的字符串。

    -
    const foo = `this is a template literal`;
    -

    模板字符串有两个基本的特点。一个是它可以多行

    -
    const foo = `this
    -is
    -a
    -template
    -literal`;
    -const bar = "this\nis\na\ntemplate\nliteral";
    -
    -

    上面的foobar是一样的。

    -

    另一个是你可以超越字符串模式 -使用${javascript表达式}插入JavaScript代码段。这是模板部分。比如:

    -
    const r = 192;
    -const g = 255;
    -const b = 64;
    -const rgbCSSColor = `rgb(${r},${g},${b})`;
    -
    -

    or

    -
    const color = [192, 255, 64];
    -const rgbCSSColor = `rgb(${color.join(',')})`;
    -
    -

    or

    -
    const aWidth = 10;
    -const bWidth = 20;
    -someElement.style.width = `${aWidth + bWidth}px`;
    -
    -

    学习JavaScript代码风格。

    -

    尽管欢迎您以任何方式组织您的代码,但至少有一个约定您应该知道。 -在JavaScript中变量、函数名、方法名 -都是小驼峰。构造函数、类名都是 -大驼峰。如果你遵守这些约定你的diamagnetic将会和大部分JavaScript匹配。

    -

    考虑使用Visual Studio Code

    -

    当然你想想用什么编辑器就用什么但是如果你没尝试过它那就考虑下 -使用Visual Studio Code来写JavaScript。 -安装完之后设置 -eslint。 -可能会花几分钟来设置但是会对你寻找JavaScript的bug有极大的帮助。

    -

    一些例子

    -

    如果你开启no-undef规则然后 -VSCode通过ESLint将会警告你很多没有定义的变量。

    -
    - -

    上面你可以看到我将doTheThing误写成了doThing。有一个红色的曲线 -在doThing下面并且鼠标悬停会提醒我们它未定义。这样就避免了一个错误。

    -

    使用THREE会得到警告所以将/* global THREE */放在你的 -JavaScript文件的顶部来告诉eslintTHREE的存在。

    -
    - -

    上面你可以看到eslint知道使用UpperCaseNames规则的是构造函数 -所以你应该使用new操作符。另一个错误被捕捉并避免了。这是the -new-cap规则

    -

    这里有100多条规则你可以打开或者关闭或者自定义 -。比如上面我提醒你 -应该使用constlet代替var

    -

    这里我使用了var它警告我应该使用let或者const

    -
    - -

    这里我是用了let但是它发现我一直没改变值所以建议我使const

    -
    - -

    当然如果你更希望继续使用var你只要关闭那条规则。 -如我上面所说所以我更喜欢使用constlet而不是var因为 -他们工作的更好而且能减少bugs。

    -

    对于确实需要重写这些规则的情况你可以添加注释 -来禁用 -他们 -通过单行或者一段代码。

    -

    如果你确实需要支持老的浏览器请使用编译器

    -

    大多数现代浏览器都是自动更新的,所以使用这些新的特性会帮助你提高效率 -和避免bug。意思是说,如果你正在做一个需要支持老的浏览器的项目, -有工具会把ES5/ES6/ES7代码 -转换成ES5之前的Javascript.

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/primitives.html b/manual/zh/primitives.html deleted file mode 100644 index 53be42ab96a9fe..00000000000000 --- a/manual/zh/primitives.html +++ /dev/null @@ -1,304 +0,0 @@ - - - 图元 - - - - - - - - - - - - - - -
    -
    -

    图元

    -
    -
    -
    -

    这篇文章是关于 Three.js 系列文章中的一篇。第一篇是 基础。 -如果你还没有阅读,建议从那里开始。

    -

    Three.js 有很多图元。图元就是一些 3D 的形状,在运行时根据大量参数生成。

    -

    使用图元是种很常见的做法,像使用球体作为地球,或者使用大量盒子来绘制 3D 图形。 -尤其是用来试验或者刚开始学习 3D。 -对大多数 3D 应用来说,更常见的做法是让美术在 3D 建模软件中创建 3D 模型, -像 BlenderMaya -或者 Cinema 4D。 -之后在这个系列中,我们会涵盖到创建和加载来自 3D 建模软件的模型。 -现在,让我们仅使用可以获得的图元。

    -

    下面的很多图元都有默认的部分或者全部参数,所以可以根据你的需要选择使用。

    -
    盒子
    -
    平面圆
    -
    锥形
    -
    圆柱
    -
    十二面体
    -
    受挤压的 2D 形状,及可选的斜切。 -这里我们挤压了一个心型。注意,这分别是 TextGeometryTextGeometry 的基础。
    -
    二十面体
    -
    绕着一条线旋转形成的形状。例如:灯泡、保龄球瓶、蜡烛、蜡烛台、酒瓶、玻璃杯等。你提供一系列点作为 2D 轮廓,并告诉 Three.js 沿着某条轴旋转时需要将侧面分成多少块。
    -
    八面体
    -
    通过提供一个函数(将网格中 2D 的点转成对应的 3D 点)生成的表面。
    -
    2D 平面
    -
    将一些环绕着中心点的三角形投影到球体上
    -
    中间有洞的 2D 圆盘
    -
    2D 的三角轮廓
    -
    球体
    -
    四面体
    -
    根据 3D 字体和字符串生成的 3D 文字
    -
    圆环体(甜甜圈)
    -
    环形节
    -
    圆环沿着路径
    -
    一个工具对象,将一个几何体作为输入,生成面夹角大于某个阈值的那条边。例如,你从顶上看一个盒子,你会看到有一条线穿过这个面,因为每个组成这个盒子的三角形都显示出来了。而如果使用 EdgesGeometry 中间的线就会被移除。调整下面的 thresholdAngle,你就会看到夹角小于这个值的边消失了。
    -
    对于给定的几何体,生成每个边包含一个线段(2 个点)的几何体。如果不这样,通常缺边或者多边,因为 WebGL 中每条边通常需要 2 个点。例如,如果你只有一个三角形,就只有 3 个点 。如果你用 wireframe: true 的材质来绘制它,你只能得到一条线。将这个三角形几何体传给 WireframeGeometry 就能生成一个新的几何体,这个几何体用 6 个点组成 3 条线段。
    - -

    你可能发现上面的大部分中,GeometryBufferGeometry 是成对出现的。 -这两种类型的区别是高效灵活 vs 性能。

    -

    基于 BufferGeometry 的图元是面向性能的类型。 -几何体的顶点是直接生成为一个高效的类型数组形式,可以被上传到 GPU 进行渲染。 -这意味着它们能更快的启动,占用更少的内存。但如果想修改数据,就需要复杂的编程。

    -

    基于 Geometry 的图元更灵活、更易修改。 -它们根据 JavaScript 的类而来,像 Vector3 是 3D 的点,Face3 是三角形。 -它们需要更多的内存,在能够被渲染前,Three.js 会将它们转换成相应的 BufferGeometry 表现形式。

    -

    如果你知道你不会操作图元,或者你擅长使用数学来操作它们,那么最好使用基于 BufferGeometry 的图元。 -但如果你想在渲染前修改一些东西,那么 Geometry 的图元会更好操作。

    -

    举个简单的例子,BufferGeometry 不能轻松的添加新的顶点。 -使用顶点的数量在创建时就定好了,相应的创建存储,填充顶点数据。 -但用 Geometry 你就能随时添加顶点。

    -

    我们会在 另一篇文章 中来讲创建自定义几何体。 -现在,我们来为创建每一个图元作为例子。 -我们从 上一篇文章的例子 开始。

    -

    在接近顶部的地方,先设置背景颜色:

    -
    const scene = new THREE.Scene();
    -+scene.background = new THREE.Color(0xAAAAAA);
    -
    -

    这告诉 Three.js 清除并设置成略浅的灰色。

    -

    需要改变摄像机的位置,这样我们能看到所有物体。

    -
    -const fov = 75;
    -+const fov = 40;
    -const aspect = 2;  // canvas 默认
    -const near = 0.1;
    --const far = 5;
    -+const far = 1000;
    -const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    --camera.position.z = 2;
    -+camera.position.z = 120;
    -
    -

    添加一个函数,addObject,传入位置 x、y 和一个 Object3D,将物体添加到场景中:

    -
    const objects = [];
    -const spread = 15;
    -
    -function addObject(x, y, obj) {
    -  obj.position.x = x * spread;
    -  obj.position.y = y * spread;
    -
    -  scene.add(obj);
    -  objects.push(obj);
    -}
    -
    -

    同时,也创建一个函数,用于生成随机颜色的材质。 -我们会使用 Color 的一个特性,让你可以基于色调、饱和度、亮度来设置颜色。

    -

    在色轮上,hue 值从 0 到 1,红色在 0 的位置,绿色在 .33 的位置,蓝色在 .66 的位置。 -saturation 值从 0 到 1,0 表示没有颜色,1 表示饱和度最高。 -luminance 值从 0 到 1,0 表示黑色,1 表示白色,0.5 表示最大数量的颜色。 -换句说话,luminance 从 0 到 0.5 表示颜色从黑到 hue,从 0.5 到 1.0 表示颜色从 hue 到白。

    -
    function createMaterial() {
    -  const material = new THREE.MeshPhongMaterial({
    -    side: THREE.DoubleSide,
    -  });
    -
    -  const hue = Math.random();
    -  const saturation = 1;
    -  const luminance = .5;
    -  material.color.setHSL(hue, saturation, luminance);
    -
    -  return material;
    -}
    -
    -

    同时,我们将 side: THREE.DoubleSide 传给材质。这告诉 Three.js 绘制组成形状的三角形的两个面。 -对于实心的形状,像球体或立方体,通常不需要绘制三角形的背面,因为它们全部朝向内部。 -对于我们的情况,我们会绘制一些像 PlaneGeometryShapeGeometry 这样的二维图形,没有内部, -如果不设置 side: THREE.DoubleSide,当从反面看时它们会消失。

    -

    需要注意的是,如果 设置 side: THREE.DoubleSide 绘制会更快,所以最好只在需要的时候设置它。 -但现在我们不会绘制很多图形,所以没有必要太担心。

    -

    接着,创建一个函数,addSolidGeometry,我们传入一个几何体, -它通过 createMaterial 创建一个随机颜色的材质,通过 addObject 添加到场景中。

    -
    function addSolidGeometry(x, y, geometry) {
    -  const mesh = new THREE.Mesh(geometry, createMaterial());
    -  addObject(x, y, mesh);
    -}
    -
    -

    现在,我们可以对我们创建的大多数图元使用它。 -比如创建一个盒子:

    -
    {
    -  const width = 8;
    -  const height = 8;
    -  const depth = 8;
    -  addSolidGeometry(-2, -2, new THREE.BoxGeometry(width, height, depth));
    -}
    -
    -

    如果你查看下面的代码,你会看到每个类型的几何体有相似的部分。

    -

    这是结果:

    -

    - -

    -

    上面的模式有一些值得注意的例外。最大的可能就是 TextGeometry。在为文字生成网格前需要先加载 3D 字体数据。 -数据的加载是异步的,所以在尝试创建几何体前需要等待。通过将字体加载 Promise 化,我们可以让这个过程更简单。 -我们创建一个 FontLoader,然后 loadFont 函数返回一个 promisepromiseresolve 会给我们字体。 -接着我们创建一个 async 函数 doit,使用 await 加载字体。最后创建几何体,调用 addOjbect 将它添加到场景中。

    -
    {
    -  const loader = new FontLoader();
    -  // 将字体加载过程 promise 化
    -  function loadFont(url) {
    -    return new Promise((resolve, reject) => {
    -      loader.load(url, resolve, undefined, reject);
    -    });
    -  }
    -
    -  async function doit() {
    -    const font = await loadFont('resources/threejs/fonts/helvetiker_regular.typeface.json');  /* threejs.org: url */
    -    const geometry = new TextGeometry('three.js', {
    -      font: font,
    -      size: 3.0,
    -      height: .2,
    -      curveSegments: 12,
    -      bevelEnabled: true,
    -      bevelThickness: 0.15,
    -      bevelSize: .3,
    -      bevelSegments: 5,
    -    });
    -    const mesh = new THREE.Mesh(geometry, createMaterial());
    -    geometry.computeBoundingBox();
    -    geometry.boundingBox.getCenter(mesh.position).multiplyScalar(-1);
    -
    -    const parent = new THREE.Object3D();
    -    parent.add(mesh);
    -
    -    addObject(-1, -1, parent);
    -  }
    -  doit();
    -}
    -
    -

    还有一个其它的区别。我们想让文字绕着它的中心旋转,但默认的,Three.js 创建的文字的旋转中心在左边。 -变通的方法是要求 Three.js 计算几何体的边界框。然后我们可以对边界框调用 getCenter,将网格位置对象传给它。 -getCenter 将盒子的中心值复制进位置对象。 -同时它也返回位置对象,这样我们就可以调用 multiplyScalar(-1) 来放置整个对象,这样对象的旋转中心就是对象的中心了。

    -

    如果我们像之前的例子一样接着调用 addSolidGeometry,它又会设置位置,这是不对的。 -在我们的例子中,我们创建了一个 Object3D 是 Three.js 场景图中的标准节点。 -Mesh 也是继承自 Object3D 的。我们会在 另一篇文章中涉及场景图是如何工作的。 -现在知道它们像 DOM 的节点就行了,子节点是相对与父节点绘制的。 -创建一个 Object3D,并将网格设置成它的子节点,我们就能将 Object3D 放置在任何位置,并保持我们之前设置的中心。

    -

    如果不这么做,文字会偏离中心。

    -

    - -

    -

    注意,左边的没有绕着中心旋转,而右边的绕着中心旋转。

    -

    其它的异常情况是,有 2 个线的例子,EdgesGeometryWireframeGeometry。 -它们调用 addLineGeometry 而不是 addSolidGeometry,看起来像这样:

    -
    function addLineGeometry(x, y, geometry) {
    -  const material = new THREE.LineBasicMaterial({color: 0x000000});
    -  const mesh = new THREE.LineSegments(geometry, material);
    -  addObject(x, y, mesh);
    -}
    -
    -

    上面代码创建了一个黑色的 LineBasicMaterial,然后创建了一个 LineSegments 对象,它封装了 Mesh, -好让 Three.js 知道你在渲染一个线段(每个段 2 个点)。

    -

    每个图元都有多个参数可以在创建时传入,最好 看文档 而不是在这里重复它们。 -你也可以点击上面每个形状边上的链接,查看对应的文档。

    -

    有一对类并不和上面的模式匹配。它们是 PointsMaterialPoints。 -PointsLineSegments 类似,它需要一个 Geometry 或者 BufferGeometry,但每个顶点都绘制一次,而不是每条线。 -要使用,你需要传入 PointsMaterial,它需要一个代表点多大的 size

    -
    const radius = 7;
    -const widthSegments = 12;
    -const heightSegments = 8;
    -const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
    -const material = new THREE.PointsMaterial({
    -    color: 'red',
    -    size: 0.2,     // in world units
    -});
    -const points = new THREE.Points(geometry, material);
    -scene.add(points);
    -
    -
    -
    -
    - -

    如果你想让点无视和摄像机的距离,始终保持相同大小,可以通过将 sizeAttenuation 设置成 false 将其关闭。

    -
    const material = new THREE.PointsMaterial({
    -    color: 'red',
    -+    sizeAttenuation: false,
    -+    size: 3,       // in pixels
    --    size: 0.2,     // in world units
    -});
    -...
    -
    -
    -
    -
    - -

    还有一个重要的东西,就是所有形状都有多个设置来设置它们的细化程度。 -一个很好的例子就是球形几何体。它可以这些参数:一圈组成的片数、从上到下的数量等。例如:

    -
    -
    -
    -
    -
    - -

    第一个球体一圈有 5 分片,高度为 3,一共 15 片,或者 30 个三角形。 -第二个球体一圈有 24 分片,高度为 10,一共 240 片,或者 480 个三角形。 -第三个球体一圈有 50 分片,高度为 50,一共 2500 片,或者 5000 个三角形。

    -

    由你决定需要细分成多少。看起来你可能需要较多数量的分片,但去除线,设置平面着色,我们就得到了:

    -
    -
    -
    -
    -
    - -

    现在并不明显是否右边有 5000 个三角形的比中间只有 480 个三角形的好更多。 -如果你只是绘制少量球体,比如一个地球地图的球体,那么单个 10000 个三角形的球体就是个不错的选择。 -但如果你要画 1000 个球体,那么 1000 个球体 x 10000 个三角形就是一千万个三角形。 -想要动画流畅,你需要浏览器每秒绘制 60 帧,那么上面的场景就需要每秒绘制 6 亿个三角形。那是巨大的运算量。

    -

    有时候很容易选择。例如你可以选择将平面细分。

    -
    -
    -
    -
    - -

    左边的平面有 2 个三角形,右边的平面有 200 个三角形。不像球体,在多数平面的应用场景中,并没有什么折中的方法。 -你可能只在你想要修改或者在某些方面封装一下的时候才将平面细分。对于盒子也是一样。

    -

    所以,选择适合你情况的方案。细分的越少,运行的越流畅,使用的内存也会更少。 -你需要根据你的具体情况选择合适的方案。

    -

    如果上面的形状不符合你的使用需求,你可以从 .obj 文件.gltf 文件 加载几何体。 -你也可以创建 自定义 Geometry

    -

    接下来是 Three.js 的场景图是如何工作的及如何使用它

    -

    - - - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/rendering-on-demand.html b/manual/zh/rendering-on-demand.html deleted file mode 100644 index 22f060e7e9e45e..00000000000000 --- a/manual/zh/rendering-on-demand.html +++ /dev/null @@ -1,200 +0,0 @@ - - - 按需渲染 - - - - - - - - - - - - - - -
    -
    -

    按需渲染

    -
    -
    -
    -

    这一章的主题即便已经很明显了, 但... 以防万一, 还是说大多数情况下three.js给出的例子都是连续渲染的. 换言之他们使用了requestAnimationFrame循环或者写成rAF loop

    -
    function render() {
    -  ...
    -  requestAnimationFrame(render);
    -}
    -requestAnimationFrame(render);
    -
    -

    有些场景连续渲染是有意义的, 但是有些情况下不需要一直动呢? 这种情况下不断地渲染会浪费电, 对于移动设备来说属实不能接受.

    -

    显而易见的解决方法是一开始的时候渲染一次, 只有当什么东西改变了以后再次渲染. 这种改变包括纹理的变化, 或者再入了模型, 其他源传来了什么数据, 用户调整了设置或者是动了摄像机.

    -

    我们以响应式设计这一章为例, 稍作修改以满足按需渲染.

    -

    首先我们添加OrbitControls, 这样当摄像机改变之后场景就可以随之渲染

    -

    First we'll add in the OrbitControls so there is something that could change -that we can render in response to.

    -
    import * as THREE from 'three';
    -+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -
    -

    然后

    -
    const fov = 75;
    -const aspect = 2;  // the canvas default
    -const near = 0.1;
    -const far = 5;
    -const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -camera.position.z = 2;
    -
    -+const controls = new OrbitControls(camera, canvas);
    -+controls.target.set(0, 0, 0);
    -+controls.update();
    -
    -

    我们不需要再渲染那三个正方体了所以不再追踪

    -
    -const cubes = [
    --  makeInstance(geometry, 0x44aa88,  0),
    --  makeInstance(geometry, 0x8844aa, -2),
    --  makeInstance(geometry, 0xaa8844,  2),
    --];
    -+makeInstance(geometry, 0x44aa88,  0);
    -+makeInstance(geometry, 0x8844aa, -2);
    -+makeInstance(geometry, 0xaa8844,  2);
    -
    -

    把这些代码移除, 然后调用requestAnimationFrame

    -
    -function render(time) {
    --  time *= 0.001;
    -+function render() {
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -  }
    -
    --  cubes.forEach((cube, ndx) => {
    --    const speed = 1 + ndx * .1;
    --    const rot = time * speed;
    --    cube.rotation.x = rot;
    --    cube.rotation.y = rot;
    --  });
    -
    -  renderer.render(scene, camera);
    -
    --  requestAnimationFrame(render);
    -}
    -
    --requestAnimationFrame(render);
    -
    -

    我们这次只需要渲染一次

    -
    render();
    -
    -

    我们需要在OrbitControls改变摄像机设置的时候渲染场景. -幸好OrbitControls提供了一个change事件来监听变化

    -
    controls.addEventListener('change', render);
    -
    -

    我们同样需要捕捉到用户改变窗口大小的情况. 在之前连续渲染的时候这种情况是自动处理的, 但是现在是按需渲染, 我们需要在窗口改变的时候显式resize窗口大小

    -
    window.addEventListener('resize', render);
    -
    -

    然后我们就实现了按需渲染的功能

    -

    - - -OrbitControls 有个选项可以增加某种惯性, 让整个画面显得不那么僵硬. 我们启用enableDamping来实现它

    -
    controls.enableDamping = true;
    -
    -

    开启enableDamping, 我们需要在渲染函数中调用controls.update, 让OrbitControls可以丝滑地让摄像机移动. 但是, 这就意味着我们不能直接地在change事件中调用render, 如此这般会导致死循环. 控制器响应一个change事件然后调用render, 然后render调用controls.update. 这个方法会再发出另一个change事件.

    -

    我们可以通过使用requestAnimationFrame调用render, 但是需要确保仅仅在需要一个新帧的时候才执行. 如果没有请求

    -

    我们可以通过使用requestAnimationFrame调用render来解决这个问题, 但是我们需要确保我们只在还没有请求一个新帧的情况下请求一个新帧, 我们可以通过一个变量来跟踪我们已经请求的帧

    -
    +let renderRequested = false;
    -
    -function render() {
    -+  renderRequested = false;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -  renderer.render(scene, camera);
    -}
    -render();
    -
    -+function requestRenderIfNotRequested() {
    -+  if (!renderRequested) {
    -+    renderRequested = true;
    -+    requestAnimationFrame(render);
    -+  }
    -+}
    -
    --controls.addEventListener('change', render);
    -+controls.addEventListener('change', requestRenderIfNotRequested);
    -
    -

    我们应该会在改变窗口大小的时候用到requestRenderIfNotRequested

    -
    -window.addEventListener('resize', render);
    -+window.addEventListener('resize', requestRenderIfNotRequested);
    -
    -

    可能很难看出来有什么不同. 试着点一下下面的例子, 然后用方向键移动, 或者拖拽旋转. 然后在上面的例子中做同样的事, 你应该能感觉出来区别. 上面的像是一帧帧在放幻灯片, 下面则是丝滑柔顺.

    -

    - -

    -

    让我们加一个简单的GUI

    -
    import * as THREE from 'three';
    -import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -+import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
    -
    -

    这个控制器可以改变每个立方体的颜色和在x方向缩放. 为了设置颜色我们用了ColorGUIHelper, 这个在光线一章提到过

    -
    const gui = new GUI();
    -
    -

    对每一个立方体, 我们建一个折叠菜单, 一个是material.color, 另一个是cube.scale.x

    -
    function makeInstance(geometry, color, x) {
    -  const material = new THREE.MeshPhongMaterial({color});
    -
    -  const cube = new THREE.Mesh(geometry, material);
    -  scene.add(cube);
    -
    -  cube.position.x = x;
    -
    -+  const folder = gui.addFolder(`Cube${x}`);
    -+  folder.addColor(new ColorGUIHelper(material, 'color'), 'value')
    -+      .name('color')
    -+      .onChange(requestRenderIfNotRequested);
    -+  folder.add(cube.scale, 'x', .1, 1.5)
    -+      .name('scale x')
    -+      .onChange(requestRenderIfNotRequested);
    -+  folder.open();
    -
    -  return cube;
    -}
    -
    -

    上面的GUI用了一个onChange方法, 在数值改变的时候调用传入一个回调函数. 这个例子中, 我们仅仅需要它调用requestRenderIfNotRequested. folder.open是使折叠菜单展开的方法

    -

    - -

    -

    我希望这篇文章能在将连续渲染改成按需渲染的时候给你一些启发. 按需渲染不像是连续渲染那么常见, 因为3D游戏或者艺术创作中必须要让场景动出来. 但是有些场合, 例如地图浏览器, 3D编辑器, 3D图产生器等等的, 可能还是按需渲染比较好.

    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/rendertargets.html b/manual/zh/rendertargets.html deleted file mode 100644 index f10f30ffa5acee..00000000000000 --- a/manual/zh/rendertargets.html +++ /dev/null @@ -1,162 +0,0 @@ - - - 渲染目标 - - - - - - - - - - - - - - -
    -
    -

    渲染目标

    -
    -
    -
    -

    在three.js中,渲染目标大体上指的是可以被渲染的纹理。当它被渲染之后,你可以像使用其他纹理一样使用它。

    -

    让我们举个简单的例子。我们将从the article on responsiveness开始。

    -

    渲染到渲染目标基本上跟通常的渲染一样。首先我们创建一个 WebGLRenderTarget

    -
    const rtWidth = 512;
    -const rtHeight = 512;
    -const renderTarget = new THREE.WebGLRenderTarget(rtWidth, rtHeight);
    -
    -

    然后我们需要一个 Camera 和一个 Scene

    -
    const rtFov = 75;
    -const rtAspect = rtWidth / rtHeight;
    -const rtNear = 0.1;
    -const rtFar = 5;
    -const rtCamera = new THREE.PerspectiveCamera(rtFov, rtAspect, rtNear, rtFar);
    -rtCamera.position.z = 2;
    -
    -const rtScene = new THREE.Scene();
    -rtScene.background = new THREE.Color('red');
    -
    -

    注意我们设置长宽比(aspect)是相对渲染目标而言的,不是画布(canvas)。 -正确的长宽比取决于我们要渲染的对象。在本例,我们要将渲染目标的纹理用在方块的一个面,基于方块的面我们设置长宽比为1.0。

    -

    我们将需要的东西添加到场景中。在本例我们使用灯光和三个方块from the previous article

    -
    {
    -  const color = 0xFFFFFF;
    -  const intensity = 1;
    -  const light = new THREE.DirectionalLight(color, intensity);
    -  light.position.set(-1, 2, 4);
    -*  rtScene.add(light);
    -}
    -
    -const boxWidth = 1;
    -const boxHeight = 1;
    -const boxDepth = 1;
    -const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    -
    -function makeInstance(geometry, color, x) {
    -  const material = new THREE.MeshPhongMaterial({color});
    -
    -  const cube = new THREE.Mesh(geometry, material);
    -*  rtScene.add(cube);
    -
    -  cube.position.x = x;
    -
    -  return cube;
    -}
    -
    -*const rtCubes = [
    -  makeInstance(geometry, 0x44aa88,  0),
    -  makeInstance(geometry, 0x8844aa, -2),
    -  makeInstance(geometry, 0xaa8844,  2),
    -];
    -
    -

    在上个例子中的 SceneCamera 保持不变,我们将在画布中继续使用它们,只需要添加渲染的物体。

    -

    让我们添加使用了渲染目标纹理的方块。

    -
    const material = new THREE.MeshPhongMaterial({
    -  map: renderTarget.texture,
    -});
    -const cube = new THREE.Mesh(geometry, material);
    -scene.add(cube);
    -
    -

    现在在渲染的时候,我们首先将渲染目标的场景(rtScene),渲染到渲染目标(注:这里有点绕,需要结合代码理解)。

    -
    function render(time) {
    -  time *= 0.001;
    -
    -  ...
    -
    -  // rotate all the cubes in the render target scene
    -  rtCubes.forEach((cube, ndx) => {
    -    const speed = 1 + ndx * .1;
    -    const rot = time * speed;
    -    cube.rotation.x = rot;
    -    cube.rotation.y = rot;
    -  });
    -
    -  // draw render target scene to render target
    -  renderer.setRenderTarget(renderTarget);
    -  renderer.render(rtScene, rtCamera);
    -  renderer.setRenderTarget(null);
    -
    -

    然后我们在画布中,渲染使用了渲染目标纹理的方块的场景。

    -
      // rotate the cube in the scene
    -  cube.rotation.x = time;
    -  cube.rotation.y = time * 1.1;
    -
    -  // render the scene to the canvas
    -  renderer.render(scene, camera);
    -
    -

    就是这样啦

    -

    - -

    -

    方块是红色的,这是因为我们设置了 rtScenebackground 为红色,所以渲染目标的纹理所处的背景为红色。

    -

    渲染目标可以用在各种各样的物体上。Shadows用了渲染目标,Picking can use a render target,多种效果post processing effects需要用到渲染目标。 -渲染汽车的后视镜,或者3D场景中的监控实时画面,都可能用到渲染目标。

    -

    关于 WebGLRenderTarget 的笔记。

    -
      -
    • 默认情况下 WebGLRenderTarget 会创建两个纹理。 颜色纹理和深度/模版纹理。如果你不需要深度或者模版纹理,你可以通过可选设置取消创建。例如:

      -
        const rt = new THREE.WebGLRenderTarget(width, height, {
      -    depthBuffer: false,
      -    stencilBuffer: false,
      -  });
      -
      -
    • -
    • 你可能需要改变渲染目标的尺寸

      -

      在上面的例子,我们创建了固定尺寸512X512的渲染目标。对于像后处理,你通常需要创建跟画布一样尺寸的渲染目标。在我们的代码中意味着,当我们改变画布的尺寸,会同时更新渲染目标尺寸,和渲染目标中正在使用的摄像机。例如:

      -
      function render(time) {
      -  time *= 0.001;
      -
      -  if (resizeRendererToDisplaySize(renderer)) {
      -    const canvas = renderer.domElement;
      -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
      -    camera.updateProjectionMatrix();
      -
      -+    renderTarget.setSize(canvas.width, canvas.height);
      -+    rtCamera.aspect = camera.aspect;
      -+    rtCamera.updateProjectionMatrix();
      -}
      -
    • -
    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/responsive.html b/manual/zh/responsive.html deleted file mode 100644 index 5abfe7f88594f1..00000000000000 --- a/manual/zh/responsive.html +++ /dev/null @@ -1,260 +0,0 @@ - - - - - - 响应式设计 - - - - - - - - - - - - - - - - -
    -
    -

    响应式设计

    -
    -
    -
    -

    这是three.js系列文章的第二篇。 - 第一篇是关于基础。 - 如果你还没有阅读第一篇那你应该从第一篇开始。

    -

    本篇文章是关于如何让你的three.js应用自适应各种情况。 - 网页的响应式是指让其在桌面、平板及手机等不同尺寸的屏幕上显示良好。

    -

    对three.js来说有更多的情况要考虑。例如,我们可能需要处理控件在左侧、右侧、顶部或底部的三维编辑器。本文的中间部分展示了另一个例子。

    -

    上一个例子中我们使用了一个没有设置样式和尺寸的。

    -
    <canvas id="c"></canvas>
    -
    -

    那个canvas默认300x150像素。

    -

    在web平台推荐使用CSS来设置物体的尺寸。

    -

    我们通过添加CSS来让canvas填充整个页面。

    -
    <style>
    -html, body {
    -   margin: 0;
    -   height: 100%;
    -}
    -#c {
    -   width: 100%;
    -   height: 100%;
    -   display: block;
    -}
    -</style>
    -
    -

    HTML中的body默认有5个像素的margin值所以设置margin为0来移除margin值。 - 设置html和body的高度为100%让他们充满整个窗口。不然的话他们的大小只会 - 和填充他们的内容一样。

    -

    然后我们让id=c的元素的尺寸是容器的100%这里是body标签。

    -

    最后我们设置它的displayblock。canvas的display默认为 - inline。行内元素的末尾会有空格。 - 通过设置canvas为块级元素就能消除这个空格。 -

    -

    这里是结果。

    -

    - - -

    -

    你可以看到canvas充满了整个页面,但是有两个问题。 - 第一是我们的立方体被拉伸了。他们不是立方体了更像是个盒子,太高或者太宽。 在新标签中打开它然后改变尺寸你就能看到立方体是怎么在宽高上被拉伸的。

    -

    -

    另一个问题是立方体看起来分辨率太低或者说块状化或者有点模糊。 - 将窗口拉伸的非常大你就能看到问题。

    -

    -

    我们先解决拉伸的问题。为此我们要将相机的宽高比设置为canvas的宽高比。 - 我们可以通过canvas的clientWidthclientHeight属性来实现。

    -

    我们需要将渲染循环变成这样。

    -
    function render(time) {
    -  time *= 0.001;
    -
    -+  const canvas = renderer.domElement;
    -+  camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -+  camera.updateProjectionMatrix();
    -
    -  ...
    -
    -

    现在立方体应该不会变形了。

    -

    - - -

    -

    在新标签页中打开例子你应该能看到立方体的宽高不会再被拉伸了。 - 他们都会保持正确的比例不管窗口的尺寸如何。

    -

    -

    我们现在来解决块状化的问题。

    -

    canvas元素有两个尺寸。一个是canvas在页面上的显示尺寸, - 是我们用CSS来设置的。另一个尺寸是canvas本身像素的数量。这和图片一样。 - 比如我们有一个128x64像素的图片然后我们可以通过CSS让它显示为 - 400x200像素。

    -
    <img src="some128x64image.jpg" style="width:400px; height:200px">
    -
    -

    一个canvas的内部尺寸,它的分辨率,通常被叫做绘图缓冲区(drawingbuffer)尺寸。 - 在three.js中我们可以通过调用renderer.setSize来设置canvas的绘图缓冲区。 - 我们应该选择什么尺寸? 最显而易见的是"和canvas的显示尺寸一样"。 - 即可以直接用canvas的clientWidthclientHeight属性。

    -

    我们写一个函数来检查渲染器的canvas尺寸是不是和canvas的显示尺寸不一样 - 如果不一样就设置它。

    -
    function resizeRendererToDisplaySize(renderer) {
    -  const canvas = renderer.domElement;
    -  const width = canvas.clientWidth;
    -  const height = canvas.clientHeight;
    -  const needResize = canvas.width !== width || canvas.height !== height;
    -  if (needResize) {
    -    renderer.setSize(width, height, false);
    -  }
    -  return needResize;
    -}
    -
    -

    注意我们检查了canvas是否真的需要调整大小。 - 调整画布大小是canvas规范的一个有趣部分,如果它已经是我们想要的大小,最好不要设置相同的大小.

    -

    一旦我们知道了是否需要调整大小我们就调用renderer.setSize然后 - 传入新的宽高。在末尾传入false很重要。 - renderer.setSize默认会设置canvas的CSS尺寸但这并不是我们想要的。 - 我们希望浏览器能继续工作就像其他使用CSS来定义尺寸的其他元素。我们不希望 - three.js使用canvas和其他元素不一样。 -

    -

    注意如果我们的canvas大小被调整了那函数会返回true。我们可以利用 - 这个来检查是否有其他的东西应该更新。我们修改渲染循环 - 来使用我们的新函数。

    -
    function render(time) {
    -  time *= 0.001;
    -
    -+  if (resizeRendererToDisplaySize(renderer)) {
    -+    const canvas = renderer.domElement;
    -+    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -+    camera.updateProjectionMatrix();
    -+  }
    -
    -  ...
    -
    -

    因为只有canvas的显示尺寸变化时宽高比才变化所以我们 - 只在resizeRendererToDisplaySize函数返回true时才设置摄像机的宽高比。

    -

    - - -

    -

    现在渲染的分辨率应该是和canvas的显示尺寸一样的。

    -

    为了说清楚让CSS处理调整尺寸我们将代码放进一个单独的js文件。 - 这里还有一些例子我们让CSS决定尺寸的大小并且注意我们并没有改变任何 - 代码来让他们工作。

    -

    我们将立方体放在文字段落的中间。

    -

    - - -

    -

    这是我们在编辑器样式布局中使用的相同代码,右侧的控制区域可以调整大小。

    -

    - - -

    -

    重点注意我们的代码并没有改变,只有我们的HTML和CSS变了。

    -

    应对HD-DPI显示器

    -

    HD-DPI代表每英寸高密度点显示器(视网膜显示器)。它指的是当今大多数的Mac和windows机器以及几乎所有的智能手机。

    -

    浏览器中的工作方式是不管屏幕的分辨率有多高使用CSS像素设置尺寸会被认为是一样的。 - 同样的物理尺寸浏览器会渲染出字体的更多细节。

    -

    使用three.js有多种方法来应对HD-DPI。

    -

    第一种就是不做任何特别的事情。这可以说是最常见的。 - 渲染三维图形需要大量的GPU处理能力。移动端的GPU能力比桌面端的要弱。至少截止到2018年, - 手机都有非常高的分辨率显示器。 - 目前最好的手机的HD-DPI比例为3x,意思是非高密度点显示器上的一个像素在高密度显示器上是9个像素。 - 意味着需要9倍的渲染。

    -

    计算9倍的像素是个大工程所以如果保持代码不变我们将计算一个像素然后浏览器将以三倍大小绘制(3x3=9像素)。

    -

    对于大型的three.js应用来说上面就是你想要的否侧你的帧速率会很低。

    -

    尽管如此如果你确实想用设备的分辨率来渲染,three.js中有两种方法来实现。

    -

    一种是使用renderer.setPixelRatio来告诉three.js分辨率的倍数。 - 访问浏览器从CSS像素到设备像素的倍数然后传给three.js。

    -
     renderer.setPixelRatio(window.devicePixelRatio);
    -
    -

    之后任何对renderer.setSize的调用都会神奇地使用您请求的大小乘以您传入的像素比例. - 强烈不建议这样。 看下面。 -

    -

    另一种方法是在调整canvas的大小时自己处理。

    -
        function resizeRendererToDisplaySize(renderer) {
    -      const canvas = renderer.domElement;
    -      const pixelRatio = window.devicePixelRatio;
    -      const width = Math.floor( canvas.clientWidth * pixelRatio );
    -      const height = Math.floor( canvas.clientHeight * pixelRatio );
    -      const needResize = canvas.width !== width || canvas.height !== height;
    -      if (needResize) {
    -        renderer.setSize(width, height, false);
    -      }
    -      return needResize;
    -    }
    -
    -

    第二种方法从客观上来说更好。为什么?因为我拿到了我想要的。 - 在使用three.js时有很多种情况下我们需要知道canvas的绘图缓冲区的确切尺寸。 - 比如制作后期处理滤镜或者我们在操作着色器需要访问gl_FragCoord变量,如果我们截屏或者给GPU - 读取像素,绘制到二维的canvas等等。 - 通过我们自己处理我们会一直知道使用的尺寸是不是我们需要的。 - 幕后并没有什么特殊的魔法发生。

    -

    这是一个使用上面代码的例子。

    -

    - - -

    -

    可能很难看出区别但是如果你有一个HD-DPI显示器 - 和上面的例子做对比你就能发现边角更清晰。

    -

    这篇文章涵盖了一个非常基础但是很有必要的主题。接下来我们快速过一遍 - three.js提供的基本的东西 . -

    - -
    -
    -
    - - - - - - - - - - \ No newline at end of file diff --git a/manual/zh/scenegraph.html b/manual/zh/scenegraph.html deleted file mode 100644 index fb4acdec9c5f05..00000000000000 --- a/manual/zh/scenegraph.html +++ /dev/null @@ -1,363 +0,0 @@ - - - 场景图 - - - - - - - - - - - - - - -
    -
    -

    场景图

    -
    -
    -
    -

    本文是关于 three.js 系列文章的一部分。第一篇文章是 three.js 基础。 如果你还没有读过它,建议先从那里开始。

    -

    Three.js 的核心可以说是它的场景图(scene graph)。场景图在 3D 引擎是一个图中节点的层次结构,其中每个节点代表了一个局部空间(local space)。

    -

    -

    这有点抽象,所以我们试着举一些例子。

    -

    比如这样一个例子:太阳系、太阳、地球、月亮。

    -

    -

    地球绕着太阳转,月球绕着地球转,月球绕着地球转了一圈。从月球的角度看,它是在地球的 "局部空间 "中旋转。尽管它相对于太阳的运动是一些疯狂的像螺线图一样的曲线,但从月球的角度来看,它只需要关注自身围绕地球这个局部空间的旋转即可。

    -

    - -
    - -

    -

    换个角度想,生活在地球上的你,不用思考关于地球自转和绕太阳公转的问题。你只是走路或开车或游泳或跑步,好像地球从未移动或者旋转。你走路、开车、游泳、跑步、生活在地球这个 "局部空间",即使相对于太阳来说,你是以每小时 1000 英里的速度绕着地球旋转,并以每小时 6 万 7 千英里的速度围绕太阳旋转。你在太阳系中的位置与头上的月亮相似,但你不必担心自己的位置。你只需担心你在地球 "局部空间 "中相对于地球的位置。

    -

    让我们一步一步来吧。想象一下,我们要做一个太阳、地球和月亮的图。我们先从太阳开始,只需制作一个球体,并将其置于原点。注意:我们用太阳、地球、月亮来演示如何使用场景图。当然,真正的太阳、地球和月亮使用的是物理学,但为了我们的目的,我们将用场景图来伪造它。

    -
    // 要更新旋转角度的对象数组
    -const objects = [];
    -
    -// 一球多用
    -const radius = 1;
    -const widthSegments = 6;
    -const heightSegments = 6;
    -const sphereGeometry = new THREE.SphereGeometry(
    -  radius,
    -  widthSegments,
    -  heightSegments
    -);
    -
    -const sunMaterial = new THREE.MeshPhongMaterial({ emissive: 0xffff00 });
    -const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
    -sunMesh.scale.set(5, 5, 5); // 扩大太阳的大小
    -scene.add(sunMesh);
    -objects.push(sunMesh);
    -
    -

    我们使用的是一个低多边形球体(low-polygon sphere)。赤道周围只有 6 个分段。这是为了便于观察旋转情况。

    -

    因为我们会重用同一个球体,所以我们将太阳网格(sunMesh)的比例设置为 5x。

    -

    我们还将 phong 材质的 emissive 属性设置为黄色。phong 材质的放射属性(emissive)是基本上不受其他光照影响的固有颜色。光照会被添加到该颜色上。

    -

    我们也在场景的中心放置了一个点光源(point light)。稍后我们会介绍更多关于点光源的细节,但现在简单地说,点光源代表从一个点向各个方向发射的光源。

    -
    {
    -  const color = 0xffffff;
    -  const intensity = 500;
    -  const light = new THREE.PointLight(color, intensity);
    -  scene.add(light);
    -}
    -
    -

    为了便于观察,我们要把摄像头放在原点的正上方向下看。最简单的方法是使用 lookAt 函数。 lookAt 函数让摄像机从它的位置“看向”我们传递 lookAt 的位置。在这样做之前,我们需要告诉摄像机的顶部朝向哪个方向,或者说哪个方向是摄像机的 "上"。对于大多数情况来说,正 Y 是向上的就足够了,但是由于我们是直视下方,我们需要告诉摄像机正 Z 是向上的。

    -
    const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -camera.position.set(0, 50, 0);
    -camera.up.set(0, 0, 1);
    -camera.lookAt(0, 0, 0);
    -
    -

    在渲染循环中,根据之前的例子进行了调整,我们用这段代码旋转 objects 数组中的所有对象。

    -
    objects.forEach((obj) => {
    -  obj.rotation.y = time;
    -});
    -
    -

    由于我们将 sunMesh 添加到 objects 数组中,它将会旋转。

    -

    - -

    -

    现在让我们把地球(earth)也加进去。

    -
    const earthMaterial = new THREE.MeshPhongMaterial({
    -  color: 0x2233ff,
    -  emissive: 0x112244,
    -});
    -const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
    -earthMesh.position.x = 10;
    -scene.add(earthMesh);
    -objects.push(earthMesh);
    -
    -

    我们做的材料是蓝色的,但是我们给它加了少量的放射蓝(emissive blue),这样它就会在我们的黑色背景下显示出来。

    -

    我们使用相同的 sphereGeometry 和新的蓝色的 earthMaterial 来制作一个 earthMesh 。我们将其定位在太阳的左边 10 个单位,并将其添加到场景中。由于我们将它添加到了我们的 objects 数组中,所以它也会旋转。

    -

    - -

    -

    你可以看到太阳和地球都在自转,但地球并没有绕着太阳转。让我们把地球变成太阳的子节点吧。

    -
    -scene.add(earthMesh);
    -+sunMesh.add(earthMesh);
    -
    -

    然后...

    -

    - -

    -

    到底发生了什么?为什么地球和太阳一样大?为什么离太阳这么远?我居然要把摄像机从 50 单位移到 150 单位以上才能看到地球。

    -

    我们让 earthMesh 成为 sunMesh 的一个子节点。sunMesh.scale.set(5, 5, 5) 将其比例设置为 5x。这意味着 sunMeshs 的局部空间是 5 倍大。这表示地球现在大了 5 倍,它与太阳的距离 ( earthMesh.position.x = 10 ) 也是 5 倍。

    -

    目前,我们的场景图是这样的:

    -

    -

    为了解决这个问题,我们添加一个空的场景图节点。我们将把太阳和地球都作为该节点的子节点。

    -
    +const solarSystem = new THREE.Object3D();
    -+scene.add(solarSystem);
    -+objects.push(solarSystem);
    -
    -const sunMaterial = new THREE.MeshPhongMaterial({emissive: 0xFFFF00});
    -const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
    -sunMesh.scale.set(5, 5, 5);
    --scene.add(sunMesh);
    -+solarSystem.add(sunMesh);
    -objects.push(sunMesh);
    -
    -const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
    -const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
    -earthMesh.position.x = 10;
    --sunMesh.add(earthMesh);
    -+solarSystem.add(earthMesh);
    -objects.push(earthMesh);
    -
    -

    这里我们创建了一个 Object3D 。像 Mesh 一样,它也是场景图中的一个节点,但与 Mesh 不同的是,它没有材质(material)和几何体(geometry)。它只是代表一个局部空间。

    -

    我们的新场景图是这样的:

    -

    -

    sunMeshearthMesh 都是 solarSystem 的子网格。三者都在旋转,现在因为 earthMesh 不是 sunMesh 的子网格,所以不再按 5 倍比例缩放。

    -

    - -

    -

    好多了。地球比太阳小,而且绕着太阳转的同时自转。

    -

    延续同样的模式,我们再加一个月亮。

    -
    +const earthOrbit = new THREE.Object3D();
    -+earthOrbit.position.x = 10;
    -+solarSystem.add(earthOrbit);
    -+objects.push(earthOrbit);
    -
    -const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
    -const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
    --earthMesh.position.x = 10; // 注意,这个偏移量已经在它的父对象THREE.Object3D "earthOrbit "中设置了。
    --solarSystem.add(earthMesh);
    -+earthOrbit.add(earthMesh);
    -objects.push(earthMesh);
    -
    -+const moonOrbit = new THREE.Object3D();
    -+moonOrbit.position.x = 2;
    -+earthOrbit.add(moonOrbit);
    -
    -+const moonMaterial = new THREE.MeshPhongMaterial({color: 0x888888, emissive: 0x222222});
    -+const moonMesh = new THREE.Mesh(sphereGeometry, moonMaterial);
    -+moonMesh.scale.set(.5, .5, .5);
    -+moonOrbit.add(moonMesh);
    -+objects.push(moonMesh);
    -
    -

    我们再次添加了更多的隐形场景图节点。首先是一个名为 earthOrbitObject3D ,并将新增 earthMeshmoonOrbit 都添加到其中。然后,我们把 moonMesh 添加到 moonOrbit 上。

    -

    新的场景图是这样的:

    -

    -

    就是这样

    -

    - -

    -

    你可以看到月亮照着本文开头所提到的螺线图形式旋转,但我们不必手动计算它。我们只需要设置我们的场景图来为我们做这件事。

    -

    绘制一些东西来可视化场景图中的节点通常很有用。Three.js 有一些很有帮助的,ummmm, 帮助工具可以用来 ummmm,...帮助我们实现这个功能。

    -

    其中一个叫做 AxesHelper 。它画了 3 条线,分别代表本地的 -X, -Y, 以及 -Z轴。让我们为我们创建的每个节点都加上一个。

    -
    // 为每个节点添加一个AxesHelper
    -objects.forEach((node) => {
    -  const axes = new THREE.AxesHelper();
    -  axes.material.depthTest = false;
    -  axes.renderOrder = 1;
    -  node.add(axes);
    -});
    -
    -

    在我们的例子中,我们希望轴即使在球体内部也能出现。要做到这一点,我们将其材质(material)的 depthTest 属性设置为 false,这意味着它们不会检查其是否在其他东西后面进行绘制。我们还将它们的 renderOrder 属性设置为 1(默认值为 0),这样它们就会在所有球体之后被绘制。否则一个球体可能会画在它们上面,把它们遮住。

    -

    - -

    -

    我们可以看到x (红色) 和 -z (蓝色)轴。由于我们是直视下方,而每个物体只是围绕 y 轴旋转,所以我们看不到y (绿色)轴。

    -

    可能很难看到其中一些轴,因为有 2 对重叠的轴。sunMeshsolarSystem 都在同一位置。同样地,earthMeshearthOrbit 也在同一位置。让我们添加一些简单的控制方法,让我们可以为每个节点打开/关闭它们。同时,我们还可以添加另一个名为 GridHelper 的帮助工具。它可以在 X,Z 平面上创建一个 2D 网格。默认情况下,网格是 10x10 单位。

    -

    我们还将使用lil-gui,这是一个在 three.js 项目中非常流行的 UI 库。lil-gui 会获取一个对象和该对象上的属性名,并根据属性的类型自动生成一个 UI 来操作该属性。

    -

    我们要为每个节点制作一个 GridHelper 和一个 AxesHelper。我们需要为每个节点添加一个标签,所以我们将删除旧的循环,转而调用一些函数为每个节点添加帮助程序。

    -
    -// 为每个节点添加一个AxesHelper
    --objects.forEach((node) => {
    --  const axes = new THREE.AxesHelper();
    --  axes.material.depthTest = false;
    --  axes.renderOrder = 1;
    --  node.add(axes);
    --});
    -
    -+function makeAxisGrid(node, label, units) {
    -+  const helper = new AxisGridHelper(node, units);
    -+  gui.add(helper, 'visible').name(label);
    -+}
    -+
    -+makeAxisGrid(solarSystem, 'solarSystem', 25);
    -+makeAxisGrid(sunMesh, 'sunMesh');
    -+makeAxisGrid(earthOrbit, 'earthOrbit');
    -+makeAxisGrid(earthMesh, 'earthMesh');
    -+makeAxisGrid(moonOrbit, 'moonOrbit');
    -+makeAxisGrid(moonMesh, 'moonMesh');
    -
    -

    makeAxisGrid 创建了一个 AxisGridHelper 类,这是一个我们将创建的让 lil-gui 满意的类。就像上面说的那样,lil-gui 会自动地生成一个 UI 来操作某个对象的命名属性。它将根据属性的类型创建不同的 UI。我们希望它创建一个复选框,所以我们需要指定一个 bool 属性。但是,我们希望坐标轴和网格都能基于一个单一的属性出现/消失,所以我们将创建一个类,其有一个属性绑定了 getter 和 setter。这样我们就可以让 lil-gui 认为它在操作一个单一的属性,但是在内部我们可以为一个节点设置 AxesHelperGridHelper 的可见(visible)属性。

    -
    // 打开/关闭轴和网格的可见性
    -// lil-gui 要求一个返回类型为bool型的属性
    -// 来创建一个复选框,所以我们为 `visible`属性
    -// 绑定了一个setter 和 getter。 从而让lil-gui
    -// 去操作该属性.
    -class AxisGridHelper {
    -  constructor(node, units = 10) {
    -    const axes = new THREE.AxesHelper();
    -    axes.material.depthTest = false;
    -    axes.renderOrder = 2; // 在网格渲染之后再渲染
    -    node.add(axes);
    -
    -    const grid = new THREE.GridHelper(units, units);
    -    grid.material.depthTest = false;
    -    grid.renderOrder = 1;
    -    node.add(grid);
    -
    -    this.grid = grid;
    -    this.axes = axes;
    -    this.visible = false;
    -  }
    -  get visible() {
    -    return this._visible;
    -  }
    -  set visible(v) {
    -    this._visible = v;
    -    this.grid.visible = v;
    -    this.axes.visible = v;
    -  }
    -}
    -
    -

    需要注意的是,我们将 AxesHelperrenderOrder 设置为 2,将GridHelper的设置为 1,这样轴就会在网格之后绘制。否则网格可能会覆盖轴。

    -

    - -

    -

    选中 solarSystem,你会看到地球是如何像我们上面设定的那样,从中心出发正好 10 个单位。你可以看到地球是如何处在 solarSystem 的局部空间(local space)内。同样地,如果你打开 earthOrbit,你会看到月球距离 earthOrbit 的局部空间(local space)的中心正好 2 个单位。

    -

    再举几个场景图的例子。在一个简单的游戏世界中,一辆汽车可能有这样的场景图。

    -

    -

    如果你移动车体,所有的轮子都会随之移动。如果你想让车身和轮子分开弹跳,你可以将车身和轮子作为代表汽车框架的框架(frame)节点的子节点。

    -

    另一个例子是游戏世界中的人类。

    -

    -

    你可以看到对于人类来说,场景图会变得很复杂。事实上,上面的场景图已经被简化了。例如,你可以把它扩展到覆盖每根手指(至少还有 28 个节点)和每个脚趾(还有 28 个节点),再加上脸部和下巴、眼睛,也许还有更多。

    -

    我们来做一个稍微复杂的场景图。我们来做一辆坦克。坦克将有 6 个轮子和一个炮塔。坦克会沿着一条路径行驶。会有一个球体在周围移动,坦克会瞄准球体。

    -

    这是场景图。网格(mesh)的颜色为绿色,Object3D 为蓝色,灯光(light)为金色,摄像机(camera)为紫色。其中一台摄像机没有被添加进场景图。

    -
    - -

    在代码中查看这些节点的设置。

    -

    对于目标,也就是坦克要瞄准的东西,有一个 targetOrbit ( Object3D ),它的旋转方式与上面的 earthOrbit 一样。targetElevation ( Object3D )是 targetOrbit 的一个子节点,它提供了一个从 targetOrbit 的偏移量和一个基准高度。它的子节点是另一个叫做 targetBobObject3D,它只是相对于 targetElevation 上下摆动。最后是 targetMesh,它只是一个立方体,我们可以旋转并改变它的颜色。

    -
    // 移动目标
    -targetOrbit.rotation.y = time * 0.27;
    -targetBob.position.y = Math.sin(time * 2) * 4;
    -targetMesh.rotation.x = time * 7;
    -targetMesh.rotation.y = time * 13;
    -targetMaterial.emissive.setHSL((time * 10) % 1, 1, 0.25);
    -targetMaterial.color.setHSL((time * 10) % 1, 1, 0.25);
    -
    -

    对于坦克来说,有一个叫做 tankObject3D,用来移动它下面的所有子节点。代码中使用了 SplineCurve,其接受用来定义曲线的一系列坐标为参数。0.0 是曲线的起始点,1.0 是曲线的终点。它首先获取当前的位置,也就是放置坦克的位置。然后获取在曲线稍远处的位置,并使用 Object3D.lookAt 将坦克指向该方向。

    -
    const tankPosition = new THREE.Vector2();
    -const tankTarget = new THREE.Vector2();
    -
    -...
    -
    -// 移动坦克
    -const tankTime = time * .05;
    -curve.getPointAt(tankTime % 1, tankPosition);
    -curve.getPointAt((tankTime + 0.01) % 1, tankTarget);
    -tank.position.set(tankPosition.x, 0, tankPosition.y);
    -tank.lookAt(tankTarget.x, 0, tankTarget.y);
    -
    -

    由于坦克顶部的炮塔是坦克的子节点,所以它会自动移动。如果要将它指向目标,我们只需要获取目标的世界位置(world position),然后再次使用 Object3D.lookAt

    -
    const targetPosition = new THREE.Vector3();
    -
    -...
    -
    -// 炮台瞄准目标
    -targetMesh.getWorldPosition(targetPosition);
    -turretPivot.lookAt(targetPosition);
    -
    -

    有一个 turretCamera,它是 turretMesh 的一个子节点,所以它会随着炮塔上下移动和旋转。我们让它瞄准目标。

    -
    // 让turretCamera瞄准目标
    -turretCamera.lookAt(targetPosition);
    -
    -

    还有一个 targetCameraPivot,它是 targetBob 的子节点,所以它随着目标漂浮。我们将其瞄准坦克。它的目的是让 targetCamera 与目标本身偏移。如果我们把摄像头变成 targetBob 的子节点,并且只瞄准摄像头本身,那么它就会在目标内部。

    -
    // 让targetCameraPivot看向坦克
    -tank.getWorldPosition(targetPosition);
    -targetCameraPivot.lookAt(targetPosition);
    -
    -

    最后,我们旋转所有的车轮

    -
    wheelMeshes.forEach((obj) => {
    -  obj.rotation.x = time * 3;
    -});
    -
    -

    对于摄像机,我们在初始化时设置了一个包含所有 4 台摄像机的数组,并附有描述。

    -
    const cameras = [
    -  { cam: camera, desc: "detached camera" },
    -  { cam: turretCamera, desc: "on turret looking at target" },
    -  { cam: targetCamera, desc: "near target looking at tank" },
    -  { cam: tankCamera, desc: "above back of tank" },
    -];
    -
    -const infoElem = document.querySelector("#info");
    -
    -

    并在渲染时循环使用我们的摄像机。

    -
    const camera = cameras[(time * 0.25) % cameras.length | 0];
    -infoElem.textContent = camera.desc;
    -
    -

    - -

    -

    我希望这能让你对场景图的工作原理以及你可能使用它们的方法有一些了解。制作 Object3D 节点,并将东西作为它们的子节点,是使用好 three.js 这样的 3D 引擎的重要一步。通常来说,让东西按照你想要的方式移动和旋转可能需要一些复杂的数学来。例如,如果没有场景图,计算月亮的运动或者汽车的轮子相对于车身的位置会非常复杂,但是使用场景图就会变得简单很多。

    -

    接下来,我们会介绍一下材质.

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/setup.html b/manual/zh/setup.html deleted file mode 100644 index 07b449046812e1..00000000000000 --- a/manual/zh/setup.html +++ /dev/null @@ -1,83 +0,0 @@ - - - 设置 - - - - - - - - - - - - - - -
    -
    -

    设置

    -
    -
    -
    -

    这是three.js系列文章的其中之一。 -第一篇是关于three.js基础。 -如果你还没有阅读那你应该从那开始。

    -

    在我们深入之前我们需要讨论一下设置你的电脑来开发。 -尤其是,因为安全的原因, -WebGL不能直接从你的硬件使用图片。意思是说 -为了能开发你需要使用web服务。幸运的是 -web服务很容易设置和使用。

    -

    首先如果你喜欢你可以从这个链接 -下载整个网站。 -一旦下载完成双击文件来解压。

    -

    下一步下载一个简单的web服务。

    -

    如果你更喜欢有用户界面的web服务,这有一个 -Servez

    -

    - -
    - -

    -

    只要将他指向你解压的文件夹,点击"Start",然后 -打开你的浏览器的http://localhost:8080/或者 -你想浏览例子打开http://localhost:8080/threejs

    -

    点击stop或者推出Servez来停止服务。 -如果你更喜欢命令行(我就是),另一种方法是使用node.js。 -下载,安装,然后打开一个command prompt / console / terminal窗口。 如果你是在Windows上安装程序会添加一个特别的"Node Command Prompt"所以使用它。

    -

    然后安装servez通过输入

    -
    npm -g install servez
    -

    如果你是OSX使用

    -
    sudo npm -g install servez
    -

    一旦你输入完成

    -
    servez path/to/folder/where/you/unzipped/files
    -

    它会打印像这样的东西

    -

    - -
    - -

    -

    然后在你的浏览器中打开http://localhost:8080/

    -

    如果你没有指定路径那么servez会使用当前的文件夹。

    -

    如果这些都不是你的选择 -这里有很多其他的服务可供选择

    -

    现在你有了服务我们可以移步到纹理.

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/shadertoy.html b/manual/zh/shadertoy.html deleted file mode 100644 index 026b6d3c6761c1..00000000000000 --- a/manual/zh/shadertoy.html +++ /dev/null @@ -1,418 +0,0 @@ - - - - - - Three.js and Shadertoy - - - - - - - - - - - - - - - -
    -
    -

    Three.js 与 Shadertoy

    -
    -
    -
    -

    Shadertoy 是一个有着众多惊艳的shader实践的著名网站。 经常有人问如何在 Three.js 里面使用那些shader。

    -

    重要的是要知道,被称作ShaderTOY 事出有因。 通常与其把 ShaderToy 里的shader当做最佳实践,不如称它们是有趣的挑战,比如:dwitter (代码少于140 个字符) 或js13kGames - (用不多于13k代码制作游戏)。

    -

    使用Shadertoy 的难题是, 给特定位置的像素着色写函数从而绘制有趣的图像。这是一种有趣的挑战,很多的结果非常惊艳。但请注意,这并非最佳实践。

    -

    点击 这个惊艳的shader绘制了整个城市

    -
    -

    在我的GPU 上全屏运行,它的运行速度为每秒大约5帧。与《城市:天际线》这样的游戏形成鲜明对比。

    -
    -

    这个游戏在同一台机器上每秒运行 30-60 帧,因为它使用更多 传统技术,建筑物由三角形绘制而成,并带有纹理,等等...

    -

    言归正传,让我们回到如何在three.js使用 Shadertoy的shader 。

    -

    当你在 shadertoy.com上点击“新建”,这是个初始的shader,至少 2019 年 1 月是这样的。

    -
    // By iq: https://www.shadertoy.com/user/iq
    -// license: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
    -void mainImage( out vec4 fragColor, in vec2 fragCoord )
    -{
    -    // Normalized pixel coordinates (from 0 to 1)
    -    vec2 uv = fragCoord/iResolution.xy;
    -
    -    // Time varying pixel color
    -    vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx+vec3(0,2,4));
    -
    -    // Output to screen
    -    fragColor = vec4(col,1.0);
    -}
    -
    -

    关于shader你首要知道的重点是,他们是用一种叫做GLSL (Graphics Library Shading Language)的语言写成的,这是一种专为3D 数学设计的强类型语言。在上面我们看到vec4, vec2,vec3 这三种特定类型。 一个 vec2 有2个value, 一个 vec3 - 有3个value,一个vec4 有4个 values。他们的使用方法非常灵活。最常见的用法是使用 x, y, z, 以及w 例如:

    -
    vec4 v1 = vec4(1.0, 2.0, 3.0, 4.0);
    -float v2 = v1.x + v1.y;  // adds 1.0 + 2.0
    -
    -

    与JavaScript不同,GLSL更像是C / C++,其中变量必须定义类型,所以不能写成这样var v = 1.2; - 而是通过 float v = 1.2;v 声明为浮点数。

    -

    详解 GLSL超出本文范畴。 概览GLSL可以点击本文 - ,进阶可以查看 本系列

    -

    注意,在2019 年 1 月, - shadertoy.com 仅关注 fragment - shaders. Fragment shader的职责在于,给定一个像素的位置,输出该像素颜色。 -

    -

    上面的代码我们看到 shader 有一个out 修饰的叫fragColor的参数。out 代表 output。这个参数向函数传递参数。我们需要将其设置为某种颜色。

    -

    它也有一个 叫 fragCoordin (代表 input)参数。 这代表了将要绘制的像素坐标。基于坐标我们可以生成特定颜色。 如果canvas有 400x300 像素,那么函数将会被调用 400x300 - 次或者说是 120,000 次。 每次 fragCoord 都是一个不同的像素坐标。

    -

    还有 2 个正在使用但未在代码中定义的变量, 一是 - iResolution。 该参数设置 canvas分辨率 。若该参数设置为 - 400x300 则 iResolution 是 400,300 。随着像素值 - 在400,300变化 uv 将在texture的纵横两个方向从 0.0 to 1.0 变化。 使用 - 规范化 值能简化工作,而且 shadertoy上大部分的 - shaders也以类似方式开始。 -

    -

    shader中另一个未定义的参数是 iTime。 该参数代表页面加载后的秒数。

    -

    上面这俩全局变量在shader术语中被称为 uniform 变量。 之所以被称为 uniform - 在于这些变量在shader的一次调用中保持uniform(统一),直到下一次shader调用。需要注意的是,这些参数都是在shadertoy定义的特定变量, 而非GLSL官方 - 变量。这俩变量是发明shadertoy的人定义的。

    -

    这篇 Shadertoy 文档中有更多定义。 现在让我们一起来写点代码来操作上面俩shader参数。

    -

    首先我们定义一个填充canvas的plane。 - 参考这篇关于背景的文章。 - 我们以这篇文章开始,不过要先删掉cube。代码很简单,如下:

    -
    function main() {
    -  const canvas = document.querySelector('#c');
    -  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -  renderer.autoClearColor = false;
    -
    -  const camera = new THREE.OrthographicCamera(
    -    -1, // left
    -     1, // right
    -     1, // top
    -    -1, // bottom
    -    -1, // near,
    -     1, // far
    -  );
    -  const scene = new THREE.Scene();
    -  const plane = new THREE.PlaneGeometry(2, 2);
    -  const material = new THREE.MeshBasicMaterial({
    -      color: 'red',
    -  });
    -  scene.add(new THREE.Mesh(plane, material));
    -
    -  function resizeRendererToDisplaySize(renderer) {
    -    const canvas = renderer.domElement;
    -    const width = canvas.clientWidth;
    -    const height = canvas.clientHeight;
    -    const needResize = canvas.width !== width || canvas.height !== height;
    -    if (needResize) {
    -      renderer.setSize(width, height, false);
    -    }
    -    return needResize;
    -  }
    -
    -  function render() {
    -    resizeRendererToDisplaySize(renderer);
    -
    -    renderer.render(scene, camera);
    -
    -    requestAnimationFrame(render);
    -  }
    -
    -  requestAnimationFrame(render);
    -}
    -
    -main();
    -
    -

    正如关于背景的文章所解释,这些参数将定义 - OrthographicCamera 以及一个大小是2个单位且被canvas填充的plane。 - 当前我们得到一个红色的canvas,因为我们使用的是红色 - MeshBasicMaterial材质。 -

    -

    - - -

    -

    现在我们添加shadertoy shader。

    -
    const fragmentShader = `
    -#include <common>
    -
    -uniform vec3 iResolution;
    -uniform float iTime;
    -
    -// By iq: https://www.shadertoy.com/user/iq
    -// license: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
    -void mainImage( out vec4 fragColor, in vec2 fragCoord )
    -{
    -    // Normalized pixel coordinates (from 0 to 1)
    -    vec2 uv = fragCoord/iResolution.xy;
    -
    -    // Time varying pixel color
    -    vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx+vec3(0,2,4));
    -
    -    // Output to screen
    -    fragColor = vec4(col,1.0);
    -}
    -
    -void main() {
    -  mainImage(gl_FragColor, gl_FragCoord.xy);
    -}
    -`;
    -
    -

    上面我们定义了刚刚提到的2个uniform变量,接下来我们关注从shadertoy里的shader GLSL代码。我们调用 - mainImage ,同时传递 - gl_FragColorgl_FragCoord.xygl_FragColor - 是一个WebGL官方 - 全局变量,代表当前像素的颜色。gl_FragCoord 是另一个WebGL官方 - 全局变量,代表当前着色像素的坐标。 -

    -

    然后设置three.js uniforms,以便控制shader参数。

    -
    const uniforms = {
    -  iTime: { value: 0 },
    -  iResolution:  { value: new THREE.Vector3() },
    -};
    -
    -

    在THREE.js的每个uniform都有 value 参数。该参数必须与shader中的uniform类型匹配。

    -

    然后我们把fragmentshader和uniforms都传递给 - ShaderMaterial。 -

    -
    -const material = new THREE.MeshBasicMaterial({
    --    color: 'red',
    --});
    -+const material = new THREE.ShaderMaterial({
    -+  fragmentShader,
    -+  uniforms,
    -+});
    -
    -

    在渲染前,需要先设置uniforms的值。 -

    -
    -function render() {
    -+function render(time) {
    -+  time *= 0.001;  // convert to seconds
    -
    -  resizeRendererToDisplaySize(renderer);
    -
    -+  const canvas = renderer.domElement;
    -+  uniforms.iResolution.value.set(canvas.width, canvas.height, 1);
    -+  uniforms.iTime.value = time;
    -
    -  renderer.render(scene, camera);
    -
    -  requestAnimationFrame(render);
    -}
    -
    -
    -

    注意: - 不清楚为何iResolution是个vec3,而且 - shadertoy.com上的文档也没有说明第三个参数是啥,在上面没有用到第三个参数所以暂时设置为1。¯\_(ツ)_/¯ -

    -
    -

    - - -

    -

    上面定义的新shader效果与我们在 Shadertoy上看到的匹配, - 至少 2019 年 1 月是这样的 😉。这个shader做了些啥?

    -
      -
    • uv 从0变到1。
    • -
    • cos(uv.xyx)得到3个cos值,以vec3形式输出,一个是uv.x的cos值, 一个是uv.y的cos值,最后是uv.x的cos值。
    • -
    • 参数中加上时间 cos(iTime+uv.xyx)形成动画。
    • -
    • 另外vec3(0,2,4)参数与cos(iTime+uv.xyx+vec3(0,2,4)) 求和使cos波偏移。
    • -
    • cos 输出值范围从-1到1,所以经过0.5 * 0.5 + cos(...)从-1 <-> 1 变为 0.0 <-> 1.0
    • -
    • 计算结果作为RGB颜色赋予当前像素。
    • -
    -

    为了更容易看出cos波形我们稍微调整一下代码。当前uv - 仅能从0到1,因cos波形在2π处重复,我们通过将uv乘上40,实现cos波形从0到40的变化,这将会使cos波形重复大约6.3次。

    -
    -vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx+vec3(0,2,4));
    -+vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx*40.0+vec3(0,2,4));
    -
    -

    如下我数了下大约是重复了6.3次,通过 +vec3(0,2,4)偏移了4因此我们能看到红蓝相间,否则我们将看到红蓝颜色混合为紫色。

    -

    - - -

    -

    了解到输入如此简单,当看到如 - a city canal, - a forest, - a snail, - a - mushroom这些结果,让人更觉得充满挑战。幸运的是这也清晰的说明为何相对于传统的三角形构成的场景,这通常这不是正确的方式。因为每个像素颜色都需要经过许多数学计算,通常会导致运行缓慢。 -

    -

    有些shadertoy的shaders使用纹理贴图作为输入,比如这个

    -
    // By Daedelus: https://www.shadertoy.com/user/Daedelus
    -// license: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
    -#define TIMESCALE 0.25
    -#define TILES 8
    -#define COLOR 0.7, 1.6, 2.8
    -
    -void mainImage( out vec4 fragColor, in vec2 fragCoord )
    -{
    -    vec2 uv = fragCoord.xy / iResolution.xy;
    -    uv.x *= iResolution.x / iResolution.y;
    -
    -    vec4 noise = texture2D(iChannel0, floor(uv * float(TILES)) / float(TILES));
    -    float p = 1.0 - mod(noise.r + noise.g + noise.b + iTime * float(TIMESCALE), 1.0);
    -    p = min(max(p * 3.0 - 1.8, 0.1), 2.0);
    -
    -    vec2 r = mod(uv * float(TILES), 1.0);
    -    r = vec2(pow(r.x - 0.5, 2.0), pow(r.y - 0.5, 2.0));
    -    p *= 1.0 - pow(min(1.0, 12.0 * dot(r, r)), 2.0);
    -
    -    fragColor = vec4(COLOR, 1.0) * p;
    -}
    -
    -

    给shader传递纹理与给常规材质传递纹理一样,只不过需要通过uniforms来设置纹理。

    -

    首先需要给shader添加一个纹理的uniform。在GLSL中对应为 - sampler2D 。 -

    -
    const fragmentShader = `
    -#include <common>
    -
    -uniform vec3 iResolution;
    -uniform float iTime;
    -+uniform sampler2D iChannel0;
    -
    -...
    -
    -

    然后我们可以像这里一样载入纹理,并且设置uniform的值。

    -
    +const loader = new THREE.TextureLoader();
    -+const texture = loader.load('resources/images/bayer.png');
    -+texture.minFilter = THREE.NearestFilter;
    -+texture.magFilter = THREE.NearestFilter;
    -+texture.wrapS = THREE.RepeatWrapping;
    -+texture.wrapT = THREE.RepeatWrapping;
    -const uniforms = {
    -  iTime: { value: 0 },
    -  iResolution:  { value: new THREE.Vector3() },
    -+  iChannel0: { value: texture },
    -};
    -
    -

    - - -

    -

    到目前为止,我们一直用Shadertoy.com上的方式使用 Shadertoy - shaders,即在canvas上绘制shader。但我们无需受限于此。请留意,通常人们在Shadertoy上写的函数仅输入一个fragCoord 和一个iResolution参数。fragCoord 不一定来自像素坐标,像纹理坐标也可以,然后就可以像常规的纹理一样使用。通常把这种通过函数生成纹理的技术叫做procedural texture

    -

    让我们改一改上面的shader,最简单的莫过于使用three.js提供的纹理坐标,乘上iResolution再传到fragCoords

    -

    我们需要加一个varying变量。varing变量通过对顶点进行插值(也叫varied)实现从vertex shader传值到fragment shader。在fragment - shader中使用之前需要先声明该变量。这个变量名中的 uv代表纹理坐标,前面的v代表varying

    -
    ...
    -
    -+varying vec2 vUv;
    -
    -void main() {
    --  mainImage(gl_FragColor, gl_FragCoord.xy);
    -+  mainImage(gl_FragColor, vUv * iResolution.xy);
    -}
    -
    -

    然后我们需要实现vertex shader,下面是最简化的three.js的vertex shader。three.js中定义了uvprojectionMatrixmodelViewMatrix,和 position这几个参数,且可以传值给shader。

    -
    const vertexShader = `
    -  varying vec2 vUv;
    -  void main() {
    -    vUv = uv;
    -    gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
    -  }
    -`;
    -
    -

    把vertexshader传给ShaderMaterial

    -
    const material = new THREE.ShaderMaterial({
    -  vertexShader,
    -  fragmentShader,
    -  uniforms,
    -});
    -
    -

    因为iResolution保持不变,因此可以在初始化时设定它的值。

    -
    const uniforms = {
    -  iTime: { value: 0 },
    --  iResolution:  { value: new THREE.Vector3() },
    -+  iResolution:  { value: new THREE.Vector3(1, 1, 1) },
    -  iChannel0: { value: texture },
    -};
    -
    -

    在渲染时无需设置它的值。

    -
    -const canvas = renderer.domElement;
    --uniforms.iResolution.value.set(canvas.width, canvas.height, 1);
    -uniforms.iTime.value = time;
    -
    -

    另外我从关于响应能力的文章复制了一段3个旋转cube代码。效果如下:

    -

    - - -

    -

    希望这篇文字能说清在three.js使用shadertoy shader的入门方法。再次重申,大部分的shadertoy - shaders与其说是性能方面的最佳实践,不如称它们是有趣的挑战(通过函数实现所有绘制)。尽管如此,他们还是有着令人印象深刻的惊艳和美,了解shader工作原理可以学到很多东西。

    -
    -
    -
    - - - - - - - - - - diff --git a/manual/zh/shadows.html b/manual/zh/shadows.html deleted file mode 100644 index 9ba2ed8fd438b7..00000000000000 --- a/manual/zh/shadows.html +++ /dev/null @@ -1,367 +0,0 @@ - - - 阴影 - - - - - - - - - - - - - - -
    -
    -

    阴影

    -
    -
    -
    -

    本文是 three.js 系列文章中的一部分。第一篇文章为 three.js 基础。如果你是个新手,还没读过,请从那里开始。 -前一篇文章关于相机再前一遍文章关于灯光,这些文章都很重要。

    -

    电脑中的阴影可以是一个很复杂的话题。有各种各样的解决方案,所有这些都有权衡,包括 three.js 中可用的解决方案。

    -

    Three.js 默认使用shadow maps(阴影贴图),阴影贴图的工作方式就是具有投射阴影的光能对所有能被投射阴影的物体从光源渲染阴影。请再读一遍,试着去理解并记住

    -

    换句话说,如果你有 20 个物体对象、5 个灯光,并且所有的物体都能被投射阴影,所有的光都能投射阴影,那么这个场景这个场景将会绘制 6 次。第一个灯光将会为所有的物体投影阴影,绘制场景。然后是第二个灯光绘制场景,然后是第三个灯光,以此类推。最后一次(即第六次)将通过前五个灯光渲染的数据,渲染出最终的实际场景。

    -

    糟糕的是,如果你有一个能投射阴影点光源在这个场景中,那个这个场景将会为这个点光源再绘制 6 次。

    -

    由于这些原因,除了寻找其他根本上的解决方案去解决一堆光源都能投射阴影的性能问题。一般还有常见的解决方案,就是允许多个光源,但只让一个光源能投射阴影。

    -

    另一个解决方案就是使用光照贴图或者环境光贴图,预先计算离线照明的效果。这将导致静态光照,但是至少该方案渲染得非常快。在另一篇文章中将涵盖这两个解决方案。

    -

    其他的解决方案是使用假的阴影。举个例子,创建一个平面,在平面上放一个近似阴影的灰度纹理,把它画在物体下面的地面上。

    -

    这个例子我们将使用假阴影

    -
    - -

    我们使用 前一篇文章的代码.

    -

    首先让我们将场景的背景颜色设置为白色

    -
    const scene = new THREE.Scene();
    -+scene.background = new THREE.Color("white");
    -
    -

    然后我们将使用相同的棋盘格地面,因为这一次我们使用的是MeshBasicMaterial,所有我们不需要地面照明

    -
    +const loader = new THREE.TextureLoader();
    -
    -{
    -  const planeSize = 40;
    -
    --  const loader = new THREE.TextureLoader();
    -  const texture = loader.load('resources/images/checker.png');
    -  texture.wrapS = THREE.RepeatWrapping;
    -  texture.wrapT = THREE.RepeatWrapping;
    -  texture.magFilter = THREE.NearestFilter;
    -  const repeats = planeSize / 2;
    -  texture.repeat.set(repeats, repeats);
    -
    -  const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    -  const planeMat = new THREE.MeshBasicMaterial({
    -    map: texture,
    -    side: THREE.DoubleSide,
    -  });
    -+  planeMat.color.setRGB(1.5, 1.5, 1.5);
    -  const mesh = new THREE.Mesh(planeGeo, planeMat);
    -  mesh.rotation.x = Math.PI * -.5;
    -  scene.add(mesh);
    -}
    -
    -

    注意我们将颜色设置为1.5, 1.5, 1.5,这将是棋盘纹理的颜色倍增 1.5,1.5,1.5。 -也就是说纹理原本的颜色是 0x808080 和 0xC0C0C0,是灰色和浅灰色,现在灰色和浅灰色乘以 1.5 将得到白色和浅灰色的棋盘。

    -

    现在让我们加载阴影贴图

    -
    const shadowTexture = loader.load("resources/images/roundshadow.png");
    -
    -

    并且创建一个数组来存放每个球体和它相关的对象

    -
    const sphereShadowBases = [];
    -
    -

    现在我们创建一个球体

    -
    const sphereRadius = 1;
    -const sphereWidthDivisions = 32;
    -const sphereHeightDivisions = 16;
    -const sphereGeo = new THREE.SphereGeometry(
    -  sphereRadius,
    -  sphereWidthDivisions,
    -  sphereHeightDivisions
    -);
    -
    -

    然后创建一个假阴影的平面网格

    -
    const planeSize = 1;
    -const shadowGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    -
    -

    现在我们将创建一堆球体,对于每个球体都将创建一个基础THREE.Object3D,并且我们将同时创建阴影平面网格和球体网格。这样,如果我们同时移动球体,阴影也一并移动,我们只需要将阴影稍微放置再地面上,防止 Z 轴阴影和地面重叠。 -我们将depthWrite属性设置为 false,这样使阴影之间不会彼此混淆。 -我们将在另一篇文章中讨论这两个问题。 -因为阴影的材质是MeshBasicMaterial,所以它并不需要照明

    -

    我们将每个球体使用不同的色相,然后保存每个球体的基础、球体网格、阴影网格和初始 y 位置。

    -
    const numSpheres = 15;
    -for (let i = 0; i < numSpheres; ++i) {
    -  // make a base for the shadow and the sphere
    -  // so they move together.
    -  const base = new THREE.Object3D();
    -  scene.add(base);
    -
    -  // add the shadow to the base
    -  // note: we make a new material for each sphere
    -  // so we can set that sphere's material transparency
    -  // separately.
    -  const shadowMat = new THREE.MeshBasicMaterial({
    -    map: shadowTexture,
    -    transparent: true, // so we can see the ground
    -    depthWrite: false, // so we don't have to sort
    -  });
    -  const shadowMesh = new THREE.Mesh(shadowGeo, shadowMat);
    -  shadowMesh.position.y = 0.001; // so we're above the ground slightly
    -  shadowMesh.rotation.x = Math.PI * -0.5;
    -  const shadowSize = sphereRadius * 4;
    -  shadowMesh.scale.set(shadowSize, shadowSize, shadowSize);
    -  base.add(shadowMesh);
    -
    -  // add the sphere to the base
    -  const u = i / numSpheres; // goes from 0 to 1 as we iterate the spheres.
    -  const sphereMat = new THREE.MeshPhongMaterial();
    -  sphereMat.color.setHSL(u, 1, 0.75);
    -  const sphereMesh = new THREE.Mesh(sphereGeo, sphereMat);
    -  sphereMesh.position.set(0, sphereRadius + 2, 0);
    -  base.add(sphereMesh);
    -
    -  // remember all 3 plus the y position
    -  sphereShadowBases.push({
    -    base,
    -    sphereMesh,
    -    shadowMesh,
    -    y: sphereMesh.position.y,
    -  });
    -}
    -
    -

    我们设置两个光源,一个是HemisphereLight,将其光照强度设置为 2,让场景比较明亮。

    -
    {
    -  const skyColor = 0xb1e1ff; // light blue
    -  const groundColor = 0xb97a20; // brownish orange
    -  const intensity = 2;
    -  const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
    -  scene.add(light);
    -}
    -
    -

    另一个是 DirectionalLight ,这将让球体看起来有些视觉的区别

    -
    {
    -  const color = 0xffffff;
    -  const intensity = 1;
    -  const light = new THREE.DirectionalLight(color, intensity);
    -  light.position.set(0, 10, 5);
    -  light.target.position.set(-5, 0, 0);
    -  scene.add(light);
    -  scene.add(light.target);
    -}
    -
    -

    现在我们设置球体动画并将其渲染。 -对于每个球体,阴影以及 base,让它们在 XZ 平面上移动。使用Math.abs(Math.sin(time))将球体上下移动,这样会带来一个类似弹性的动画。并且我们还设置了阴影材质的不透明度,与球体的高度相关。高度越高,阴影越模糊。

    -
    function render(time) {
    -  time *= 0.001;  // convert to seconds
    -
    -  ...
    -
    -  sphereShadowBases.forEach((sphereShadowBase, ndx) => {
    -    const {base, sphereMesh, shadowMesh, y} = sphereShadowBase;
    -
    -    // u is a value that goes from 0 to 1 as we iterate the spheres
    -    const u = ndx / sphereShadowBases.length;
    -
    -    // compute a position for the base. This will move
    -    // both the sphere and its shadow
    -    const speed = time * .2;
    -    const angle = speed + u * Math.PI * 2 * (ndx % 1 ? 1 : -1);
    -    const radius = Math.sin(speed - ndx) * 10;
    -    base.position.set(Math.cos(angle) * radius, 0, Math.sin(angle) * radius);
    -
    -    // yOff is a value that goes from 0 to 1
    -    const yOff = Math.abs(Math.sin(time * 2 + ndx));
    -    // move the sphere up and down
    -    sphereMesh.position.y = y + THREE.MathUtils.lerp(-2, 2, yOff);
    -    // fade the shadow as the sphere goes up
    -    shadowMesh.material.opacity = THREE.MathUtils.lerp(1, .25, yOff);
    -  });
    -
    -  ...
    -
    -

    这里有 15 种弹跳球

    -

    - -

    -

    在某些应用程序中使用圆形或者椭圆的阴影也是很常见的。当然也可以使用不同形状的阴影纹理,也可以将阴影的边缘锐化。使用这种类型的阴影的例子是 Animal Crossing Pocket Camp,在其中你可以看到每个字符都有一个简单的原型阴影。这种方式很有效,也很方便。Monument Valley 纪念碑谷看起来似乎也使用这种阴影。

    -

    因此,移动阴影贴图,有三种光可以投射阴影,分别为DirectionalLight 定向光PointLight 点光源SpotLight 聚光灯

    -

    让我们从 DirectionalLight 定向光 开始。这里我们使用关于灯光的文章作为基础

    -

    第一件事是设置渲染器中的阴影属性

    -
    const renderer = new THREE.WebGLRenderer({ canvas });
    -+renderer.shadowMap.enabled = true;
    -
    -

    我们还需要设置光能投射阴影

    -
    const light = new THREE.DirectionalLight(color, intensity);
    -+light.castShadow = true;
    -
    -

    在场景中的每个网格,我们都能设置它是否能投射阴影或被投射阴影。 -这里我们只设置地面能被投射阴影,这样我们不需要关心地面投射阴影的问题。

    -
    const mesh = new THREE.Mesh(planeGeo, planeMat);
    -mesh.receiveShadow = true;
    -
    -

    对于球体和立方体,我们需要设置他们都能投射阴影或者被投射阴影

    -
    const mesh = new THREE.Mesh(cubeGeo, cubeMat);
    -mesh.castShadow = true;
    -mesh.receiveShadow = true;
    -
    -...
    -
    -const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    -mesh.castShadow = true;
    -mesh.receiveShadow = true;
    -
    -

    然后我们运行它

    -

    - -

    -

    发生了什么?为什么阴影的一部分不见了

    -

    原因是阴影是通过光线的角度渲染场景之后生成的。在这种情况下,现在只有一个DirectionalLight 定向光在照射这个球体,就像我们之前的文章关于相机,光源的阴影相机决定了阴影投射的区域。在上面的例子中,该区域太小了。

    -

    为了可视化该区域,我们可以通过CameraHelper 相机帮助类 来获取光源的阴影相机。

    -
    const cameraHelper = new THREE.CameraHelper(light.shadow.camera);
    -scene.add(cameraHelper);
    -
    -

    你现在可以看到光源的阴影相机可以投射的区域。

    -

    - -

    -

    我们来回调整相机的 x 坐标值。这样我们可以很清楚的看到:光源的阴影相机所包围的 box 才是能投射阴影的区域。

    -

    我们可以通过调整光源的阴影相机来调整该盒子的大小。

    -

    现在我们添加一些可以调整光源相关属性的 GUI 设置。 -由于DirectionalLight 定向光表现形式是光照一直都是平行方向移动的,所以我们在DirectionalLight 定向光中使用OrthographicCamera 正交相机为了观察阴影相机。 -我们在 关于相机的文章介绍了OrthographicCamera 正交相机是如何工作的。

    -

    回忆OrthographicCamera 正交相机的定义和其视图的用法,以及其属性:left, right, top, bottom, near, far,zoom

    -

    我们再次为 lil-gui 创建一个DimensionGUIHelper类。这个类的作用是响应式的通过一个属性来设置两个相关的属性。然后将其加入到 lil-gui 选项中。 -我们根据width的值设置 leftright,根据height的值设置updown

    -
    class DimensionGUIHelper {
    -  constructor(obj, minProp, maxProp) {
    -    this.obj = obj;
    -    this.minProp = minProp;
    -    this.maxProp = maxProp;
    -  }
    -  get value() {
    -    return this.obj[this.maxProp] * 2;
    -  }
    -  set value(v) {
    -    this.obj[this.maxProp] = v / 2;
    -    this.obj[this.minProp] = v / -2;
    -  }
    -}
    -
    -

    我们也会使用在关于相机的文中中创建的MinMaxGUIHelper,他将负责near and far的变化

    -
    const gui = new GUI();
    -gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
    -gui.add(light, 'intensity', 0, 2, 0.01);
    -+{
    -+  const folder = gui.addFolder('Shadow Camera');
    -+  folder.open();
    -+  folder.add(new DimensionGUIHelper(light.shadow.camera, 'left', 'right'), 'value', 1, 100)
    -+    .name('width')
    -+    .onChange(updateCamera);
    -+  folder.add(new DimensionGUIHelper(light.shadow.camera, 'bottom', 'top'), 'value', 1, 100)
    -+    .name('height')
    -+    .onChange(updateCamera);
    -+  const minMaxGUIHelper = new MinMaxGUIHelper(light.shadow.camera, 'near', 'far', 0.1);
    -+  folder.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
    -+  folder.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
    -+  folder.add(light.shadow.camera, 'zoom', 0.01, 1.5, 0.01).onChange(updateCamera);
    -+}
    -
    -

    我们需要让 GUI 在我们改变数据的时候调用updateCamera方法 -这个方法将更新光源,光源的帮助类以及光源的阴影相机和像是光的阴影相机的帮助类。

    -
    function updateCamera() {
    -  // update the light target's matrixWorld because it's needed by the helper
    -  light.target.updateMatrixWorld();
    -  helper.update();
    -  // update the light's shadow camera's projection matrix
    -  light.shadow.camera.updateProjectionMatrix();
    -  // and now update the camera helper we're using to show the light's shadow camera
    -  cameraHelper.update();
    -}
    -updateCamera();
    -
    -

    现在我们给了光的阴影相机一个 GUI,我们可以尝试随意更改其中的值。

    -

    - -

    -

    widthheight的值设置在 30 附近,我们将看到阴影是正常渲染的,因为这个区域都在阴影相机的投影范围内。

    -

    这也带来一个疑问。为什么我们不将widthheight设置成一个非常大的值,这样不就可以投影一切了?我们将它设置成 100 来看看会发生什么。

    -
    - -

    我们将看到一些块状的阴影!

    -

    这个问题是另一个需要注意的阴影相关设置。被投射产生的阴影也是有纹理的,这些阴影的纹理也是有单位大小的。如果阴影相机的属性设置的越大,就意味着它能投射的区域也变得很大,就意味着投射的阴影会越来越块状。

    -

    你可以设置light.shadow.mapSize.widthlight.shadow.mapSize.height来设置阴影的纹理分辨率。他们默认为 512X512。如果设置的很大,他们在计算时将占用更多的内存,并且变得很慢。为了获得更真实的阴影,应该尽量将值设置的最小。请注意在渲染器中该属性renderer.capabilities.maxTextureSize对于每个用户都有最大纹理的上限值。

    -

    当我们切换到 SpotLight 聚光灯时,光源的阴影相机就变成了PerspectiveCamera透视相机 ,与DirectionalLight 定向光的阴影相机不同,SpotLight 聚光灯的阴影相机有其本身所控制,我们也可以手动设置大部分设置。SpotLight聚光灯阴影相机的的fovSpotLight聚光灯angle 关联。aspect属性是根据阴影映射自动设置大小的。

    -
    -const light = new THREE.DirectionalLight(color, intensity);
    -+const light = new THREE.SpotLight(color, intensity);
    -
    -

    我们在关于灯光的文章中添加了penumbraangle的相关介绍

    -

    - -

    -

    最后,我们介绍 PointLight 聚光灯的阴影投射。 -PointLight 聚光灯是向四面八方发散的,所以唯一的设置只有nearfar。实际上PointLight 聚光灯 相当于 6 个面的SpotLight 点光源组合而成。这意味着它的渲染速度要慢得多,相当于整个场景的阴影和渲染 6 次,每个方向(面)都需要渲染一次。

    -

    我们将在场景中放置一个盒子,这样我们可以看到墙壁和天花板的阴影效果。我们设置材质的属性,只让它在盒子的内部渲染,就像地板一样。我们还设置它可以被投射阴影,并且将它的高度设置的比地板稍微低一点,防止 Z 轴渲染重合。

    -
    {
    -  const cubeSize = 30;
    -  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
    -  const cubeMat = new THREE.MeshPhongMaterial({
    -    color: "#CCC",
    -    side: THREE.BackSide,
    -  });
    -  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
    -  mesh.receiveShadow = true;
    -  mesh.position.set(0, cubeSize / 2 - 0.1, 0);
    -  scene.add(mesh);
    -}
    -
    -

    当然我们也需要把光源切换成PointLight 聚光灯

    -
    -const light = new THREE.SpotLight(color, intensity);
    -+const light = new THREE.PointLight(color, intensity);
    -
    -....
    -
    -// so we can easily see where the point light is
    -+const helper = new THREE.PointLightHelper(light);
    -+scene.add(helper);
    -
    -

    - -

    -

    使用 GUI 的position来移动光源的位置,你就可以看到墙上阴影强度的改变。你还可以调整其他的设置,比如nearfarnear代表最小的渲染阴影的距离,这只会渲染物体的距离大于其值的物体的阴影。 far 这代表渲染比其值距离小的物体的阴影。

    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/textures.html b/manual/zh/textures.html deleted file mode 100644 index 867dbf9a2ed3fd..00000000000000 --- a/manual/zh/textures.html +++ /dev/null @@ -1,467 +0,0 @@ - - - 纹理 - - - - - - - - - - - - - - -
    -
    -

    纹理

    -
    -
    -
    -

    本文是关于 three.js 系列文章的一部分。第一篇文章是 three.js 基础。上一篇文章是关于本文的环境搭建。如果你还没有读过它,建议先从那里开始。

    -

    纹理是Three.js中的一种大话题,我还不能100%地确定在什么层面上解释它们,但我会试着去做它。这里面有很多主题,而且很多主题是相互关联的,所以很难一下子解释清楚。下面是本文的快速目录。

    - - -

    你好,纹理

    -

    纹理一般是指我们常见的在一些第三方程序中创建的图像,如Photoshop或GIMP。比如我们把这张图片放在立方体上。

    -
    - -
    - -

    我们将修改我们的第一个例子中的其中一个。我们需要做的就是创建一个TextureLoader。调用它的load方法,同时传入图像的URL,并将材质的 map 属性设置为该方法的返回值,而不是设置它的 color属性。

    -
    +const loader = new THREE.TextureLoader();
    -+const texture = loader.load( 'resources/images/wall.jpg' );
    -+texture.colorSpace = THREE.SRGBColorSpace;
    -
    -const material = new THREE.MeshBasicMaterial({
    --  color: 0xFF8844,
    -+  map: texture,
    -});
    -
    -

    注意,我们使用的是 MeshBasicMaterial, 所以没有必要增加光线

    -

    - -

    -

    6种纹理,在立方体的每个面上都有不同的纹理。

    -

    6个纹理,一个立方体的每个面都有一个,怎么样?

    -
    -
    - - - -
    -
    - - - -
    -
    - -

    我们只需制作6种材料,并在创建 Mesh 时将它们作为一个数组传递给它们。

    -
    const loader = new THREE.TextureLoader();
    --const texture = loader.load( 'resources/images/wall.jpg' );
    --texture.colorSpace = THREE.SRGBColorSpace;
    -
    --const material = new THREE.MeshBasicMaterial({
    --  map: texture,
    --});
    -+const materials = [
    -+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-1.jpg')}),
    -+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-2.jpg')}),
    -+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-3.jpg')}),
    -+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-4.jpg')}),
    -+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-5.jpg')}),
    -+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-6.jpg')}),
    -+];
    --const cube = new THREE.Mesh(geometry, material);
    -+const cube = new THREE.Mesh(geometry, materials);
    -
    -+function loadColorTexture( path ) {
    -+  const texture = loader.load( path );
    -+  texture.colorSpace = THREE.SRGBColorSpace;
    -+  return texture;
    -+}
    -
    -

    有效果了!

    -

    - -

    -

    但需要注意的是,并不是所有的几何体类型都支持多种材质。BoxGeometry 可以为六个面分别指定材质。ConeGeometry支持两种材质,分别用于底面和侧面。 CylinderGeometry支持三种材质,分别用于底面、顶面和侧面。对于其他情况,你需要构建或加载自定义几何体和(或)修改纹理坐标。

    -

    在其他3D引擎中,如果你想在一个几何体上使用多个图像,使用 纹理图集(Texture Atlas) 更为常见,性能也更高。纹理图集是将多个图像放在一个单一的纹理中,然后使用几何体顶点上的纹理坐标来选择在几何体的每个三角形上使用纹理的哪些部分。

    -

    什么是纹理坐标?它们是添加到一块几何体的每个顶点上的数据,用于指定该顶点对应的纹理的哪个部分。当我们开始构建自定义几何体时(building custom geometry),我们会介绍它们。

    -

    加载纹理

    -

    简单的方法

    -

    本文的大部分代码都使用最简单的加载纹理的方法。我们创建一个 TextureLoader ,然后调用它的load方法。 -这将返回一个 Texture 对象。

    -
    const texture = loader.load('resources/images/flower-1.jpg');
    -
    -

    需要注意的是,使用这个方法,我们的纹理将是透明的,直到图片被three.js异步加载完成,这时它将用下载的图片更新纹理。

    -

    这有一个很大的好处,就是我们不必等待纹理加载,我们的页面会立即开始渲染。这对于很多用例来说可能都没问题,但如果我们想要的话,我们可以让three.js告诉我们何时纹理已经下载完毕。

    -

    等待一个纹理加载

    -

    为了等待贴图加载,贴图加载器的 load 方法会在贴图加载完成后调用一个回调。回到上面的例子,我们可以在创建Mesh并将其添加到场景之前等待贴图加载,就像这样。

    -
    const loader = new THREE.TextureLoader();
    -loader.load('resources/images/wall.jpg', (texture) => {
    -  const material = new THREE.MeshBasicMaterial({
    -    map: texture,
    -  });
    -  const cube = new THREE.Mesh(geometry, material);
    -  scene.add(cube);
    -  cubes.push(cube);  // 添加到我们要旋转的立方体数组中
    -});
    -
    -

    除非你清除你的浏览器的缓存并且连接缓慢,你不太可能看到任何差异,但放心,它正在等待纹理加载。

    -

    - -

    -

    等待多个纹理加载

    -

    要等到所有纹理都加载完毕,你可以使用 LoadingManager 。创建一个并将其传递给 TextureLoader,然后将其onLoad属性设置为回调。

    -
    +const loadManager = new THREE.LoadingManager();
    -*const loader = new THREE.TextureLoader(loadManager);
    -
    -const materials = [
    -  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-1.jpg')}),
    -  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-2.jpg')}),
    -  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-3.jpg')}),
    -  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-4.jpg')}),
    -  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-5.jpg')}),
    -  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-6.jpg')}),
    -];
    -
    -+loadManager.onLoad = () => {
    -+  const cube = new THREE.Mesh(geometry, materials);
    -+  scene.add(cube);
    -+  cubes.push(cube);  // 添加到我们要旋转的立方体数组中
    -+};
    -
    -

    LoadingManager 也有一个 onProgress 属性,我们可以设置为另一个回调来显示进度指示器。

    -

    首先,我们在HTML中添加一个进度条

    -
    <body>
    -  <canvas id="c"></canvas>
    -+  <div id="loading">
    -+    <div class="progress"><div class="progressbar"></div></div>
    -+  </div>
    -</body>
    -
    -

    然后给它加上CSS

    -
    #loading {
    -    position: fixed;
    -    top: 0;
    -    left: 0;
    -    width: 100%;
    -    height: 100%;
    -    display: flex;
    -    justify-content: center;
    -    align-items: center;
    -}
    -#loading .progress {
    -    margin: 1.5em;
    -    border: 1px solid white;
    -    width: 50vw;
    -}
    -#loading .progressbar {
    -    margin: 2px;
    -    background: white;
    -    height: 1em;
    -    transform-origin: top left;
    -    transform: scaleX(0);
    -}
    -
    -

    然后在代码中,我们将在 onProgress 回调中更新 progressbar 的比例。调用它有如下几个参数:最后加载的项目的URL,目前加载的项目数量,以及加载的项目总数。

    -
    +const loadingElem = document.querySelector('#loading');
    -+const progressBarElem = loadingElem.querySelector('.progressbar');
    -
    -loadManager.onLoad = () => {
    -+  loadingElem.style.display = 'none';
    -  const cube = new THREE.Mesh(geometry, materials);
    -  scene.add(cube);
    -  cubes.push(cube);  // 添加到我们要旋转的立方体数组中
    -};
    -
    -+loadManager.onProgress = (urlOfLastItemLoaded, itemsLoaded, itemsTotal) => {
    -+  const progress = itemsLoaded / itemsTotal;
    -+  progressBarElem.style.transform = `scaleX(${progress})`;
    -+};
    -
    -

    除非你清除了你的缓存,而且连接速度很慢,否则你可能看不到加载栏。

    -

    - -

    -

    从其他源加载纹理

    -

    要使用其他服务器上的图片,这些服务器需要发送正确的头文件。如果他们不发送,你就不能在three.js中使用这些图片,并且会得到一个错误。如果你运行提供图片的服务器,请确保它发送正确的头文件.。如果你不控制托管图片的服务器,而且它没有发送权限头文件,那么你就不能使用该服务器上的图片。

    -

    例如 imgurflickrgithub 都会发送头文件,允许你在 three.js 中使用他们服务器上托管的图片,使用 three.js。而其他大多数网站则不允许。

    -

    内存管理

    -

    纹理往往是three.js应用中使用内存最多的部分。重要的是要明白,一般来说,纹理会占用 宽度 * 高度 * 4 * 1.33 字节的内存。

    -

    注意,这里没有提到任何关于压缩的问题。我可以做一个.jpg的图片,然后把它的压缩率设置的超级高。比如说我在做一个房子的场景。在房子里面有一张桌子,我决定在桌子的顶面放上这个木质的纹理

    -
    - -

    那张图片只有157k,所以下载起来会比较快,但实际上它的大小是3024×3761像素.。按照上面的公式,那就是

    -
    3024 * 3761 * 4 * 1.33 = 60505764.5
    -

    在three.js中,这张图片会占用60兆(meg)的内存!。只要几个这样的纹理,你就会用完内存。

    -

    我之所以提出这个问题,是因为要知道使用纹理是有隐性成本的。为了让three.js使用纹理,必须把纹理交给GPU,而GPU一般都要求纹理数据不被压缩。

    -

    这个故事的寓意在于,不仅仅要让你的纹理的文件大小小,还得让你的纹理尺寸小。文件大小小=下载速度快。尺寸小=占用的内存少。你应该把它们做得多小?越小越好,而且看起来仍然是你需要的样子。

    -

    JPG vs PNG

    -

    这和普通的HTML差不多,JPG有损压缩,PNG有无损压缩,所以PNG的下载速度一般比较慢。但是,PNG支持透明度。PNG可能也适合作为非图像数据(non-image data)的格式,比如法线图,以及其他种类的非图像图,我们后面会介绍。

    -

    请记住,在WebGL中JPG使用的内存并不比PNG少。参见上文。

    -

    过滤和mips

    -

    让我们把这个16x16的纹理应用到

    -
    - -

    一个立方体上。

    -
    - -

    让我们把这个立方体画得非常小

    -
    - -

    嗯,我想这很难看得清楚。让我们把这个小方块放大

    -
    - -

    GPU怎么知道小立方体的每一个像素需要使用哪些颜色?如果立方体小到只有1、2个像素呢?

    -

    这就是过滤(filtering)的意义所在。

    -

    如果是Photoshop,Photoshop会把几乎所有的像素平均在一起,来计算出这1、2个像素的颜色。这将是一个非常缓慢的操作。GPU用mipmaps解决了这个问题。

    -

    Mips 是纹理的副本,每一个都是前一个 mip 的一半宽和一半高,其中的像素已经被混合以制作下一个较小的 mip。Mips一直被创建,直到我们得到1x1像素的Mip。对于上面的图片,所有的Mip最终会变成这样的样子

    -
    - -

    现在,当立方体被画得很小,只有1或2个像素大时,GPU可以选择只用最小或次小级别的mip来决定让小立方体变成什么颜色。

    -

    在three.js中,当纹理绘制的尺寸大于其原始尺寸时,或者绘制的尺寸小于其原始尺寸时,你都可以做出相应的处理。

    -

    当纹理绘制的尺寸大于其原始尺寸时,你可以将 texture.magFilter 属性设置为 THREE.NearestFilterTHREE.LinearFilterNearestFilter 意味着只需从原始纹理中选取最接近的一个像素。对于低分辨率的纹理,这给你一个非常像素化的外观,就像Minecraft。

    -

    LinearFilter 是指从纹理中选择离我们应该选择颜色的地方最近的4个像素,并根据实际点与4个像素的距离,以适当的比例进行混合。

    -
    -
    -
    -
    Nearest
    -
    -
    -
    -
    Linear
    -
    -
    - -

    为了在绘制的纹理小于其原始尺寸时设置过滤器,你可以将 texture.minFilter 属性设置为下面6个值之一。

    -
      -
    • THREE.NearestFilter

      -

      同上,在纹理中选择最近的像素。

      -
    • -
    • THREE.LinearFilter

      -

      和上面一样,从纹理中选择4个像素,然后混合它们

      -
    • -
    • THREE.NearestMipmapNearestFilter

      -

      选择合适的mip,然后选择一个像素。

      -
    • -
    • THREE.NearestMipmapLinearFilter

      -

      选择2个mips,从每个mips中选择一个像素,混合这2个像素。

      -
    • -
    • THREE.LinearMipmapNearestFilter

      -

      选择合适的mip,然后选择4个像素并将它们混合。

      -
    • -
    • THREE.LinearMipmapLinearFilter

      -

      选择2个mips,从每个mips中选择4个像素,然后将所有8个像素混合成1个像素。

      -
    • -
    -

    下面是一个分别使用上面6个设置的例子

    -
    -
    -
    -
    click to
    change
    texture
    -
    -
    nearest
    -
    linear
    -
    nearest
    mipmap
    nearest
    -
    nearest
    mipmap
    linear
    -
    linear
    mipmap
    nearest
    -
    linear
    mipmap
    linear
    -
    -
    - -

    需要注意的是,使用 NearestFilterLinearFilter 的左上方和中上方没有使用mips。正因为如此,它们在远处会闪烁,因为GPU是从原始纹理中挑选像素。左边只有一个像素被选取,中间有4个像素被选取并混合,但这还不足以得出一个好的代表颜色。其他4条做得比较好,右下角的LinearMipmapLinearFilter最好。

    -

    如果你点击上面的图片,它将在我们上面一直使用的纹理和每一个mip级别都是不同颜色的纹理之间切换。

    -
    -
    -
    - -

    这样就更清楚了。在左上角和中上角你可以看到第一个mip一直用到了远处。右上角和中下角你可以清楚地看到哪里使用了不同的mip。

    -

    切换回原来的纹理,你可以看到右下角是最平滑的,质量最高的。你可能会问为什么不总是使用这种模式。最明显的原因是有时你希望东西是像素化的,以达到复古的效果或其他原因。其次最常见的原因是,读取8个像素并混合它们比读取1个像素并混合要慢。虽然单个纹理不太可能成为快和慢的区别,但随着我们在这些文章中的进一步深入,我们最终会有同时使用4或5个纹理的材料的情况。4个纹理*每个纹理8个像素,就是查找32个像素的永远渲染的像素。在移动设备上,这一点可能需要被重点考虑。

    -

    重复,偏移,旋转,包裹一个纹理

    -

    纹理有重复、偏移和旋转纹理的设置。

    -

    默认情况下,three.js中的纹理是不重复的。要设置纹理是否重复,有2个属性,wrapS 用于水平包裹,wrapT 用于垂直包裹。

    -

    它们可以被设置为以下其中一个:

    -
      -
    • THREE.ClampToEdgeWrapping

      -

      每条边上的最后一个像素无限重复。

      -
    • -
    • THREE.RepeatWrapping

      -

      纹理重复

      -
    • -
    • THREE.MirroredRepeatWrapping

      -

      在每次重复时将进行镜像

      -
    • -
    -

    比如说,要开启两个方向的包裹。

    -
    someTexture.wrapS = THREE.RepeatWrapping;
    -someTexture.wrapT = THREE.RepeatWrapping;
    -
    -

    重复是用[repeat]重复属性设置的。

    -
    const timesToRepeatHorizontally = 4;
    -const timesToRepeatVertically = 2;
    -someTexture.repeat.set(timesToRepeatHorizontally, timesToRepeatVertically);
    -
    -

    纹理的偏移可以通过设置 offset 属性来完成。纹理的偏移是以单位为单位的,其中1个单位=1个纹理大小。换句话说,0 = 没有偏移,1 = 偏移一个完整的纹理数量。

    -
    const xOffset = .5;   // offset by half the texture
    -const yOffset = .25;  // offset by 1/4 the texture
    -someTexture.offset.set(xOffset, yOffset);
    -
    -

    通过设置以弧度为单位的 rotation 属性以及用于选择旋转中心的 center 属性,可以设置纹理的旋转。它的默认值是0,0,从左下角开始旋转。像偏移一样,这些单位是以纹理大小为单位的,所以将它们设置为 .5.5 将会围绕纹理中心旋转。

    -
    someTexture.center.set(.5, .5);
    -someTexture.rotation = THREE.MathUtils.degToRad(45);
    -
    -

    让我们修改一下上面的示例,来试试这些属性吧

    -

    首先,我们要保留一个对纹理的引用,这样我们就可以对它进行操作。

    -
    +const texture = loader.load('resources/images/wall.jpg');
    -const material = new THREE.MeshBasicMaterial({
    --  map: loader.load('resources/images/wall.jpg');
    -+  map: texture,
    -});
    -
    -

    然后,我们会再次使用 lil-gui 来提供一个简单的界面。

    -
    import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
    -
    -

    正如我们在之前的lil-gui例子中所做的那样,我们将使用一个简单的类来给lil-gui提供一个可以以度数为单位进行操作的对象,但它将以弧度为单位设置该属性。

    -
    class DegRadHelper {
    -  constructor(obj, prop) {
    -    this.obj = obj;
    -    this.prop = prop;
    -  }
    -  get value() {
    -    return THREE.MathUtils.radToDeg(this.obj[this.prop]);
    -  }
    -  set value(v) {
    -    this.obj[this.prop] = THREE.MathUtils.degToRad(v);
    -  }
    -}
    -
    -

    我们还需要一个类,将 "123" 这样的字符串转换为 123 这样的数字,因为three.js的枚举设置需要数字,比如 wrapSwrapT,但lil-gui只使用字符串来设置枚举。

    -
    class StringToNumberHelper {
    -  constructor(obj, prop) {
    -    this.obj = obj;
    -    this.prop = prop;
    -  }
    -  get value() {
    -    return this.obj[this.prop];
    -  }
    -  set value(v) {
    -    this.obj[this.prop] = parseFloat(v);
    -  }
    -}
    -
    -

    利用这些类,我们可以为上面的设置设置一个简单的GUI。

    -
    const wrapModes = {
    -  'ClampToEdgeWrapping': THREE.ClampToEdgeWrapping,
    -  'RepeatWrapping': THREE.RepeatWrapping,
    -  'MirroredRepeatWrapping': THREE.MirroredRepeatWrapping,
    -};
    -
    -function updateTexture() {
    -  texture.needsUpdate = true;
    -}
    -
    -const gui = new GUI();
    -gui.add(new StringToNumberHelper(texture, 'wrapS'), 'value', wrapModes)
    -  .name('texture.wrapS')
    -  .onChange(updateTexture);
    -gui.add(new StringToNumberHelper(texture, 'wrapT'), 'value', wrapModes)
    -  .name('texture.wrapT')
    -  .onChange(updateTexture);
    -gui.add(texture.repeat, 'x', 0, 5, .01).name('texture.repeat.x');
    -gui.add(texture.repeat, 'y', 0, 5, .01).name('texture.repeat.y');
    -gui.add(texture.offset, 'x', -2, 2, .01).name('texture.offset.x');
    -gui.add(texture.offset, 'y', -2, 2, .01).name('texture.offset.y');
    -gui.add(texture.center, 'x', -.5, 1.5, .01).name('texture.center.x');
    -gui.add(texture.center, 'y', -.5, 1.5, .01).name('texture.center.y');
    -gui.add(new DegRadHelper(texture, 'rotation'), 'value', -360, 360)
    -  .name('texture.rotation');
    -
    -

    最后需要注意的是,如果你改变了纹理上的 wrapSwrapT,你还必须设置 texture.needsUpdate,以便three.js知道并应用这些设置。其他的设置会自动应用。

    -

    - -

    -

    这只是进入纹理主题的一个步骤。在某些时候,我们将介绍纹理坐标以及其他9种可应用于材料的纹理类型。

    -

    现在我们继续说说灯光

    - -

    - - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/tips.html b/manual/zh/tips.html deleted file mode 100644 index 54763b2fd50638..00000000000000 --- a/manual/zh/tips.html +++ /dev/null @@ -1,339 +0,0 @@ - - - 小技巧 - - - - - - - - - - - - - -
    -
    -

    Tips

    -
    -
    -
    -

    本文中我们总结了一些在使用three.js过程中可能会遇到的但又看起来不需要各自列出一章的小问题。

    -
    -

    -

    canvas截图

    -

    在浏览器中存在两种有效的方式进行截图。 -旧的 -canvas.toDataURL -与新的更好的 -canvas.toBlob

    -

    所以你可能认为仅通过添加下列代码即可轻松实现截图功能

    -
    <canvas id="c"></canvas>
    -+<button id="screenshot" type="button">Save...</button>
    -
    -
    const elem = document.querySelector('#screenshot');
    -elem.addEventListener('click', () => {
    -  canvas.toBlob((blob) => {
    -    saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`);
    -  });
    -});
    -
    -const saveBlob = (function() {
    -  const a = document.createElement('a');
    -  document.body.appendChild(a);
    -  a.style.display = 'none';
    -  return function saveData(blob, fileName) {
    -     const url = window.URL.createObjectURL(blob);
    -     a.href = url;
    -     a.download = fileName;
    -     a.click();
    -  };
    -}());
    -
    -

    下面是来自介绍 响应式设计 -并添加了上述代码与一些放置按钮的CSS的例子。

    -

    - -

    -

    当我尝试截图得到了如下图片

    -
    - -

    是的,就是一张纯黑的图片而已。

    -

    取决于你的浏览器与系统的不同这个例子也有可能会正常生效,但是一般情况下这个例子是无法正常生效的。

    -

    这个问题的出现是因为基于性能和兼容性的考量,默认情况下浏览器会在绘制完成后清除WebGL canvas的缓存。

    -

    解决方案是在你捕获截图前调用一次渲染代码。

    -

    在我们的代码里我们只要进行小幅度调整即可。首先,分离出我们的渲染代码

    -
    +const state = {
    -+  time: 0,
    -+};
    -
    --function render(time) {
    --  time *= 0.001;
    -+function render() {
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -  cubes.forEach((cube, ndx) => {
    -    const speed = 1 + ndx * .1;
    --    const rot = time * speed;
    -+    const rot = state.time * speed;
    -    cube.rotation.x = rot;
    -    cube.rotation.y = rot;
    -  });
    -
    -  renderer.render(scene, camera);
    -
    --  requestAnimationFrame(render);
    -}
    -
    -+function animate(time) {
    -+  state.time = time * 0.001;
    -+
    -+  render();
    -+
    -+  requestAnimationFrame(animate);
    -+}
    -+requestAnimationFrame(animate);
    -
    -

    现在 render 方法只与实际的渲染过程相关联了。我们可以在刚好要捕获canvas截图前调用它。

    -
    const elem = document.querySelector('#screenshot');
    -elem.addEventListener('click', () => {
    -+  render();
    -  canvas.toBlob((blob) => {
    -    saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`);
    -  });
    -});
    -
    -

    现在应该能正常生效了。

    -

    - -

    有关其他解决方案,请参阅下一项。

    -
    - -

    -

    防止canvas被清空

    -

    如果你想要让用户使用动画对象进行绘图。你需要在创建 WebGLRenderer 的时候传入 preserveDrawingBuffer: true。这将阻止浏览器清理canvas。类似的,你也需要告诉three.js不要自动清理canvas。

    -
    const canvas = document.querySelector('#c');
    --const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -+const renderer = new THREE.WebGLRenderer({
    -+  canvas,
    -+  preserveDrawingBuffer: true,
    -+  alpha: true,
    -+});
    -+renderer.autoClearColor = false;
    -
    -

    - -

    -

    需要注意的是如果你确实需要制作一个画图程序的话这并不能解决你的问题,因为浏览器仍然会改变分辨率的时候随时有可能清空canvas。我们目前的方案是让canvas的分辨率跟随显示大小的改变。而canvas的显示大小也在随着窗口大小变化。这包括了即便用户在另一个标签页中下载了一个文件,浏览器添加了一个状态栏的情况。也包括了用户转动手机时浏览器从纵向切换至横向布局的情况

    -

    如果你切实需要制作一个绘图的程序,你可以 -使用渲染目标的方式渲染到纹理上

    -
    -

    -

    获取键盘输入

    -

    在这些教程中,我们通常会将事件监听器绑定到canvas上 canvas。 -虽然许多事件都能生效,但是默认情况下键盘事件不会正常响应。

    -

    为了获取键盘事件,我们将canvas的 tabindex -属性设置为0或更高。如下。

    -
    <canvas tabindex="0"></canvas>
    -
    -

    这将导致一个新的问题,任何设置了 tabindex 的元素会在聚焦的时候突出显示。为了解决这个问题,我们在CSS中将它focus状态下的outline属性设置为none

    -
    canvas:focus {
    -  outline:none;
    -}
    -
    -

    这里为了演示使用了3个canvas

    -
    <canvas id="c1"></canvas>
    -<canvas id="c2" tabindex="0"></canvas>
    -<canvas id="c3" tabindex="1"></canvas>
    -
    -

    并且只为最后一个canvas设置css

    -
    #c3:focus {
    -    outline: none;
    -}
    -
    -

    让我们用同样的事件监听器分别与它们相关联

    -
    document.querySelectorAll('canvas').forEach((canvas) => {
    -  const ctx = canvas.getContext('2d');
    -
    -  function draw(str) {
    -    ctx.clearRect(0, 0, canvas.width, canvas.height);
    -    ctx.textAlign = 'center';
    -    ctx.textBaseline = 'middle';
    -    ctx.fillText(str, canvas.width / 2, canvas.height / 2);
    -  }
    -  draw(canvas.id);
    -
    -  canvas.addEventListener('focus', () => {
    -    draw('has focus press a key');
    -  });
    -
    -  canvas.addEventListener('blur', () => {
    -    draw('lost focus');
    -  });
    -
    -  canvas.addEventListener('keydown', (e) => {
    -    draw(`keyCode: ${e.keyCode}`);
    -  });
    -});
    -
    -

    请注意,你无法让第一个canvas接收到键盘输入。第二个canvas虽然能接收到输入但是被突出显示了。第三个canvas同时解决了这这两个问题。

    -

    - -

    -
    -

    -

    透明化canvas

    -

    默认情况下THREE.js让canvas显示为不透明。如果你需要让canvas变得透明可以在创建 WebGLRenderer 的时候传入 alpha:true

    -
    const canvas = document.querySelector('#c');
    --const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -+const renderer = new THREE.WebGLRenderer({
    -+  canvas,
    -+  alpha: true,
    -+});
    -
    -

    你可能还想告诉它你的结果 使用 premultiplied alpha

    -
    const canvas = document.querySelector('#c');
    -const renderer = new THREE.WebGLRenderer({
    -  canvas,
    -  alpha: true,
    -+  premultipliedAlpha: false,
    -});
    -
    -

    Three.js 使用 -premultipliedAlpha: true 作为canvas的缺省值,但使用 premultipliedAlpha: false 作为材质的缺省值。

    -

    如果你想要更好的理解premultiplied alpha的使用与否,这里有一篇关于这个问题的好文章

    -

    不管怎样,让我们用透明canvas来设置一个简单的例子。

    -

    我们将上述配置应用到来自关于响应式设计的文章里的例子。让我们也将材质变得更透明。

    -
    function makeInstance(geometry, color, x) {
    --  const material = new THREE.MeshPhongMaterial({color});
    -+  const material = new THREE.MeshPhongMaterial({
    -+    color,
    -+    opacity: 0.5,
    -+  });
    -
    -...
    -
    -

    并且添加一些HTML内容

    -
    <body>
    -  <canvas id="c"></canvas>
    -+  <div id="content">
    -+    <div>
    -+      <h1>Cubes-R-Us!</h1>
    -+      <p>We make the best cubes!</p>
    -+    </div>
    -+  </div>
    -</body>
    -
    -

    还有一些将画布放置到前面的CSS

    -
    body {
    -    margin: 0;
    -}
    -#c {
    -    width: 100%;
    -    height: 100%;
    -    display: block;
    -+    position: fixed;
    -+    left: 0;
    -+    top: 0;
    -+    z-index: 2;
    -+    pointer-events: none;
    -}
    -+#content {
    -+  font-size: 7vw;
    -+  font-family: sans-serif;
    -+  text-align: center;
    -+  width: 100%;
    -+  height: 100%;
    -+  display: flex;
    -+  justify-content: center;
    -+  align-items: center;
    -+}
    -
    -

    注意 pointer-events: none 使得canvas不响应鼠标与触摸事件,以至于你能够选中下面的文字。

    -

    - -

    -
    -

    -

    使用three.js动画作为背景

    -

    一个常见的问题是如何使用three.js动画作为网站的背景。

    -

    这有两种显而易见的方法。

    -
      -
    • 将canvas的CSS position 属性如下设置为 fixed
    • -
    -
    #c {
    - position: fixed;
    - left: 0;
    - top: 0;
    - ...
    -}
    -
    -

    你可简单的在上一个的例子里使用这个解决方案。只需要将 z-index 设为 -1 -就可以看到立方体们显示到文字后面。

    -

    这个解决方案存在一个小缺点,那就是你的Javascript必须集成在页面中。而且如果你的页面实现很复杂的话,你需要保证页面里的three.js可视化代码不与实现其他功能的代码相冲突。

    -
      -
    • 使用 iframe
    • -
    -

    这种解决方案被应用在了 本站首页.

    -

    在你的网页种只需要插入一个iframe,像这样

    -
    <iframe id="background" src="responsive.html">
    -<div>
    -  Your content goes here.
    -</div>
    -
    -

    然后修改样式使其填满窗口,并且处于背景中。这几乎和我们之前用到的canvas样式代码一样。只不过因为iframe存在默认边框,我们需要额外将 border 设为 none

    -
    #background {
    -    position: fixed;
    -    width: 100%;
    -    height: 100%;
    -    left: 0;
    -    top: 0;
    -    z-index: -1;
    -    border: none;
    -    pointer-events: none;
    -}
    -

    - -

    - -
    -
    -
    - - - - - - - - \ No newline at end of file diff --git a/manual/zh/transparency.html b/manual/zh/transparency.html deleted file mode 100644 index 7c037a6606cb0c..00000000000000 --- a/manual/zh/transparency.html +++ /dev/null @@ -1,363 +0,0 @@ - - - Transparency - - - - - - - - - - - - - -
    -
    -

    透明

    -
    -
    -
    -

    在three.js中,透明很简单,也很困难。

    -

    首先,我们来看简单的部分。让我们来制作一个包含8个立方体的场景,它们呈2 * 2 * 2网格排布。

    -

    我们从按需渲染文章中的例子开始。例子中原来有3个方格,现在修改到8个。 - 首先改变makeInstance函数,接收x、y、z参数。

    -
    -function makeInstance(geometry, color) {
    -+function makeInstance(geometry, color, x, y, z) {
    -  const material = new THREE.MeshPhongMaterial({color});
    -
    -  const cube = new THREE.Mesh(geometry, material);
    -  scene.add(cube);
    -
    --  cube.position.x = x;
    -+  cube.position.set(x, y, z);
    -
    -  return cube;
    -}
    -
    -

    然后我们来创建8个立方体。

    -
    +function hsl(h, s, l) {
    -+  return (new THREE.Color()).setHSL(h, s, l);
    -+}
    -
    --makeInstance(geometry, 0x44aa88,  0);
    --makeInstance(geometry, 0x8844aa, -2);
    --makeInstance(geometry, 0xaa8844,  2);
    -
    -+{
    -+  const d = 0.8;
    -+  makeInstance(geometry, hsl(0 / 8, 1, .5), -d, -d, -d);
    -+  makeInstance(geometry, hsl(1 / 8, 1, .5),  d, -d, -d);
    -+  makeInstance(geometry, hsl(2 / 8, 1, .5), -d,  d, -d);
    -+  makeInstance(geometry, hsl(3 / 8, 1, .5),  d,  d, -d);
    -+  makeInstance(geometry, hsl(4 / 8, 1, .5), -d, -d,  d);
    -+  makeInstance(geometry, hsl(5 / 8, 1, .5),  d, -d,  d);
    -+  makeInstance(geometry, hsl(6 / 8, 1, .5), -d,  d,  d);
    -+  makeInstance(geometry, hsl(7 / 8, 1, .5),  d,  d,  d);
    -+}
    -
    -

    我也调整了摄像机。

    -
    const fov = 75;
    -const aspect = 2;  // the canvas default
    -const near = 0.1;
    --const far = 5;
    -+const far = 25;
    -const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    --camera.position.z = 4;
    -+camera.position.z = 2;
    -
    -

    将背景色调整为白色。

    -
    const scene = new THREE.Scene();
    -+scene.background = new THREE.Color('white');
    -
    -

    还添加了第二个灯光,这样立方体的所有面都可以被照亮。

    -
    -{
    -+function addLight(...pos) {
    -  const color = 0xFFFFFF;
    -  const intensity = 1;
    -  const light = new THREE.DirectionalLight(color, intensity);
    --  light.position.set(-1, 2, 4);
    -+  light.position.set(...pos);
    -  scene.add(light);
    -}
    -+addLight(-1, 2, 4);
    -+addLight( 1, -1, -2);
    -
    -

    让立方体变得透明,我们只需要设置transparent和 - opacity。opacity为1,物体完全不透明,opacity为0,物体将完全透明。

    -
    function makeInstance(geometry, color, x, y, z) {
    --  const material = new THREE.MeshPhongMaterial({color});
    -+  const material = new THREE.MeshPhongMaterial({
    -+    color,
    -+    opacity: 0.5,
    -+    transparent: true,
    -+  });
    -
    -  const cube = new THREE.Mesh(geometry, material);
    -  scene.add(cube);
    -
    -  cube.position.set(x, y, z);
    -
    -  return cube;
    -}
    -
    -

    然后,我们就得到了8个透明的立方体。

    -

    - -

    -

    在例子中拖拉,来旋转视图。

    -

    这好像很简单,但是拉近一些看。立方体背后的面好像消失了。

    -
    -
    没有后面的面
    - -

    我们在材质文章中学习了side材质属性。 - 那么,让我们将side属性设置为THREE.DoubleSide来让每个立方体的所有面都被绘制。

    -
    const material = new THREE.MeshPhongMaterial({
    -  color,
    -  map: loader.load(url),
    -  opacity: 0.5,
    -  transparent: true,
    -+  side: THREE.DoubleSide,
    -});
    -
    -

    然后我们得到了

    -

    - -

    -

    试试看,看起来好像起作用了,我们能看到后面的那些面。不过在更近距离的查看中,有些时候还是看不到。

    -
    -
    每个立方体的左后面都消失了
    - -

    这种情况之所以会发生,是因为3d物体的一般性绘制方式。对于每个几何体,一次绘制一个三角形。 - 当三角形的一个像素在被绘制的时候,会记录两件事情。一是像素的颜色,二是像素的深度。当下一个三角形被绘制的时候,对于深度大于先前被记录的深度的像素,将不会被绘制。

    -

    这种方式,对于不透明的物体工作得很好。不过,对于透明的物体不能正常工作。

    -

    这个问题的解决方案是将透明的物体进行排序,排在后面的物体比排在前面的物体先绘制。 - THREE.js对于物体,比如Mesh就是这样做的, - 否则上面第一个关于立方体的例子将会失败,因为一些立方体遮挡住了其它的立方体。不幸的是,为一个个的三角形进行排序将会十分的慢。

    -

    每个立方体有12个三角形,每个面有2个。三角形绘制的顺序和在几何体中构建的顺序是一致的, - 取决于我们从哪个方向看向这些三角形,距离摄像机近一些的先被绘制。因此,在后面的那些三角形不会被绘制。这就是我们看不到后面的面的原因。

    -

    对于一个凸状物体,比如球体或是立方体,一种解决方案是将每一个立方体添加到场景中两次。一次带有仅绘制后面三角形的材质,另外一次带有仅绘制前面三角形的材质。

    -
    function makeInstance(geometry, color, x, y, z) {
    -+  [THREE.BackSide, THREE.FrontSide].forEach((side) => {
    -    const material = new THREE.MeshPhongMaterial({
    -      color,
    -      opacity: 0.5,
    -      transparent: true,
    -+      side,
    -    });
    -
    -    const cube = new THREE.Mesh(geometry, material);
    -    scene.add(cube);
    -
    -    cube.position.set(x, y, z);
    -+  });
    -}
    -
    -

    上面的办法好像可以工作。

    -

    - -

    - 它假定了three.js的排序是稳定的,意味着因为我们先添加了side: THREE.BackSide 的物体,还因为两个物体在同样的位置,这个物体将会在 - side: THREE.FrontSide 的物体之前被绘制。 -

    让我们制作2个相交的平面(删除了所有和立方体相关的代码)。 - 我们将会给每个平面添加纹理

    -
    const planeWidth = 1;
    -const planeHeight = 1;
    -const geometry = new THREE.PlaneGeometry(planeWidth, planeHeight);
    -
    -const loader = new THREE.TextureLoader();
    -
    -function makeInstance(geometry, color, rotY, url) {
    -  const texture = loader.load(url, render);
    -  const material = new THREE.MeshPhongMaterial({
    -    color,
    -    map: texture,
    -    opacity: 0.5,
    -    transparent: true,
    -    side: THREE.DoubleSide,
    -  });
    -
    -  const mesh = new THREE.Mesh(geometry, material);
    -  scene.add(mesh);
    -
    -  mesh.rotation.y = rotY;
    -}
    -
    -makeInstance(geometry, 'pink',       0,             'resources/images/happyface.png');
    -makeInstance(geometry, 'lightblue',  Math.PI * 0.5, 'resources/images/hmmmface.png');
    -
    - 这次我们可以使用side: THREE.DoubleSide因为同一时间我们只能看到一个平面的一个面。也请注意到我们将render - 函数传递到了纹理加载函数中这样当纹理加载完成的时候,可以重新渲染场景。这是因为这个例子是使用 按需渲染代替了持续渲染。 -

    - -

    -

    我们又一次的看到了类似的问题。

    -
    -
    一半的脸消失不见了
    - -

    这里的解决方案是手动的将每个平面分割为2个,这样它们实际上就没有了交集。

    -
    function makeInstance(geometry, color, rotY, url) {
    -+  const base = new THREE.Object3D();
    -+  scene.add(base);
    -+  base.rotation.y = rotY;
    -
    -+  [-1, 1].forEach((x) => {
    -    const texture = loader.load(url, render);
    -+    texture.offset.x = x < 0 ? 0 : 0.5;
    -+    texture.repeat.x = .5;
    -    const material = new THREE.MeshPhongMaterial({
    -      color,
    -      map: texture,
    -      opacity: 0.5,
    -      transparent: true,
    -      side: THREE.DoubleSide,
    -    });
    -
    -    const mesh = new THREE.Mesh(geometry, material);
    --    scene.add(mesh);
    -+    base.add(mesh);
    -
    --    mesh.rotation.y = rotY;
    -+    mesh.position.x = x * .25;
    -  });
    -}
    -
    -

    你如何完成取决于你。如果我在使用的是Blender这样的模型包,我可能会手动的调整纹理的坐标。这里我们使用的是PlaneGeometry,默认情况下会将纹理拉伸到整个平面。像我们前面讲到过的, - 通过设置 texture.repeattexture.offset,我们可以放缩和移动纹理,在每个平面上得到正确的一半脸的纹理。

    -

    上面的代码生成了一个Object3D对象,并且设置为2个平面的parent。旋转一个父级 - Object3D 所需要的数学要比没有它时简单一些。

    -

    - -

    -

    这种解决方案真的只能用于像2个不会改变相交位置的简单物体。

    -

    对于添加了纹理的物体,还有一种解决方案是设置alpha测试。

    -

    Alpha测试是指像素的alpha值低于某个水平的时候,three.js就不会绘制它。如果我们根本就不绘制某个像素,那么上面提到的深度问题就消失了。 - 对于具有相对尖锐边缘的纹理,这种方式工作得很好。例子中包含了树或植物上的叶子纹理或者一片草地。

    -

    让我们在两个平面上试一下。首先我们使用不同的纹理。上面的纹理都是100%不透明。现在2个纹理是透明的。

    -
    -
    -
    -
    - -

    回到那两个相交的平面(我们分割之前),让我们使用纹理并且设置alphaTest

    -
    function makeInstance(geometry, color, rotY, url) {
    -  const texture = loader.load(url, render);
    -  const material = new THREE.MeshPhongMaterial({
    -    color,
    -    map: texture,
    --    opacity: 0.5,
    -    transparent: true,
    -+    alphaTest: 0.5,
    -    side: THREE.DoubleSide,
    -  });
    -
    -  const mesh = new THREE.Mesh(geometry, material);
    -  scene.add(mesh);
    -
    -  mesh.rotation.y = rotY;
    -}
    -
    --makeInstance(geometry, 'pink',       0,             'resources/images/happyface.png');
    --makeInstance(geometry, 'lightblue',  Math.PI * 0.5, 'resources/images/hmmmface.png');
    -+makeInstance(geometry, 'white', 0,             'resources/images/tree-01.png');
    -+makeInstance(geometry, 'white', Math.PI * 0.5, 'resources/images/tree-02.png');
    -
    -

    在我们运行之前,让我们添加一点UI,这样我们可以更简单的测试alphaTest - 和 transparent 选项。我们将会使用在 - three'js中的场景图文章中介绍过的lil-gui。

    -

    首先我们为lil-gui创建一个辅助类来为场景中的每种材质设置值。

    -
    class AllMaterialPropertyGUIHelper {
    -  constructor(prop, scene) {
    -    this.prop = prop;
    -    this.scene = scene;
    -  }
    -  get value() {
    -    const {scene, prop} = this;
    -    let v;
    -    scene.traverse((obj) => {
    -      if (obj.material && obj.material[prop] !== undefined) {
    -        v = obj.material[prop];
    -      }
    -    });
    -    return v;
    -  }
    -  set value(v) {
    -    const {scene, prop} = this;
    -    scene.traverse((obj) => {
    -      if (obj.material && obj.material[prop] !== undefined) {
    -        obj.material[prop] = v;
    -        obj.material.needsUpdate = true;
    -      }
    -    });
    -  }
    -}
    -
    -

    然后我们来添加窗口。

    -
    const gui = new GUI();
    -gui.add(new AllMaterialPropertyGUIHelper('alphaTest', scene), 'value', 0, 1)
    -    .name('alphaTest')
    -    .onChange(requestRenderIfNotRequested);
    -gui.add(new AllMaterialPropertyGUIHelper('transparent', scene), 'value')
    -    .name('transparent')
    -    .onChange(requestRenderIfNotRequested);
    -
    -

    当然我们需要引用lil-gui。

    -
    import * as THREE from 'three';
    -import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    -+import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
    -
    -

    下面是结果。

    -

    - -

    -

    可以看到起作用了,但是当放大看的时候,你可以看到一个平面有白色的线条。

    -
    - -

    这也是我们上面提到的深度问题。那个平面先被绘制,因此后面的平面将不会被绘制。 - 没有完美的解决方案。调整alphaTest并且打开或关闭 - transparent来为你的场景寻找一个合适的解决方案。 -

    -

    从文章中可以知道,完美的透明是困难的,有着各种问题、取舍和变通方法。

    -

    举例来说,你有一辆车。汽车通常会在4个面上有挡风玻璃。如果你想要避免上面提到的排序问题, - 你可能不得不将每一扇窗户成为它自己的物体,以便three.js可以排序这些窗户并以正确的顺序绘制它们。 -

    -

    如果你在制作一些植物或是草地,alpha测试是常用的解决方案。

    -

    采用那种方案取决于你的需求。

    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/uniform-types.html b/manual/zh/uniform-types.html deleted file mode 100644 index 8b02c046a88c14..00000000000000 --- a/manual/zh/uniform-types.html +++ /dev/null @@ -1,241 +0,0 @@ - - - Uniform 类型 - - - - - - - - - - - - - -
    -
    -

    Uniform 类型

    -
    -
    -
    - -

    - 每个 uniform 都必须有一个 `value` 属性。其值的类型必须与 GLSL 代码中 uniform 变量的类型相对应,具体的 GLSL 基本类型对应关系见下表。Uniform 结构体和数组同样受支持。基本类型的 GLSL 数组必须指定为对应 THREE 对象的数组,或者包含所有对象数据的扁平数组。换言之,GLSL 基本类型本身不应再嵌套一层数组。此规则不具有传递性。例如,一个由 `vec2` 数组组成的数组,每个子数组包含五个向量,则必须是一个数组的数组,其中每个元素可以是五个 `Vector2` 对象或十个 `number`。 -

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    GLSL 类型JavaScript 类型
    intNumber
    uintNumber
    floatNumber
    boolBoolean
    boolNumber
    vec2Vector2
    vec2Float32Array (*)
    vec2Array (*)
    vec3Vector3
    vec3Color
    vec3Float32Array (*)
    vec3Array (*)
    vec4Vector4
    vec4Quaternion
    vec4Float32Array (*)
    vec4Array (*)
    mat2Float32Array (*)
    mat2Array (*)
    mat3Matrix3
    mat3Float32Array (*)
    mat3Array (*)
    mat4Matrix4
    mat4Float32Array (*)
    mat4Array (*)
    ivec2, bvec2Float32Array (*)
    ivec2, bvec2Array (*)
    ivec3, bvec3Int32Array (*)
    ivec3, bvec3Array (*)
    ivec4, bvec4Int32Array (*)
    ivec4, bvec4Array (*)
    sampler2DTexture
    samplerCubeCubeTexture
    - -

    - (*) 对于相同 GLSL 类型的(最内层)数组(维度)同样适用,其中包含数组中所有向量或矩阵的分量。 -

    - -

    结构化 Uniform

    - -

    - 有时你可能希望在着色器代码中将 uniform 组织为 `struct`(结构体)。必须使用以下方式,`three.js` 才能正确处理结构化的 uniform 数据。 -

    -
    -uniforms = {
    -  data: {
    -    value: {
    -      position: new Vector3(),
    -      direction: new Vector3( 0, 0, 1 )
    -    }
    -  }
    -};
    -
    - 此定义可以映射到以下 GLSL 代码: -
    -struct Data {
    -  vec3 position;
    -  vec3 direction;
    -};
    -uniform Data data;
    -
    - -

    带数组的结构化 Uniform

    - -

    - 也可以在数组中管理 `structs`。此用法的语法如下: -

    -
    -const entry1 = {
    -  position: new Vector3(),
    -  direction: new Vector3( 0, 0, 1 )
    -};
    -const entry2 = {
    -  position: new Vector3( 1, 1, 1 ),
    -  direction: new Vector3( 0, 1, 0 )
    -};
    -
    -uniforms = {
    -  data: {
    -    value: [ entry1, entry2 ]
    -  }
    -};
    -
    - 此定义可以映射到以下 GLSL 代码: -
    -struct Data {
    -  vec3 position;
    -  vec3 direction;
    -};
    -uniform Data data[ 2 ];
    -
    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/useful-links.html b/manual/zh/useful-links.html deleted file mode 100644 index 7eefb0ad46e89e..00000000000000 --- a/manual/zh/useful-links.html +++ /dev/null @@ -1,190 +0,0 @@ - - - 相关资源 - - - - - - - - - - - - - -
    -
    -

    相关资源

    -
    -
    -
    - -

    - 以下是一些在学习 three.js 时可能对你有用的链接合集。
    - 如果你想在此添加内容,或者认为以下某个链接已经过时或失效,请点击右下角的"编辑"按钮进行修改!

    - - 另外请注意,由于 three.js 处于快速开发中,其中许多链接可能包含过时的信息——如果某些内容未按预期工作,或与这些链接中描述的不一致,请检查浏览器控制台中的警告或错误,同时查阅相关文档页面。 -

    - -

    帮助论坛

    -

    - Three.js 官方使用[link:https://discourse.threejs.org/ 论坛]和 [link:http://stackoverflow.com/tags/three.js/info Stack Overflow] 来处理帮助请求。如果你需要帮助,请前往这些平台。请不要在 GitHub 上提交 issue 来寻求帮助。 -

    - -

    教程与课程

    - -

    three.js 入门

    -
      -
    • - [link:https://threejs.org/manual/#en/fundamentals Three.js 基础入门课程] -
    • -
    • - [link:https://codepen.io/rachsmith/post/beginning-with-3d-webgl-pt-1-the-scene Beginning with 3D WebGL],作者 [link:https://codepen.io/rachsmith/ Rachel Smith]。 -
    • -
    • - [link:https://www.august.com.au/blog/animating-scenes-with-webgl-three-js/ Animating scenes with WebGL and three.js] -
    • -
    - -

    更深入/进阶的文章与课程

    -
      -
    • - [link:https://threejs-journey.com/ Three Journey] 课程,作者 [link:https://bruno-simon.com/ Bruno Simon] - 手把手教初学者使用 Three.js -
    • -
    • - [link:https://discoverthreejs.com/ Discover three.js] -
    • -
    • - [link:http://blog.cjgammon.com/ 系列教程],作者 [link:http://www.cjgammon.com/ CJ Gammon]。 -
    • -
    • - [link:https://medium.com/soffritti.pierfrancesco/glossy-spheres-in-three-js-bfd2785d4857 Glossy spheres in three.js]。 -
    • -
    • - [link:https://www.udacity.com/course/interactive-3d-graphics--cs291 Interactive 3D Graphics] - Udacity 上的免费课程,讲授 3D 图形学基础,使用 three.js 作为编程工具。 -
    • -
    • - [Link:https://aerotwist.com/tutorials/ Aerotwist] 教程,作者 [link:https://github.com/paullewis/ Paul Lewis]。 -
    • -
    • - [link:https://discourse.threejs.org/t/three-js-bookshelf/2468 Three.js 书架] - 想找更多关于 three.js 或计算机图形学的资源?来看看社区推荐的书单吧。 -
    • -
    - -

    新闻与动态

    -
      -
    • - [link:https://twitter.com/hashtag/threejs Three.js on Twitter] -
    • -
    • - [link:http://www.reddit.com/r/threejs/ Three.js on reddit] -
    • -
    • - [link:http://www.reddit.com/r/webgl/ WebGL on reddit] -
    • -
    - -

    示例

    -
      -
    • - [link:https://github.com/edwinwebb/three-seed/ three-seed] - 使用 ES6 和 Webpack 的 three.js 起步项目 -
    • -
    • - [link:http://stemkoski.github.io/Three.js/index.html Professor Stemkoski 的示例] - 使用 three.js r60 构建的适合初学者的示例合集。 -
    • -
    • - [link:https://threejs.org/examples/ three.js 官方示例] - 这些示例作为 three.js 仓库的一部分维护,始终使用最新版本的 three.js。 -
    • -
    • - [link:https://raw.githack.com/mrdoob/three.js/dev/examples/ three.js 官方开发分支示例] - 与上面相同,但使用的是 three.js 的开发分支,用于在开发过程中检查一切是否正常工作。 -
    • -
    - -

    工具

    -
      -
    • - [link:https://github.com/tbensky/physgl physgl.org] - 基于 three.js 封装的 JavaScript 前端,为学习物理和数学的学生提供 WebGL 图形支持。 -
    • -
    • - [link:https://whsjs.readme.io/ Whitestorm.js] – 模块化的 three.js 框架,带有 AmmoNext 物理插件。 -
    • -
    • - [link:http://zz85.github.io/zz85-bookmarklets/threelabs.html Three.js Inspector] -
    • -
    • - [link:http://idflood.github.io/ThreeNodes.js/ ThreeNodes.js]。 -
    • -
    • - [link:https://marketplace.visualstudio.com/items?itemName=slevesque.shader vscode shader] - 着色器语言语法高亮。 -
      - [link:https://marketplace.visualstudio.com/items?itemName=bierner.comment-tagged-templates vscode comment-tagged-templates] - 使用注释为标签模板字符串提供着色器语言语法高亮,如 glsl.js。 -
    • -
    • - [link:https://github.com/MozillaReality/WebXR-emulator-extension WebXR-emulator-extension] -
    • -
    - -

    WebGL 参考

    -
      -
    • - [link:https://www.khronos.org/files/webgl/webgl-reference-card-1_0.pdf webgl-reference-card.pdf] - 包含所有 WebGL 和 GLSL 关键字、术语、语法和定义的参考卡片。 -
    • -
    - -

    旧链接

    -

    - 这些链接出于历史原因保留——你可能仍然会觉得它们有用,但请注意其中的信息可能涉及非常旧的 three.js 版本。 -

    - -
      -
    • - [link:https://www.youtube.com/watch?v=Dir4KO9RdhM AlterQualia at WebGL Camp 3] -
    • -
    • - [link:http://yomotsu.github.io/threejs-examples/ Yomotsu 的示例] - 使用 three.js r45 的示例合集。 -
    • -
    • - [link:http://fhtr.org/BasicsOfThreeJS/#1 Introduction to Three.js],作者 [link:http://github.com/kig/ Ilmari Heikkinen](幻灯片)。 -
    • -
    • - [link:http://www.slideshare.net/yomotsu/webgl-and-threejs WebGL and Three.js],作者 [link:http://github.com/yomotsu Akihiro Oyamada](幻灯片)。 -
    • -
    • - [link:https://www.youtube.com/watch?v=VdQnOaolrPA Trigger Rally],作者 [link:https://github.com/jareiko jareiko](视频)。 -
    • -
    • - [link:http://blackjk3.github.io/threefab/ ThreeFab] - 场景编辑器,维护至 three.js r50 左右。 -
    • -
    • - [link:http://bkcore.com/blog/3d/webgl-three-js-workflow-tips.html Max to Three.js workflow tips and tricks],作者 [link:https://github.com/BKcore BKcore] -
    • -
    • - [link:http://12devsofxmas.co.uk/2012/01/webgl-and-three-js/ A whirlwind look at Three.js],作者 [link:http://github.com/nrocy Paul King] -
    • -
    • - [link:http://bkcore.com/blog/3d/webgl-three-js-animated-selective-glow.html Animated selective glow in Three.js],作者 [link:https://github.com/BKcore BKcore] -
    • -
    • - [link:http://www.natural-science.or.jp/article/20120220155529.php Building A Physics Simulation Environment] - 日语 three.js 教程 -
    • -
    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/voxel-geometry.html b/manual/zh/voxel-geometry.html deleted file mode 100644 index 77d1fab4c4df4f..00000000000000 --- a/manual/zh/voxel-geometry.html +++ /dev/null @@ -1,1075 +0,0 @@ - - - - - 体素(类似《我的世界》)几何体 - - - - - - - - - - - - - -
    -
    -

    体素(类似《我的世界》)几何体

    -
    -
    -
    -

    我在多个地方都看到过这个话题:“如何实现像《我的世界》那样的体素显示”。

    - -

    大多数人初次尝试时,会为每个体素位置创建一个立方体几何体,然后生成一个网格(mesh)。出于好奇,我也试了一下。我创建了一个包含 16777216 个元素的 Uint8Array 数组,用来表示一个 256x256x256 的体素立方体。

    - -
    const cellSize = 256;
    -const cell = new Uint8Array(cellSize * cellSize * cellSize);
    -
    - -

    然后我用正弦波生成了一层类似小山丘的地形,如下所示:

    - -
    for (let y = 0; y < cellSize; ++y) {
    -  for (let z = 0; z < cellSize; ++z) {
    -    for (let x = 0; x < cellSize; ++x) {
    -      const height = (Math.sin(x / cellSize * Math.PI * 4) + Math.sin(z / cellSize * Math.PI * 6)) * 20 + cellSize / 2;
    -      if (height > y && height < y + 1) {
    -        const offset = y * cellSize * cellSize +
    -                       z * cellSize +
    -                       x;
    -        cell[offset] = 1;
    -      }
    -    }
    -  }
    -}
    -
    - -

    接着我遍历所有体素,只要值不为 0,就创建一个立方体网格:

    - -
    const geometry = new THREE.BoxGeometry(1, 1, 1);
    -const material = new THREE.MeshPhongMaterial({color: 'green'});
    -
    -for (let y = 0; y < cellSize; ++y) {
    -  for (let z = 0; z < cellSize; ++z) {
    -    for (let x = 0; x < cellSize; ++x) {
    -      const offset = y * cellSize * cellSize +
    -                     z * cellSize +
    -                     x;
    -      const block = cell[offset];
    -      const mesh = new THREE.Mesh(geometry, material);
    -      mesh.position.set(x, y, z);
    -      scene.add(mesh);
    -    }
    -  }
    -}
    -
    - -

    其余代码基于 “按需渲染”一文中的示例。

    - -

    - -

    - -

    页面加载需要较长时间,如果你尝试移动摄像机,很可能非常卡顿。就像 “如何优化大量对象”一文中提到的,问题在于对象数量太多——仅 256x256 就有 65536 个方块!

    - -

    使用 “合并几何体” 技术可以解决本例的问题。但如果不仅仅是生成单层地形,而是将地面以下的所有空间都用体素填充呢?换句话说,将填充体素的循环修改如下:

    - -
    for (let y = 0; y < cellSize; ++y) {
    -  for (let z = 0; z < cellSize; ++z) {
    -    for (let x = 0; x < cellSize; ++x) {
    -      const height = (Math.sin(x / cellSize * Math.PI * 4) + Math.sin(z / cellSize * Math.PI * 6)) * 20 + cellSize / 2;
    --      if (height > y && height < y + 1) {
    -+      if (height < y + 1) {
    -        const offset = y * cellSize * cellSize +
    -                       z * cellSize +
    -                       x;
    -        cell[offset] = 1;
    -      }
    -    }
    -  }
    -}
    -
    -

    我尝试运行了一次,只是为了看看结果。程序运行了大约一分钟,然后因 内存不足 而崩溃了 😅

    - -

    这里存在多个问题,但最严重的是:我们生成了大量立方体内部的面片(faces),而这些面实际上永远不可见。

    - -

    换句话说,假设我们有一个 3x2x2 的体素方块。如果我们只是简单合并立方体,会得到如下结构:

    - -
    -
    -
    - -

    但实际上我们想要的是这个:

    - -
    -
    -
    - -

    在上方的盒子中,体素之间存在面片。这些面是完全浪费的,因为它们永远不可见。而且不只是每个体素之间一个面,实际上是两个面——每个体素朝向其邻居的那个面都是多余的。对于大量体素来说,这些额外的面会严重拖累性能。

    - -

    显然,我们不能简单地合并几何体。我们必须自己构建几何体,并考虑:如果一个体素有相邻的邻居,那么它就不需要朝向该邻居的那个面。

    - -

    下一个问题是:256x256x256 太大了。16 兆字节的内存占用已经很高,而且大部分空间其实是空的,造成了大量内存浪费。同时体素总数高达 1600 万个!一次性处理这么多数据是不现实的。

    - -

    解决方案是将区域划分为更小的区域。任何完全为空的区域都不需要存储。我们使用 32x32x32 的小区域(每个约 32KB),仅在其中有数据时才创建。我们将这种 32x32x32 的区域称为一个“单元”(cell)。

    - -

    让我们逐步实现。首先创建一个类来管理体素数据:

    - -
    class VoxelWorld {
    -  constructor(cellSize) {
    -    this.cellSize = cellSize;
    -  }
    -}
    -
    - -

    接下来编写一个为“单元”生成几何体的函数。假设你传入一个单元的坐标。例如,如果你想获取覆盖体素 (0-31x, 0-31y, 0-31z) 的单元的几何体,就传入 0,0,0;如果想获取覆盖 (32-63x, 0-31y, 0-31z) 的单元,则传入 1,0,0。

    - -

    我们需要能够检查相邻体素,因此假设我们的类有一个 getVoxel 方法,它接收体素坐标并返回该位置的体素值。例如,传入 35,0,0 且 cellSize 为 32 时,它会查找单元 (1,0,0),并在该单元中访问体素 (3,0,0)。通过这个方法,即使相邻体素位于其他单元中,我们也能正确访问。

    - -
    class VoxelWorld {
    -  constructor(cellSize) {
    -    this.cellSize = cellSize;
    -  }
    -+  generateGeometryDataForCell(cellX, cellY, cellZ) {
    -+    const {cellSize} = this;
    -+    const startX = cellX * cellSize;
    -+    const startY = cellY * cellSize;
    -+    const startZ = cellZ * cellSize;
    -+
    -+    for (let y = 0; y < cellSize; ++y) {
    -+      const voxelY = startY + y;
    -+      for (let z = 0; z < cellSize; ++z) {
    -+        const voxelZ = startZ + z;
    -+        for (let x = 0; x < cellSize; ++x) {
    -+          const voxelX = startX + x;
    -+          const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
    -+          if (voxel) {
    -+            for (const {dir} of VoxelWorld.faces) {
    -+              const neighbor = this.getVoxel(
    -+                  voxelX + dir[0],
    -+                  voxelY + dir[1],
    -+                  voxelZ + dir[2]);
    -+              if (!neighbor) {
    -+                // 该体素在此方向上没有邻居,因此需要生成一个面
    -+              }
    -+            }
    -+          }
    -+        }
    -+      }
    -+    }
    -+  }
    -}
    -
    -+VoxelWorld.faces = [
    -+  { // 左侧
    -+    dir: [ -1,  0,  0 ],
    -+  },
    -+  { // 右侧
    -+    dir: [  1,  0,  0 ],
    -+  },
    -+  { // 底部
    -+    dir: [  0, -1,  0 ],
    -+  },
    -+  { // 顶部
    -+    dir: [  0,  1,  0 ],
    -+  },
    -+  { // 背面
    -+    dir: [  0,  0, -1 ],
    -+  },
    -+  { // 前面
    -+    dir: [  0,  0,  1 ],
    -+  },
    -+];
    -
    - -

    通过上述代码,我们已经知道何时需要生成一个面。现在来实际生成这些面。

    - -
    class VoxelWorld {
    -  constructor(cellSize) {
    -    this.cellSize = cellSize;
    -  }
    -  generateGeometryDataForCell(cellX, cellY, cellZ) {
    -    const {cellSize} = this;
    -+    const positions = [];
    -+    const normals = [];
    -+    const indices = [];
    -    const startX = cellX * cellSize;
    -    const startY = cellY * cellSize;
    -    const startZ = cellZ * cellSize;
    -
    -    for (let y = 0; y < cellSize; ++y) {
    -      const voxelY = startY + y;
    -      for (let z = 0; z < cellSize; ++z) {
    -        const voxelZ = startZ + z;
    -        for (let x = 0; x < cellSize; ++x) {
    -          const voxelX = startX + x;
    -          const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
    -          if (voxel) {
    --            for (const {dir} of VoxelWorld.faces) {
    -+            for (const {dir, corners} of VoxelWorld.faces) {
    -              const neighbor = this.getVoxel(
    -                  voxelX + dir[0],
    -                  voxelY + dir[1],
    -                  voxelZ + dir[2]);
    -              if (!neighbor) {
    -                // 该体素在此方向上没有邻居,因此需要生成一个面
    -+                const ndx = positions.length / 3;
    -+                for (const pos of corners) {
    -+                  positions.push(pos[0] + x, pos[1] + y, pos[2] + z);
    -+                  normals.push(...dir);
    -+                }
    -+                indices.push(
    -+                  ndx, ndx + 1, ndx + 2,
    -+                  ndx + 2, ndx + 1, ndx + 3
    -+                );
    -              }
    -            }
    -          }
    -        }
    -      }
    -    }
    -+    return {
    -+      positions,
    -+      normals,
    -+      indices
    -+    };
    -  }
    -}
    -
    -VoxelWorld.faces = [
    -  { // 左侧
    -    dir: [ -1,  0,  0 ],
    -+    corners: [
    -+      [ 0, 1, 0 ],
    -+      [ 0, 0, 0 ],
    -+      [ 0, 1, 1 ],
    -+      [ 0, 0, 1 ]
    -+    ]
    -  },
    -  { // 右侧
    -    dir: [  1,  0,  0 ],
    -+    corners: [
    -+      [ 1, 1, 1 ],
    -+      [ 1, 0, 1 ],
    -+      [ 1, 1, 0 ],
    -+      [ 1, 0, 0 ]
    -+    ]
    -  },
    -  { // 底部
    -    dir: [  0, -1,  0 ],
    -+    corners: [
    -+      [ 1, 0, 1 ],
    -+      [ 0, 0, 1 ],
    -+      [ 1, 0, 0 ],
    -+      [ 0, 0, 0 ]
    -+    ]
    -  },
    -  { // 顶部
    -    dir: [  0,  1,  0 ],
    -+    corners: [
    -+      [ 0, 1, 1 ],
    -+      [ 1, 1, 1 ],
    -+      [ 0, 1, 0 ],
    -+      [ 1, 1, 0 ]
    -+    ]
    -  },
    -  { // 背面
    -    dir: [  0,  0, -1 ],
    -+    corners: [
    -+      [ 1, 0, 0 ],
    -+      [ 0, 0, 0 ],
    -+      [ 1, 1, 0 ],
    -+      [ 0, 1, 0 ]
    -+    ]
    -  },
    -  { // 前面
    -    dir: [  0,  0,  1 ],
    -+    corners: [
    -+      [ 0, 0, 1 ],
    -+      [ 1, 0, 1 ],
    -+      [ 0, 1, 1 ],
    -+      [ 1, 1, 1 ]
    -+    ]
    -  }
    -];
    -
    -

    上面的代码已经可以为我们生成基本的几何数据,我们只需要提供 getVoxel 函数即可。我们先从一个硬编码的单元开始实现。

    - -
    class VoxelWorld {
    -  constructor(cellSize) {
    -    this.cellSize = cellSize;
    -+    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
    -  }
    -+  getCellForVoxel(x, y, z) {
    -+    const {cellSize} = this;
    -+    const cellX = Math.floor(x / cellSize);
    -+    const cellY = Math.floor(y / cellSize);
    -+    const cellZ = Math.floor(z / cellSize);
    -+    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
    -+      return null;
    -+    }
    -+    return this.cell;
    -+  }
    -+  getVoxel(x, y, z) {
    -+    const cell = this.getCellForVoxel(x, y, z);
    -+    if (!cell) {
    -+      return 0;
    -+    }
    -+    const {cellSize} = this;
    -+    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
    -+    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
    -+    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
    -+    const voxelOffset = voxelY * cellSize * cellSize +
    -+                        voxelZ * cellSize +
    -+                        voxelX;
    -+    return cell[voxelOffset];
    -+  }
    -  generateGeometryDataForCell(cellX, cellY, cellZ) {
    -
    -  ...
    -}
    -
    - -

    这段代码看起来可以正常工作了。我们再添加一个 setVoxel 函数,以便可以设置一些体素数据。

    - -
    class VoxelWorld {
    -  constructor(cellSize) {
    -    this.cellSize = cellSize;
    -    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
    -  }
    -  getCellForVoxel(x, y, z) {
    -    const {cellSize} = this;
    -    const cellX = Math.floor(x / cellSize);
    -    const cellY = Math.floor(y / cellSize);
    -    const cellZ = Math.floor(z / cellSize);    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
    -      return null;
    -    }
    -    return this.cell;
    -  }
    -+  setVoxel(x, y, z, v) {
    -+    let cell = this.getCellForVoxel(x, y, z);
    -+    if (!cell) {
    -+      return;  // TODO: 是否应添加一个新单元?
    -+    }
    -+    const {cellSize} = this;
    -+    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
    -+    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
    -+    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
    -+    const voxelOffset = voxelY * cellSize * cellSize +
    -+                        voxelZ * cellSize +
    -+                        voxelX;
    -+    cell[voxelOffset] = v;
    -+  }
    -  getVoxel(x, y, z) {
    -    const cell = this.getCellForVoxel(x, y, z);
    -    if (!cell) {
    -      return 0;
    -    }
    -    const {cellSize} = this;
    -    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
    -    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
    -    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
    -    const voxelOffset = voxelY * cellSize * cellSize +
    -                        voxelZ * cellSize +
    -                        voxelX;
    -    return cell[voxelOffset];
    -  }
    -  generateGeometryDataForCell(cellX, cellY, cellZ) {
    -
    -  ...
    -}
    -
    - -

    嗯……我注意到有很多重复的代码。让我们重构一下,提高代码复用性。

    - -
    class VoxelWorld {
    -  constructor(cellSize) {
    -    this.cellSize = cellSize;
    -+    this.cellSliceSize = cellSize * cellSize;
    -    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
    -  }
    -  getCellForVoxel(x, y, z) {
    -    const {cellSize} = this;
    -    const cellX = Math.floor(x / cellSize);
    -    const cellY = Math.floor(y / cellSize);
    -    const cellZ = Math.floor(z / cellSize);
    -    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
    -      return null;
    -    }
    -    return this.cell;
    -  }
    -+  computeVoxelOffset(x, y, z) {
    -+    const {cellSize, cellSliceSize} = this;
    -+    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
    -+    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
    -+    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
    -+    return voxelY * cellSliceSize +
    -+           voxelZ * cellSize +
    -+           voxelX;
    -+  }
    -  setVoxel(x, y, z, v) {
    -    const cell = this.getCellForVoxel(x, y, z);
    -    if (!cell) {
    -      return;  // TODO: 是否应添加一个新单元?
    -    }
    --    const {cellSize} = this;
    --    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
    --    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
    --    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
    --    const voxelOffset = voxelY * cellSize * cellSize +
    --                        voxelZ * cellSize +
    --                        voxelX;
    -+    const voxelOffset = this.computeVoxelOffset(x, y, z);
    -    cell[voxelOffset] = v;
    -  }
    -  getVoxel(x, y, z) {
    -    const cell = this.getCellForVoxel(x, y, z);
    -    if (!cell) {
    -      return 0;
    -    }
    --    const {cellSize} = this;
    --    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
    --    const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
    --    const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
    --    const voxelOffset = voxelY * cellSize * cellSize +
    --                        voxelZ * cellSize +
    --                        voxelX;
    -+    const voxelOffset = this.computeVoxelOffset(x, y, z);
    -    return cell[voxelOffset];
    -  }
    -  generateGeometryDataForCell(cellX, cellY, cellZ) {
    -
    -  ...
    -}
    -
    - -

    现在我们来编写代码,用体素填充第一个单元。

    - -
    const cellSize = 32;
    -
    -const world = new VoxelWorld(cellSize);
    -
    -for (let y = 0; y < cellSize; ++y) {
    -  for (let z = 0; z < cellSize; ++z) {
    -    for (let x = 0; x < cellSize; ++x) {
    -      const height = (Math.sin(x / cellSize * Math.PI * 2) + Math.sin(z / cellSize * Math.PI * 3)) * (cellSize / 6) + (cellSize / 2);
    -      if (y < height) {
    -        world.setVoxel(x, y, z, 1);
    -      }
    -    }
    -  }
    -}
    -
    -

    接下来,我们编写实际生成几何体的代码,就像我们在 自定义 BufferGeometry 教程中介绍的那样。

    - -
    const {positions, normals, indices} = world.generateGeometryDataForCell(0, 0, 0);
    -const geometry = new THREE.BufferGeometry();
    -const material = new THREE.MeshLambertMaterial({color: 'green'});
    -
    -const positionNumComponents = 3;
    -const normalNumComponents = 3;
    -geometry.setAttribute(
    -    'position',
    -    new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
    -geometry.setAttribute(
    -    'normal',
    -    new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
    -geometry.setIndex(indices);
    -const mesh = new THREE.Mesh(geometry, material);
    -scene.add(mesh);
    -
    - -

    让我们试试效果:

    - -

    - -

    - -

    看起来已经正常工作了!接下来,我们添加纹理支持。

    - -

    在网上搜索后,我找到了一组由 Joshtimus 制作的、采用 CC-BY-NC-SA 许可协议的 Minecraft 纹理资源包。我随机挑选了几张贴图,并制作了如下的 纹理图集(texture atlas)

    - -
    - -

    为了简化使用,这些纹理按“体素类型”排列成列,其中:

    -
      -
    • 第一行:体素的侧面(left/right/front/back)
    • -
    • 第二行:体素的顶部(top)
    • -
    • 第三行:体素的底部(bottom)
    • -
    - -

    了解了图集结构后,我们可以向 VoxelWorld.faces 数据中添加信息,指定每个面应使用的行(uvRow)以及对应的 UV 坐标。

    - -
    VoxelWorld.faces = [
    -  { // 左面
    -+    uvRow: 0,
    -    dir: [ -1,  0,  0 ],
    -    corners: [
    --      [ 0, 1, 0 ],
    --      [ 0, 0, 0 ],
    --      [ 0, 1, 1 ],
    --      [ 0, 0, 1 ],
    -+      { pos: [ 0, 1, 0 ], uv: [ 0, 1 ] },
    -+      { pos: [ 0, 0, 0 ], uv: [ 0, 0 ] },
    -+      { pos: [ 0, 1, 1 ], uv: [ 1, 1 ] },
    -+      { pos: [ 0, 0, 1 ], uv: [ 1, 0 ] },
    -    ],
    -  },
    -  { // 右面
    -+    uvRow: 0,
    -    dir: [  1,  0,  0 ],
    -    corners: [
    --      [ 1, 1, 1 ],
    --      [ 1, 0, 1 ],
    --      [ 1, 1, 0 ],
    --      [ 1, 0, 0 ],
    -+      { pos: [ 1, 1, 1 ], uv: [ 0, 1 ] },
    -+      { pos: [ 1, 0, 1 ], uv: [ 0, 0 ] },
    -+      { pos: [ 1, 1, 0 ], uv: [ 1, 1 ] },
    -+      { pos: [ 1, 0, 0 ], uv: [ 1, 0 ] },
    -    ],
    -  },
    -  { // 底面
    -+    uvRow: 1,
    -    dir: [  0, -1,  0 ],
    -    corners: [
    --      [ 1, 0, 1 ],
    --      [ 0, 0, 1 ],
    --      [ 1, 0, 0 ],
    --      [ 0, 0, 0 ],
    -+      { pos: [ 1, 0, 1 ], uv: [ 1, 0 ] },
    -+      { pos: [ 0, 0, 1 ], uv: [ 0, 0 ] },
    -+      { pos: [ 1, 0, 0 ], uv: [ 1, 1 ] },
    -+      { pos: [ 0, 0, 0 ], uv: [ 0, 1 ] },
    -    ],
    -  },
    -  { // 顶面
    -+    uvRow: 2,
    -    dir: [  0,  1,  0 ],
    -    corners: [
    --      [ 0, 1, 1 ],
    --      [ 1, 1, 1 ],
    --      [ 0, 1, 0 ],
    --      [ 1, 1, 0 ],
    -+      { pos: [ 0, 1, 1 ], uv: [ 1, 1 ] },
    -+      { pos: [ 1, 1, 1 ], uv: [ 0, 1 ] },
    -+      { pos: [ 0, 1, 0 ], uv: [ 1, 0 ] },
    -+      { pos: [ 1, 1, 0 ], uv: [ 0, 0 ] },
    -    ],
    -  },
    -  { // 背面
    -+    uvRow: 0,
    -    dir: [  0,  0, -1 ],
    -    corners: [
    --      [ 1, 0, 0 ],
    --      [ 0, 0, 0 ],
    --      [ 1, 1, 0 ],
    --      [ 0, 1, 0 ],
    -+      { pos: [ 1, 0, 0 ], uv: [ 0, 0 ] },
    -+      { pos: [ 0, 0, 0 ], uv: [ 1, 0 ] },
    -+      { pos: [ 1, 1, 0 ], uv: [ 0, 1 ] },
    -+      { pos: [ 0, 1, 0 ], uv: [ 1, 1 ] },
    -    ],
    -  },
    -  { // 前面
    -+    uvRow: 0,
    -    dir: [  0,  0,  1 ],
    -    corners: [
    --      [ 0, 0, 1 ],
    --      [ 1, 0, 1 ],
    --      [ 0, 1, 1 ],
    --      [ 1, 1, 1 ],
    -+      { pos: [ 0, 0, 1 ], uv: [ 0, 0 ] },
    -+      { pos: [ 1, 0, 1 ], uv: [ 1, 0 ] },
    -+      { pos: [ 0, 1, 1 ], uv: [ 0, 1 ] },
    -+      { pos: [ 1, 1, 1 ], uv: [ 1, 1 ] },
    -    ],
    -  },
    -];
    -
    - -

    然后我们更新生成几何体的代码,以使用这些 UV 数据。我们需要知道图集中每个纹理块的大小以及整个纹理图集的尺寸。

    - -
    class VoxelWorld {
    --  constructor(cellSize) {
    --    this.cellSize = cellSize;
    -+  constructor(options) {
    -+    this.cellSize = options.cellSize;
    -+    this.tileSize = options.tileSize;
    -+    this.tileTextureWidth = options.tileTextureWidth;
    -+    this.tileTextureHeight = options.tileTextureHeight;
    -+    const {cellSize} = this;
    -+    this.cellSliceSize = cellSize * cellSize;
    -+    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
    -  }
    -
    -  ...
    -
    -  generateGeometryDataForCell(cellX, cellY, cellZ) {
    --    const {cellSize} = this;
    -+    const {cellSize, tileSize, tileTextureWidth, tileTextureHeight} = this;
    -    const positions = [];
    -    const normals = [];
    -+    const uvs = [];
    -    const indices = [];
    -    const startX = cellX * cellSize;
    -    const startY = cellY * cellSize;
    -    const startZ = cellZ * cellSize;
    -
    -    for (let y = 0; y < cellSize; ++y) {
    -      const voxelY = startY + y;
    -      for (let z = 0; z < cellSize; ++z) {
    -        const voxelZ = startZ + z;
    -        for (let x = 0; x < cellSize; ++x) {
    -          const voxelX = startX + x;
    -          const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
    -          if (voxel) {
    -            const uvVoxel = voxel - 1;  // 体素 0 代表天空,因此 UV 从 0 开始
    -            // 这里有体素,但需要为其生成面吗?
    --            for (const {dir, corners} of VoxelWorld.faces) {
    -+            for (const {dir, corners, uvRow} of VoxelWorld.faces) {
    -              const neighbor = this.getVoxel(
    -                  voxelX + dir[0],
    -                  voxelY + dir[1],
    -                  voxelZ + dir[2]);
    -              if (!neighbor) {
    -                // 该方向无相邻体素,因此需要添加一个面
    -                const ndx = positions.length / 3;
    --                for (const pos of corners) {
    -+                for (const {pos, uv} of corners) {
    -                  positions.push(pos[0] + x, pos[1] + y, pos[2] + z);
    -                  normals.push(...dir);
    -+                  uvs.push(
    -+                        (uvVoxel +   uv[0]) * tileSize / tileTextureWidth,
    -+                    1 - (uvRow + 1 - uv[1]) * tileSize / tileTextureHeight);
    -                }
    -                indices.push(
    -                  ndx, ndx + 1, ndx + 2,
    -                  ndx + 2, ndx + 1, ndx + 3
    -                );
    -              }
    -            }
    -          }
    -        }
    -      }
    -    }
    -
    -    return {
    -      positions,
    -      normals,
    -      uvs,
    -      indices
    -    };
    -  }
    -}
    -
    -

    接下来,我们需要 加载纹理

    - -
    const loader = new THREE.TextureLoader();
    -const texture = loader.load('resources/images/minecraft/flourish-cc-by-nc-sa.png', render);
    -texture.magFilter = THREE.NearestFilter;
    -texture.minFilter = THREE.NearestFilter;
    -texture.colorSpace = THREE.SRGBColorSpace;
    -
    - -

    然后将相关参数传递给 VoxelWorld

    - -
    +const tileSize = 16;
    -+const tileTextureWidth = 256;
    -+const tileTextureHeight = 64;
    --const world = new VoxelWorld(cellSize);
    -+const world = new VoxelWorld({
    -+  cellSize,
    -+  tileSize,
    -+  tileTextureWidth,
    -+  tileTextureHeight,
    -+});
    -
    - -

    现在,我们实际在创建几何体时使用 UV 坐标,并在创建材质时使用纹理

    - -
    -const {positions, normals, indices} = world.generateGeometryDataForCell(0, 0, 0);
    -+const {positions, normals, uvs, indices} = world.generateGeometryDataForCell(0, 0, 0);
    -const geometry = new THREE.BufferGeometry();
    --const material = new THREE.MeshLambertMaterial({color: 'green'});
    -+const material = new THREE.MeshLambertMaterial({
    -+  map: texture,
    -+  side: THREE.DoubleSide,
    -+  alphaTest: 0.1,
    -+  transparent: true,
    -+});
    -
    -const positionNumComponents = 3;
    -const normalNumComponents = 3;
    -+const uvNumComponents = 2;
    -geometry.setAttribute(
    -    'position',
    -    new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
    -geometry.setAttribute(
    -    'normal',
    -    new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
    -+geometry.setAttribute(
    -+    'uv',
    -+    new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
    -geometry.setIndex(indices);
    -const mesh = new THREE.Mesh(geometry, material);
    -scene.add(mesh);
    -
    - -

    最后一件事:我们需要设置一些体素,使用不同的纹理。

    - -
    for (let y = 0; y < cellSize; ++y) {
    -  for (let z = 0; z < cellSize; ++z) {
    -    for (let x = 0; x < cellSize; ++x) {
    -      const height = (Math.sin(x / cellSize * Math.PI * 2) + Math.sin(z / cellSize * Math.PI * 3)) * (cellSize / 6) + (cellSize / 2);
    -      if (y < height) {
    --        world.setVoxel(x, y, z, 1);
    -+        world.setVoxel(x, y, z, randInt(1, 17));
    -      }
    -    }
    -  }
    -}
    -
    -+function randInt(min, max) {
    -+  return Math.floor(Math.random() * (max - min) + min);
    -+}
    -
    - -

    这样,我们就成功应用了纹理!

    - -

    - -

    - -

    接下来,我们让程序支持多个体素单元(cell)。

    - -

    为此,我们将使用“单元 ID”来存储单元。单元 ID 就是单元坐标的字符串表示,用逗号分隔。例如,体素坐标 (35, 0, 0) 属于单元 (1, 0, 0),其 ID 为 "1,0,0"

    - -
    class VoxelWorld {
    -  constructor(options) {
    -    this.cellSize = options.cellSize;
    -    this.tileSize = options.tileSize;
    -    this.tileTextureWidth = options.tileTextureWidth;
    -    this.tileTextureHeight = options.tileTextureHeight;
    -    const {cellSize} = this;
    -    this.cellSliceSize = cellSize * cellSize;
    --    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
    -+    this.cells = {};
    -  }
    -+  computeCellId(x, y, z) {
    -+    const {cellSize} = this;
    -+    const cellX = Math.floor(x / cellSize);
    -+    const cellY = Math.floor(y / cellSize);
    -+    const cellZ = Math.floor(z / cellSize);
    -+    return `${cellX},${cellY},${cellZ}`;
    -+  }
    -+  getCellForVoxel(x, y, z) {
    --    const cellX = Math.floor(x / cellSize);
    --    const cellY = Math.floor(y / cellSize);
    --    const cellZ = Math.floor(z / cellSize);
    --    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
    --      return null;
    --    }
    --    return this.cell;
    -+    return this.cells[this.computeCellId(x, y, z)];
    -  }
    -
    -   ...
    -}
    -
    - -

    现在我们可以修改 setVoxel 方法:当尝试设置一个尚未存在的单元中的体素时,自动创建该单元。

    - -
      setVoxel(x, y, z, v) {
    --    const cell = this.getCellForVoxel(x, y, z);
    -+    let cell = this.getCellForVoxel(x, y, z);
    -    if (!cell) {
    --      return 0;
    -+      cell = this.addCellForVoxel(x, y, z);
    -    }
    -    const voxelOffset = this.computeVoxelOffset(x, y, z);
    -    cell[voxelOffset] = v;
    -  }
    -+  addCellForVoxel(x, y, z) {
    -+    const cellId = this.computeCellId(x, y, z);
    -+    let cell = this.cells[cellId];
    -+    if (!cell) {
    -+      const {cellSize} = this;
    -+      cell = new Uint8Array(cellSize * cellSize * cellSize);
    -+      this.cells[cellId] = cell;
    -+    }
    -+    return cell;
    -+  }
    -
    - -

    让我们为场景添加可编辑功能。

    - -

    首先,我们添加一个用户界面(UI)。使用单选按钮(radio buttons),我们可以创建一个 8×2 的纹理选择面板:

    - -
    <body>
    -  <canvas id="c"></canvas>
    -+  <div id="ui">
    -+    <div class="tiles">
    -+      <input type="radio" name="voxel" id="voxel1" value="1"><label for="voxel1" style="background-position:   -0% -0%"></label>
    -+      <input type="radio" name="voxel" id="voxel2" value="2"><label for="voxel2" style="background-position: -100% -0%"></label>
    -+      <input type="radio" name="voxel" id="voxel3" value="3"><label for="voxel3" style="background-position: -200% -0%"></label>
    -+      <input type="radio" name="voxel" id="voxel4" value="4"><label for="voxel4" style="background-position: -300% -0%"></label>
    -+      <input type="radio" name="voxel" id="voxel5" value="5"><label for="voxel5" style="background-position: -400% -0%"></label>
    -+      <input type="radio" name="voxel" id="voxel6" value="6"><label for="voxel6" style="background-position: -500% -0%"></label>
    -+      <input type="radio" name="voxel" id="voxel7" value="7"><label for="voxel7" style="background-position: -600% -0%"></label>
    -+      <input type="radio" name="voxel" id="voxel8" value="8"><label for="voxel8" style="background-position: -700% -0%"></label>
    -+    </div>
    -+    <div class="tiles">
    -+      <input type="radio" name="voxel" id="voxel9"  value="9" ><label for="voxel9"  style="background-position:  -800% -0%"></label>
    -+      <input type="radio" name="voxel" id="voxel10" value="10"><label for="voxel10" style="background-position:  -900% -0%"></label>
    -+      <input type="radio" name="voxel" id="voxel11" value="11"><label for="voxel11" style="background-position: -1000% -0%"></label>
    -+      <input type="radio" name="voxel" id="voxel12" value="12"><label for="voxel12" style="background-position: -1100% -0%"></label>
    -+      <input type="radio" name="voxel" id="voxel13" value="13"><label for="voxel13" style="background-position: -1200% -0%"></label>
    -+      <input type="radio" name="voxel" id="voxel14" value="14"><label for="voxel14" style="background-position: -1300% -0%"></label>
    -+      <input type="radio" name="voxel" id="voxel15" value="15"><label for="voxel15" style="background-position: -1400% -0%"></label>
    -+      <input type="radio" name="voxel" id="voxel16" value="16"><label for="voxel16" style="background-position: -1500% -0%"></label>
    -+    </div>
    -+  </div>
    -</body>
    -
    - -

    再添加一些 CSS 样式,用于美化 UI、显示纹理图块,并高亮当前选中的项:

    -
    body {
    -    margin: 0;
    -}
    -#c {
    -    width: 100%;
    -    height: 100%;
    -    display: block;
    -}
    -+#ui {
    -+    position: absolute;
    -+    left: 10px;
    -+    top: 10px;
    -+    background: rgba(0, 0, 0, 0.8);
    -+    padding: 5px;
    -+}
    -+#ui input[type=radio] {
    -+  width: 0;
    -+  height: 0;
    -+  display: none;
    -+}
    -+#ui input[type=radio] + label {
    -+  background-image: url('resources/images/minecraft/flourish-cc-by-nc-sa.png');
    -+  background-size: 1600% 400%;
    -+  image-rendering: pixelated;
    -+  width: 64px;
    -+  height: 64px;
    -+  display: inline-block;
    -+}
    -+#ui input[type=radio]:checked + label {
    -+  outline: 3px solid red;
    -+}
    -+@media (max-width: 600px), (max-height: 600px) {
    -+  #ui input[type=radio] + label {
    -+    width: 32px;
    -+    height: 32px;
    -+  }
    -+}
    -
    -

    用户体验将如下所示:如果没有选择任何方块并点击一个体素,该体素将被删除;或者,如果点击一个体素并按住 Shift 键,它也会被删除。否则,如果选择了一个方块,它将被添加。你可以再次点击已选中的方块类型来取消选择。

    -

    下面的代码可以让用户取消选中的单选按钮。

    -
    let currentVoxel = 0;
    -let currentId;
    -
    -document.querySelectorAll('#ui .tiles input[type=radio][name=voxel]').forEach((elem) => {
    -  elem.addEventListener('click', allowUncheck);
    -});
    -
    -function allowUncheck() {
    -  if (this.id === currentId) {
    -    this.checked = false;
    -    currentId = undefined;
    -    currentVoxel = 0;
    -  } else {
    -    currentId = this.id;
    -    currentVoxel = parseInt(this.value);
    -  }
    -}
    -
    -

    下面的代码会根据用户点击的位置放置体素。它使用了类似我们在 拾取那篇文章 中的代码,但不是用内置的 RayCaster,而是用 VoxelWorld.intersectRay,它返回交点的位置和被击中的面的法线。

    -
    function getCanvasRelativePosition(event) {
    -  const rect = canvas.getBoundingClientRect();
    -  return {
    -    x: (event.clientX - rect.left) * canvas.width  / rect.width,
    -    y: (event.clientY - rect.top ) * canvas.height / rect.height,
    -  };
    -}
    -
    -function placeVoxel(event) {
    -  const pos = getCanvasRelativePosition(event);
    -  const x = (pos.x / canvas.width ) *  2 - 1;
    -  const y = (pos.y / canvas.height) * -2 + 1;  // 注意这里 Y 要翻转
    -
    -  const start = new THREE.Vector3();
    -  const end = new THREE.Vector3();
    -  start.setFromMatrixPosition(camera.matrixWorld);
    -  end.set(x, y, 1).unproject(camera);
    -
    -  const intersection = world.intersectRay(start, end);
    -  if (intersection) {
    -    const voxelId = event.shiftKey ? 0 : currentVoxel;
    -    // 交点位于面上,这意味着数学精度问题可能会让我们位于面的任一侧
    -    // 如果是删除(currentVoxel = 0),则沿法线方向进入体素一半
    -    // 如果是添加(currentVoxel > 0),则沿法线方向离开体素一半
    -    const pos = intersection.position.map((v, ndx) => {
    -      return v + intersection.normal[ndx] * (voxelId > 0 ? 0.5 : -0.5);
    -    });
    -    world.setVoxel(...pos, voxelId);
    -    updateVoxelGeometry(...pos);
    -    requestRenderIfNotRequested();
    -  }
    -}
    -
    -const mouse = {
    -  x: 0,
    -  y: 0,
    -};
    -
    -function recordStartPosition(event) {
    -  mouse.x = event.clientX;
    -  mouse.y = event.clientY;
    -  mouse.moveX = 0;
    -  mouse.moveY = 0;
    -}
    -function recordMovement(event) {
    -  mouse.moveX += Math.abs(mouse.x - event.clientX);
    -  mouse.moveY += Math.abs(mouse.y - event.clientY);
    -}
    -function placeVoxelIfNoMovement(event) {
    -  if (mouse.moveX < 5 && mouse.moveY < 5) {
    -    placeVoxel(event);
    -  }
    -  window.removeEventListener('pointermove', recordMovement);
    -  window.removeEventListener('pointerup', placeVoxelIfNoMovement);
    -}
    -canvas.addEventListener('pointerdown', (event) => {
    -  event.preventDefault();
    -  recordStartPosition(event);
    -  window.addEventListener('pointermove', recordMovement);
    -  window.addEventListener('pointerup', placeVoxelIfNoMovement);
    -}, {passive: false});
    -canvas.addEventListener('touchstart', (event) => {
    -  // 阻止滚动
    -  event.preventDefault();
    -}, {passive: false});
    -
    -

    上面的代码做了很多事。基本上,鼠标有双重用途:一是移动相机,二是编辑世界。当你松开鼠标时,如果在按下鼠标后没有移动它,就会放置/删除一个体素。这是假设如果你移动了鼠标,你是想移动相机而不是放置方块。moveXmoveY 是绝对移动距离,所以如果你向左移动 10 然后再向右移动 10,总共移动了 20 个单位。这种情况下,用户很可能只是来回旋转模型,而不想放置方块。我没有测试 5 这个范围是否合适。

    -

    在代码中我们调用 world.setVoxel 来设置一个体素,然后调用 updateVoxelGeometry 来根据变化更新 three.js 的几何体。

    -

    我们现在来实现它。如果用户点击了单元格边缘的体素,那么相邻单元格的几何体可能也需要更新。这意味着我们需要检查刚刚编辑的体素所在的单元格,以及该单元格在 6 个方向上的相邻单元格。

    -
    const neighborOffsets = [
    -  [ 0,  0,  0], // 自身
    -  [-1,  0,  0], // 左
    -  [ 1,  0,  0], // 右
    -  [ 0, -1,  0], // 下
    -  [ 0,  1,  0], // 上
    -  [ 0,  0, -1], // 后
    -  [ 0,  0,  1], // 前
    -];
    -function updateVoxelGeometry(x, y, z) {
    -  const updatedCellIds = {};
    -  for (const offset of neighborOffsets) {
    -    const ox = x + offset[0];
    -    const oy = y + offset[1];
    -    const oz = z + offset[2];
    -    const cellId = world.computeCellId(ox, oy, oz);
    -    if (!updatedCellIds[cellId]) {
    -      updatedCellIds[cellId] = true;
    -      updateCellGeometry(ox, oy, oz);
    -    }
    -  }
    -}
    -
    -

    我本来打算这样检查相邻单元格:

    -
    const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
    -if (voxelX === 0) {
    -  // 更新左边的单元格
    -} else if (voxelX === cellSize - 1) {
    -  // 更新右边的单元格
    -}
    -
    -

    并且为另外 4 个方向再加 4 次检查,但我想到直接用一个偏移数组,并保存已更新过的单元格 ID,代码会更简单。如果更新的体素不在单元格边缘,测试会很快跳过更新同一个单元格。

    -

    对于 updateCellGeometry,我们将直接使用之前生成一个单元格几何体的代码,并让它支持处理多个单元格。

    -
    const cellIdToMesh = {};
    -function updateCellGeometry(x, y, z) {
    -  const cellX = Math.floor(x / cellSize);
    -  const cellY = Math.floor(y / cellSize);
    -  const cellZ = Math.floor(z / cellSize);
    -  const cellId = world.computeCellId(x, y, z);
    -  let mesh = cellIdToMesh[cellId];
    -  const geometry = mesh ? mesh.geometry : new THREE.BufferGeometry();
    -
    -  const {positions, normals, uvs, indices} = world.generateGeometryDataForCell(cellX, cellY, cellZ);
    -  const positionNumComponents = 3;
    -  geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
    -  const normalNumComponents = 3;
    -  geometry.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
    -  const uvNumComponents = 2;
    -  geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
    -  geometry.setIndex(indices);
    -  geometry.computeBoundingSphere();
    -
    -  if (!mesh) {
    -    mesh = new THREE.Mesh(geometry, material);
    -    mesh.name = cellId;
    -    cellIdToMesh[cellId] = mesh;
    -    scene.add(mesh);
    -    mesh.position.set(cellX * cellSize, cellY * cellSize, cellZ * cellSize);
    -  }
    -}
    -
    -

    上面的代码会检查单元格 ID 到网格的映射。如果我们请求的单元格不存在,就会创建一个新的 Mesh 并放到世界空间的正确位置。最后,我们用新数据更新属性和索引。

    - - -

    一些注意事项:

    -

    RayCaster 可能也能很好地工作,我没试过。我找到的是一个针对体素优化的光线投射器

    -

    我把 intersectRay 做成了 VoxelWorld 的一部分,因为如果它太慢,我们可以先对单元格进行光线投射,再对体素进行光线投射,作为一种简单的加速方式。

    -

    你可能需要修改光线投射的长度,因为目前它会一直到 Z-far。我猜如果用户点击了很远的地方,他们并不是真的想在世界另一端的 1、2 像素大的位置放方块。

    -

    调用 geometry.computeBoundingSphere 可能会比较慢。我们可以直接手动设置包围球以适配整个单元格。

    -

    当一个单元格里的所有体素都是 0 时,我们是否要移除这个单元格?如果要发布这个功能,这可能是一个合理的优化。

    -

    考虑这个工作的方式,最糟糕的情况是一个开关体素交错的棋盘格。我暂时不知道在性能太慢时可以用什么其他策略。也许性能慢了会促使用户不要去做超大棋盘格。

    -

    为了简单起见,纹理图集是每种方块类型占用 1 列。更好的做法是制作一个更灵活的结构,让每种方块类型可以指定它的面纹理在图集中的位置。现在这种方式浪费了很多空间。

    -

    看看真正的 Minecraft,会发现有些方块不是立方体,比如栅栏或花。这种情况下,我们需要一个方块类型表,每种方块要记录它是立方体还是其他几何形状。如果不是立方体,那么在生成几何体时的邻居检测也需要改变。例如花方块旁边的另一个方块不应该移除它们之间的面。

    -

    如果你想用 three.js 做一个类 Minecraft 的东西,希望这些内容能给你一些起步思路,以及如何生成相对高效的几何体。

    -

    - - - - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/webgl-compatibility-check.html b/manual/zh/webgl-compatibility-check.html deleted file mode 100644 index 25d2920c4121ba..00000000000000 --- a/manual/zh/webgl-compatibility-check.html +++ /dev/null @@ -1,60 +0,0 @@ - - - WebGL 兼容性检查 - - - - - - - - - - - - - -
    -
    -

    WebGL 兼容性检查

    -
    -
    -
    - -

    - 尽管这个问题越来越少见,但某些设备或浏览器可能仍然不支持 WebGL 2。以下方法可以检测是否支持 WebGL 2,并在不支持时向用户显示提示信息。请导入 WebGL 支持检测模块,并在渲染任何内容之前运行以下代码。 -

    - -
    -import WebGL from 'three/addons/capabilities/WebGL.js';
    -
    -if ( WebGL.isWebGL2Available() ) {
    -
    -  // 在此处调用初始化函数或执行其他初始化操作
    -  animate();
    -
    -} else {
    -
    -  const warning = WebGL.getWebGL2ErrorMessage();
    -  document.getElementById( 'container' ).appendChild( warning );
    -
    -}
    -
    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/webgpu-postprocessing.html b/manual/zh/webgpu-postprocessing.html deleted file mode 100644 index fc559320cdb38b..00000000000000 --- a/manual/zh/webgpu-postprocessing.html +++ /dev/null @@ -1,248 +0,0 @@ - - - WebGPURenderer 后处理 - - - - - - - - - - - - - - -
    -
    -

    WebGPURenderer 后处理

    -
    -
    -
    - -

    - `WebGPURenderer` 提供了全新的后处理组件。本文介绍其工作方式和基础用法。 -

    - -

    概述

    - -

    - 旧版 `WebGLRenderer` 的后处理在设计上存在一些限制。由于渲染器支持不足, - 使用 MRT(多渲染目标)较繁琐,同时缺少自动 pass/effect 合并机制, - 难以优化整体性能。 -

    - -

    - 新版 `WebGPURenderer` 后处理栈从一开始就面向这些需求设计。 -

    -
      -
    • - 内置完整 MRT 支持。 -
    • -
    • - 系统会在可能时自动合并效果,减少总渲染 pass 数量。 -
    • -
    • - 效果链通过节点组合表达,可更灵活地搭建后处理流程。 -
    • -
    -

    - 下面来看如何在 three.js 应用中接入这套后处理系统。 -

    - -

    基础用法

    - -

    - 请先阅读 WebGPURenderer 指南并正确配置导入。 - 然后按如下方式创建渲染管线模块实例: -

    - -
    -const renderPipeline = new THREE.RenderPipeline( renderer );
    -
    - -

    - `RenderPipeline` 用于替代旧的 `EffectComposer`。 - 要确保最终输出来自该模块,需要把动画循环改成如下形式: -

    -
    --  renderer.render( scene, camera );
    -+  renderPipeline.render();
    -
    - -

    - 大多数后处理流程都会先创建一个所谓的场景 pass(scene pass),也称 beauty pass, - 作为原始渲染图像,然后再叠加 Bloom、景深、SSR 等效果。 - 先从 TSL 命名空间导入 `pass()` 并创建该 pass。 -

    -
    -import { pass } from 'three/tsl';
    -
    -// 在你的初始化流程中
    -
    -const scenePass = pass( scene, camera );
    -
    -

    - 节点系统的核心思想是:把材质或后处理效果表示为节点组合。 - 例如要实现 DotScreen 与 RGB Shift,只需创建对应效果节点并串联。 -

    - -
    -import { pass } from 'three/tsl';
    -+  import { dotScreen } from 'three/addons/tsl/display/DotScreenNode.js';
    -+  import { rgbShift } from 'three/addons/tsl/display/RGBShiftNode.js';
    -
    -// 在你的初始化流程中
    -
    -const scenePass = pass( scene, camera );
    -
    -+  const dotScreenPass = dotScreen( scenePass );
    -+  const rgbShiftPass = rgbShift( dotScreenPass );
    -
    - -

    - 完成后,把最终节点赋给 `RenderPipeline` 即可。 -

    -
    -renderPipeline.outputNode = rgbShiftPass;
    -
    - -

    色调映射与色彩空间

    - -

    - 使用后处理时,色调映射与色彩空间转换会在效果链末尾自动执行。 - 但某些场景你可能希望完全控制执行时机与顺序。 - 例如使用 `FXAANode` 做 FXAA,或用 `Lut3DNode` 做调色时, - 可以关闭自动处理,并通过 `renderOutput()` 手动应用。 -

    - -
    -import { pass, renderOutput } from 'three/tsl';
    -import { fxaa } from 'three/addons/tsl/display/FXAANode.js';
    -
    -// 在你的初始化流程中
    -
    -const renderPipeline = new THREE.RenderPipeline( renderer );
    -renderPipeline.outputColorTransform = false; // 禁用默认输出色彩变换
    -
    -const scenePass = pass( scene, camera );
    -const outputPass = renderOutput( scenePass ); // 在这里应用色调映射和色彩空间转换
    -
    -// FXAA 必须在 sRGB 色彩空间中计算
    -
    -const fxaaPass = fxaa( outputPass );
    -renderPipeline.outputNode = fxaaPass;
    -
    - -

    - `renderOutput()` 不是强制的,你也可以按需求自行实现色调映射与色彩空间转换。 -

    - -

    MRT(多渲染目标)

    - -

    - 新后处理栈内置 MRT,对高级效果非常关键。MRT 允许你在一次渲染 pass 中产生多个输出。 - 例如使用 TRAA 时,你可以按下述配置准备抗锯齿输入。 -

    - -
    -import { pass, mrt, output, velocity } from 'three/tsl';
    -
    -// 在你的初始化流程中
    -
    -const scenePass = pass( scene, camera );
    -scenePass.setMRT( mrt( {
    -  output: output,
    -  velocity: velocity
    -} ) );
    -
    -

    - 传给 `mrt()` 的配置对象用于描述该 pass 的各个输出。 - 本例中我们保存默认输出(场景主图)和速度信息,用于 TRAA。 - 如果还需要深度,一般无需额外作为 MRT 输出配置; - 在默认输出 pass 中按需请求即可获取。 - 若后续效果需要这些结果,可把它们作为纹理节点读取。 -

    - -
    -import { traa } from 'three/addons/tsl/display/TRAANode.js';
    -
    -// 在你的初始化流程中
    -
    -const scenePassColor = scenePass.getTextureNode( 'output' );
    -const scenePassDepth = scenePass.getTextureNode( 'depth' );
    -const scenePassVelocity = scenePass.getTextureNode( 'velocity' );
    -
    -const traaPass = traa( scenePassColor, scenePassDepth, scenePassVelocity, camera );
    -renderPipeline.outputNode = traaPass;
    -
    - -

    - MRT 配置取决于你的具体方案。你可以使用 `output`、`velocity`、`normalView`、 - `emissive` 等 TSL 对象,把片元数据写入不同 attachment。 - 在复杂 MRT 流程中,为提升性能并避免显存压力,必须做好数据打包与格式优化。 - 默认 attachment 精度是 RGBA16(Half-Float),并非所有数据都需要这么高。 - 例如下方把 `diffuseColor` 改为 RGBA8,可将带宽和内存占用减半。 -

    - -
    -const diffuseTexture = scenePass.getTexture( 'diffuseColor' );
    -diffuseTexture.type = THREE.UnsignedByteType;
    -
    - -

    - 下方 SSR(屏幕空间反射)示例把默认 FP16 法线转换为 RGBA8 颜色, - 并将金属度/粗糙度打包到单个 attachment。配合 `sample()` TSL 函数可实现自定义解包, - 本例中会把颜色还原为归一化方向向量。 -

    - -
    -scenePass.setMRT( mrt( {
    -  output: output,
    -  normal: packNormalToRGB( normalView ),
    -  metalrough: vec2( metalness, roughness )
    -} ) );
    -
    -// 使用 RGBA8 替代 RGBA16
    -
    -const normalTexture = scenePass.getTexture( 'normal' );
    -normalTexture.type = THREE.UnsignedByteType;
    -
    -const metalRoughTexture = scenePass.getTexture( 'metalrough' );
    -metalRoughTexture.type = THREE.UnsignedByteType;
    -
    -// 自定义解包。后续效果里请使用得到的 "sceneNormal"
    -// 来替代 "scenePassNormal"
    -
    -const sceneNormal = sample( ( uv ) => {
    -
    -  return unpackRGBToNormal( scenePassNormal.sample( uv ) );
    -
    -} );
    -
    - -

    - 后续还会继续增强打包/解包能力,提供更多 MRT 数据组织方式。 - 目前建议参考 - 官方示例, - 了解现有效果和配置模式。 -

    -
    -
    -
    - - - - - - - - diff --git a/manual/zh/webgpurenderer.html b/manual/zh/webgpurenderer.html deleted file mode 100644 index ccfe3dcf0dbb7a..00000000000000 --- a/manual/zh/webgpurenderer.html +++ /dev/null @@ -1,182 +0,0 @@ - - - WebGPU 渲染器 - - - - - - - - - - - - - - -
    -
    -

    WebGPU 渲染器

    -
    -
    -
    - -

    - `WebGPURenderer` 是 three.js 的下一代渲染器。本文会简要介绍它的能力和基本使用方式。 -

    - -

    概述

    - -

    - `WebGPURenderer` 被设计为 `WebGLRenderer` 的现代替代方案。 - 它优先使用 WebGPU(现代高性能图形与计算 API), - 同时也被设计成通用渲染器:若设备/浏览器不支持 WebGPU, - 会自动回退到 WebGL 2 后端。 -

    -

    - 这个回退机制非常关键:应用可以在支持 WebGPU 的平台获得新能力, - 同时不牺牲仅支持 WebGL 2 设备的兼容性。 -

    - -

    - 除了接入 WebGPU,`WebGPURenderer` 还提供了以下特性: -

    - -

      -
    • - 内置全新的节点材质系统,开发自定义材质更灵活、更稳健。 -
    • -
    • - 支持 three.js 着色语言 TSL。你可以用 JavaScript 以跨平台方式编写 shader, - 并根据后端自动转译为 WGSL 或 GLSL。 -
    • -
    • - 内置全新后处理栈,支持 MRT(多渲染目标)并可借助节点系统自动合并 pass。 -
    • -
    - - 下面看看如何在 three.js 应用中集成 `WebGPURenderer`。 - -

    使用方式

    - -

    - `WebGPURenderer` 使用不同构建入口,因此导入方式需要调整: -

    - -
    --  import * as THREE from 'three';
    -+  import * as THREE from 'three/webgpu';
    -
    - -

    - 如果你使用 import map,建议改成如下形式(路径按你的工程结构调整): -

    -
    -  <script type="importmap">
    -    {
    -      "imports": {
    -        "three": "../build/three.webgpu.js",
    -        "three/webgpu": "../build/three.webgpu.js",
    -        "three/tsl": "../build/three.tsl.js",
    -        "three/addons/": "./jsm/"
    -      }
    -    }
    -  </script>
    -
    - -

    - 创建渲染器实例的方式和 `WebGLRenderer` 类似: -

    - -
    -const renderer = new THREE.WebGPURenderer( { antialias: true } );
    -renderer.setPixelRatio( window.devicePixelRatio );
    -renderer.setSize( window.innerWidth, window.innerHeight );
    -renderer.setAnimationLoop( render );
    -document.body.appendChild( renderer.domElement );
    -
    -

    - 需要注意,WebGPU 初始化是异步的。因此推荐使用 `setAnimationLoop()`, - 它能确保首次渲染前完成初始化。 - 如果你坚持使用 `window.requestAnimationFrame()` 或需要在初始化阶段直接使用渲染器, - 则要额外调用一行初始化代码。 -

    -
    -const renderer = new THREE.WebGPURenderer( { antialias: true } );
    -renderer.setPixelRatio( window.devicePixelRatio );
    -renderer.setSize( window.innerWidth, window.innerHeight );
    -renderer.setAnimationLoop( render );
    -document.body.appendChild( renderer.domElement );
    -
    -+  await renderer.init();
    -
    -

    - `WebGLRenderer` 中常见的方法(如 `clear()`、`setRenderTarget()`、`dispose()`) - 在 `WebGPURenderer` 中同样可用。完整接口请参考 - API 文档。 -

    - -

    - 正如前文所述,`WebGPURenderer` 默认使用 WebGPU,必要时回退 WebGL 2。 - 如果你想在测试中强制 WebGL 2,或出于某些原因禁用 WebGPU, - 可以使用 `forceWebGL` 参数。 -

    -
    --  const renderer = new THREE.WebGPURenderer( { antialias: true } );
    -+  const renderer = new THREE.WebGPURenderer( { antialias: true, forceWebGL: true } );
    -
    - -

    迁移说明

    - -

    - 准备迁移到 `WebGPURenderer` 时,需要注意以下几点: -

    - -
      -
    • - `ShaderMaterial`、`RawShaderMaterial` 以及通过 `onBeforeCompile()` 改造内置材质, - 在 `WebGPURenderer` 中不受支持。相关逻辑需要迁移到节点材质与 TSL。 -
    • -
    • - `EffectComposer` 及其传统 pass 在这里不支持, - 因为 `WebGPURenderer` 提供了新一代后处理栈。 - 类似材质迁移,后处理效果也使用 TSL 编写,并以节点组合表达。 - 常用效果已迁移并提供了性能更好的节点版本,同时新增了 SSGI、SSS、 - 更好的 DoF 等新效果。可查看 - 官方示例。 -
    • -
    • - 渲染器整体仍属于实验阶段,尽管成熟度近年已明显提升。 - 依据你的应用和场景,仍可能遇到缺失特性,或在某些场景下 `WebGLRenderer` 更快。 - 如遇问题建议在 GitHub 提 issue。`WebGPURenderer` 会持续迭代, - 建议尽量使用最新版本。 -
    • -
    - -

    WebGLRenderer 的现状

    - -

    虽然当前研发重点在 `WebGPURenderer`、节点材质和 TSL, - `WebGLRenderer` 仍在维护,且依然是纯 WebGL 2 应用的推荐选择。 - 但请注意,项目已不计划为 `WebGLRenderer` 增加大型新特性,这一点从最近的版本说明(release notes)中也可以明显看出。 - 同时我们也在评估为其加入有限的节点材质支持, - 以便某些项目更平滑地迁移到 `WebGPURenderer`。 -

    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/webxr-basics.html b/manual/zh/webxr-basics.html deleted file mode 100644 index d59104b2567f38..00000000000000 --- a/manual/zh/webxr-basics.html +++ /dev/null @@ -1,286 +0,0 @@ - - - VR - - - - - - - - - - - - - -
    -
    -

    VR

    -
    -
    -
    -

    在 three.js 中制作一个 VR 应用相当简单。你基本上只需要告诉 three.js 你想使用 WebXR。关于 WebXR,有几点应该很容易理解。摄像机的朝向是由 VR 系统提供的,因为用户会转动头部来选择观看的方向。同样,视野范围(field of view)和长宽比也是由 VR 系统提供的,因为每个系统的视野和显示比例都不同。

    -

    我们来看一个来自制作响应式网页的示例,并让它支持 VR。

    -

    在开始之前,你需要一台支持 VR 的设备,比如 Android 智能手机、Google Daydream、Oculus Go、Oculus Rift、Vive、Samsung Gear VR,或者一部安装了WebXR 浏览器的 iPhone。

    -

    接下来,如果你在本地运行,你需要像设置教程中提到的那样运行一个简单的 Web 服务器。

    -

    如果你用于查看 VR 的设备不是运行服务的同一台电脑,那么你需要通过 https 来访问网页,否则浏览器将不允许使用 WebXR API。设置教程中提到的名为 Servez 的服务器支持启用 https。勾选该选项并启动服务器。

    -
    -

    请注意 URL,你需要使用你电脑的本地 IP 地址。它通常会以 19217210 开头。在 VR 设备的浏览器中输入完整地址,包括 https:// 部分。注意:你的电脑和 VR 设备必须在同一个本地网络或 WiFi 上,并且最好是在家庭网络中。注意:许多咖啡馆的网络配置不允许设备间直接通信。

    -

    你可能会看到如下图所示的错误提示。点击“高级”,然后点击继续

    -
    -

    现在你可以运行示例代码了。

    -

    如果你打算真正进行 WebXR 开发,你还应该了解一下 远程调试,这样你就可以查看控制台警告、错误,当然也可以调试你的代码

    -

    如果你只是想看看下面的代码是否可运行,你可以直接在本网站运行它。

    -

    我们首先需要在引入 three.js 之后引入对 VR 的支持:

    -
    import * as THREE from 'three';
    -+import {VRButton} from 'three/addons/webxr/VRButton.js';  // 引入 VR 按钮模块
    -
    -

    然后我们需要启用 three.js 的 WebXR 支持,并将 VR 按钮添加到页面中:

    -
    function main() {
    -  const canvas = document.querySelector('#c');
    -  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    -+  renderer.xr.enabled = true;  // 启用 WebXR 支持
    -+  document.body.appendChild(VRButton.createButton(renderer));  // 将 VR 按钮添加到页面
    -
    -

    我们需要让 three.js 来运行渲染循环。在此之前我们一直使用 requestAnimationFrame 循环,但为了支持 VR,我们需要让 three.js 自己控制渲染循环。我们可以调用 WebGLRenderer.setAnimationLoop 并传入一个回调函数来实现:

    -
    function render(time) {
    -  time *= 0.001;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -  cubes.forEach((cube, ndx) => {
    -    const speed = 1 + ndx * .1;
    -    const rot = time * speed;
    -    cube.rotation.x = rot;
    -    cube.rotation.y = rot;
    -  });
    -
    -  renderer.render(scene, camera);
    -
    --  requestAnimationFrame(render);  // 原来的 requestAnimationFrame 被移除
    -}
    -
    --requestAnimationFrame(render);  // 原调用被注释
    -+renderer.setAnimationLoop(render);  // 改为使用 WebXR 的渲染循环方式
    -
    -

    还有一个细节:我们最好设置一个摄像机的高度,使其符合站立用户的平均视角高度。

    -
    const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -+camera.position.set(0, 1.6, 0);  // 设置摄像机高度为 1.6 米,符合站立用户的平均视角
    -
    -

    并将立方体上移,使其位于摄像机前方:

    -
    const cube = new THREE.Mesh(geometry, material);
    -scene.add(cube);
    -
    -cube.position.x = x;
    -+cube.position.y = 1.6;  // 与摄像机高度一致
    -+cube.position.z = -2;  // 放置在摄像机前方 2 米处
    -
    -

    我们将 z 设置为 -2,因为摄像机现在位于 z = 0,默认朝向 -z 轴方向。

    -

    这引出了一个非常重要的点:VR 中的单位是以米为单位。换句话说,一个单位 = 一米。这意味着摄像机在距离地面 1.6 米的位置,立方体的中心位于摄像机前方 2 米处。每个立方体的大小是 1x1x1 米。这一点非常关键,因为 VR 需要将虚拟世界中的尺寸与用户在现实世界中的动作相匹配。

    -

    现在,我们应该可以在摄像机前方看到三个旋转的立方体,并且有一个进入 VR 的按钮。

    -

    - -

    -

    我发现 VR 效果更好一些时,是摄像机周围有一些参考物,比如一个房间。因此我们来添加一个简单的网格立方体贴图,就像我们在背景文章中讲到的那样。我们会使用相同的网格纹理贴图在立方体的每一面上,这样可以创建一个“网格房间”。

    -
    const scene = new THREE.Scene();
    -+{
    -+  const loader = new THREE.CubeTextureLoader();  // 创建立方体贴图加载器
    -+  const texture = loader.load([
    -+    'resources/images/grid-1024.png',  // 六个面的纹理都用同一张图
    -+    'resources/images/grid-1024.png',
    -+    'resources/images/grid-1024.png',
    -+    'resources/images/grid-1024.png',
    -+    'resources/images/grid-1024.png',
    -+    'resources/images/grid-1024.png',
    -+  ]);
    -+  scene.background = texture;  // 设置场景背景为加载的立方体贴图
    -+}
    -
    -

    这样看起来会更好一些。

    - -

    - -

    -

    注意:要实际看到 VR 效果,你需要一台兼容 WebXR 的设备。我相信大多数 Android 手机在使用 Chrome 或 Firefox 时都支持 WebXR。至于 iOS,你也许可以使用这个 WebXR 应用,但总体上 WebXR 在 iOS 上的支持在 2019 年 5 月时仍处于不支持状态。

    -

    在 Android 或 iPhone 上使用 WebXR,你需要一个手机专用的 VR 头显。你可以以很便宜的价格购买,比如用纸板做的只需约 5 美元,高端一些的可能需要 100 美元左右。不幸的是,我也不清楚该推荐哪款产品。我这些年买过 6 个设备,质量参差不齐,最贵的也没超过 25 美元。

    -

    以下是一些可能遇到的问题:

    -
      -
    1. 是否适配你的手机尺寸

      -

      手机尺寸各异,因此 VR 头显需要与之匹配。很多头显声称支持多种尺寸。从我的经验来看,适配尺寸越多,实际效果越差,因为它们不得不在多个尺寸之间做出妥协。不幸的是,支持多尺寸的头显是最常见的类型。

      -
    2. -
    3. 是否能够调节焦距以适配你的脸型

      -

      有些设备的可调节性更强。通常最多提供两种调节方式:镜片与眼睛之间的距离,以及两只眼睛之间的镜片间距。

      -
    4. -
    5. 镜片是否太反光

      -

      许多头显的镜片连接区域是一段塑料通道。如果这些塑料材质是光滑或反光的,那么它会像镜子一样反射屏幕内容,造成强烈干扰。

      -

      几乎没有评论会提及这个问题。

      -
    6. -
    7. 佩戴是否舒适

      -

      大多数设备像眼镜一样压在鼻梁上。几分钟后可能就会感觉不适。有些设备配有环绕头部的固定带,有些还有一条从上方穿过头顶的第三条带子。这些可能或可能不会起到将设备固定在合适位置的作用。

      -

      事实是,对大多数(甚至所有)设备来说,眼睛必须正对镜片中心。如果镜片略微偏高或偏低,图像就会变模糊。这可能非常令人沮丧,因为一开始图像是清晰的,但使用 45 到 60 秒后设备稍微移位 1 毫米,你会突然发现自己在努力看一个模糊的图像。

      -
    8. -
    9. 是否支持眼镜

      -

      如果你戴眼镜,你需要查看评论确认该设备是否支持眼镜佩戴。

      -
    10. -
    -

    很遗憾,我没法给出推荐。Google 提供了一些便宜的纸板 VR 眼镜建议,有些仅需 5 美元左右,不妨从那里开始尝试。如果你喜欢这个体验,再考虑升级。5 美元也就一杯咖啡的钱,试一试也无妨!

    -

    VR 设备大致可以分为 3 种类型:

    -
      -
    1. 三自由度(3DoF),无输入设备

      -

      这通常指的是手机类设备,尽管有时也可以购买第三方输入设备。所谓三自由度是指你可以上下转头(1)、左右转头(2)、以及左右倾斜头部(3)。

      -
    2. - -
    3. 三自由度(3DoF)+ 一个三自由度输入设备

      -

      这类设备包括 Google Daydream 和 Oculus GO。

      -

      它们同样支持三自由度,并配有一个小型控制器,在 VR 中像激光指针一样使用。激光指针本身也只有三自由度,系统只能识别它的指向方向,不能识别它的位置。

      -
    4. - -
    5. 六自由度(6DoF)+ 六自由度输入设备

      -

      这些是真正的 VR 设备(哈哈)。六自由度意味着设备不仅知道你头部的朝向,还知道你头部的实际位置。这意味着你左右移动、前后移动、或坐下/站起,设备都能感知并在 VR 中进行同步。

      -

      体验非常真实,令人惊艳。在一个好的演示中你可能会被震撼到,我至今仍然会被打动。

      -

      此外,这类设备通常配有两个控制器,分别对应左右手。系统可以准确识别你双手的位置和朝向,因此你可以在 VR 中通过触摸、推动、扭动等手势操作物体。

      -

      支持六自由度的设备包括 Vive、Vive Pro、Oculus Rift、Quest 以及我相信所有 Windows MR 设备。

      -
    6. -
    - -

    讲了这么多,我也不能完全确认哪些设备确实能与 WebXR 配合使用。但我 99% 确信,大多数 Android 手机在使用 Chrome 时是可以的。你可能需要在 about:flags 中启用 WebXR 支持。我也知道 Google Daydream 是可用的,同样需要在 about:flags 中启用支持。Oculus Rift、Vive、Vive Pro 可以通过 Chrome 或 Firefox 使用。我对 Oculus Go 和 Oculus Quest 不太确定,因为它们使用的是定制操作系统,但根据网络信息,它们似乎也是可以的。

    - -

    好了,介绍完 VR 设备和 WebXR,我们继续讲其他内容。

    - -
      -
    • 同时支持 VR 和 非 VR 模式

      -

      据我所知(截至 r112 版本),three.js 并没有提供一个简单的方法来同时支持 VR 和非 VR 模式。理想情况下,如果不处于 VR 模式,我们希望可以使用任何方式控制摄像机,例如使用 OrbitControls,并且在切换进出 VR 模式时可以接收到事件,以便启用或禁用控制器。

      -
    • -
    - -

    如果 future 的 three.js 添加了支持,我会尝试更新本文。在此之前,你可能需要制作两个版本的页面,或者在 URL 中传入一个标记参数,例如:

    -
    https://mysite.com/mycooldemo?allowvr=true
    -
    -

    然后我们可以加一些链接来切换模式:

    -
    <body>
    -  <canvas id="c"></canvas>
    -+  <div class="mode">
    -+    <a href="?allowvr=true" id="vr">启用 VR 模式</a>
    -+    <a href="?" id="nonvr">使用非 VR 模式</a>
    -+  </div>
    -</body>
    -
    - -

    并加上一些 CSS 来定位这些链接:

    -
    body {
    -    margin: 0;
    -}
    -#c {
    -    width: 100%;
    -    height: 100%;
    -    display: block;
    -}
    -+.mode {
    -+  position: absolute;
    -+  right: 1em;  /* 右上角显示 */
    -+  top: 1em;
    -+}
    -
    - -

    你可以在代码中这样读取参数:

    -
    function main() {
    -  const canvas = document.querySelector('#c');
    -  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    --  renderer.xr.enabled = true;
    --  document.body.appendChild(VRButton.createButton(renderer));
    -
    -  const fov = 75;
    -  const aspect = 2;  // canvas 默认宽高比
    -  const near = 0.1;
    -  const far = 5;
    -  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -  camera.position.set(0, 1.6, 0);
    -
    -+  const params = (new URL(document.location)).searchParams;
    -+  const allowvr = params.get('allowvr') === 'true';  // 从 URL 中读取 allowvr 参数
    -+  if (allowvr) {
    -+    renderer.xr.enabled = true;
    -+    document.body.appendChild(VRButton.createButton(renderer));
    -+    document.querySelector('#vr').style.display = 'none';  // 隐藏“启用 VR”按钮
    -+  } else {
    -+    // 非 VR 模式,添加控制器
    -+    const controls = new OrbitControls(camera, canvas);
    -+    controls.target.set(0, 1.6, -2);
    -+    controls.update();
    -+    document.querySelector('#nonvr').style.display = 'none';  // 隐藏“非 VR 模式”按钮
    -+  }
    -
    - -

    这到底好不好我也说不准。我感觉 VR 模式和非 VR 模式之间所需的实现差异通常非常大, - 所以除了最简单的应用场景外,或许制作两个单独的页面会更合适?你需要自己决定。

    - -

    注意:由于种种原因,这段代码在本网站的在线编辑器中是无法运行的, - 所以如果你想试试看,可以点击这里。 - 页面会以非 VR 模式启动,你可以用鼠标或手指来移动摄像机。 - 点击“允许 VR”按钮后,页面会切换为支持 VR 模式, - 如果你使用的是 VR 设备,就可以点击“进入 VR”按钮。

    -
      -
    • -

      决定支持哪种等级的 VR 设备

      -

      上文我们介绍了三种类型的 VR 设备。

      - -
        -
      • 3DOF 无输入设备
      • -
      • 3DOF + 3DOF 输入设备
      • -
      • 6DOF + 6DOF 输入设备
      • -
      - -

      你需要决定你愿意投入多少精力来支持每种类型的设备。

      - -

      例如,对于最简单的无输入设备,你能做的通常就是在用户视野中放置一些按钮或物体, - 当用户将视图中心的某个指示器对准这些物体大约 0.5 秒时,就触发点击。 - 常见的用户体验方式是在目标物体上显示一个小型的计时圈, - 表示“如果你继续把视线保持在这里一会儿,这个按钮将被选中”。

      - -

      由于没有其他输入方式,这已经是你能做的最好的交互方式了。

      - -

      下一级别是用户拥有一个 3DOF 的输入设备。通常它可以用来指向目标, - 并且用户至少有两个按钮可以使用。Daydream 控制器还有一个触控板, - 可以提供常规的触摸输入。

      - -

      无论如何,如果用户使用这类设备,让他们使用控制器指向目标, - 会比强迫他们通过头部移动去“看”目标舒适得多。

      - -

      一个类似等级的设备可能是 3DOF 或 6DOF 的头显配合游戏手柄使用。 - 你需要自己决定该如何支持这种情况。常见的方式是用户仍然需要转头瞄准目标, - 而手柄只是用来触发按钮。

      - -

      最后一个层级是使用 6DOF 头显配合两个 6DOF 控制器的用户。 - 对于这类用户来说,如果你的应用只有 3DOF 的交互, - 往往会让他们感到沮丧。同样,他们通常期望能够在 VR 中用手操作物体, - 你需要决定是否要支持这种高度自由的交互方式。

      -
    - -

    如你所见,入门 VR 开发相对简单,但如果你真的想做出一个可发布的 VR 应用, - 那就需要大量的决策和设计。

    - -

    这篇文章只是使用 three.js 进行 VR 开发的简要介绍。 - 我们将在 后续文章 中介绍各种输入方式。

    -
    -
    -
    - - - diff --git a/manual/zh/webxr-look-to-select.html b/manual/zh/webxr-look-to-select.html deleted file mode 100644 index 08fc56fb73a42d..00000000000000 --- a/manual/zh/webxr-look-to-select.html +++ /dev/null @@ -1,392 +0,0 @@ - - - - - VR - 用目光进行选择 - - - - - - - - - - - - - -
    -
    -

    VR - 用目光进行选择

    -
    -
    -
    -

    注意:本页示例需要支持VR的设备。没有这样的设备则无法运行。参见 上一篇文章 了解原因

    -

    上一篇文章 中,我们介绍了一个使用 three.js 的非常简单的 VR 示例,并讨论了各种类型的 VR 系统。

    -

    最简单且可能是最常见的类型是谷歌 Cardboard 风格的 VR,它基本上就是将手机放入一个 5 到 50 美元的面罩中。这种 VR 没有控制器,因此人们必须想出创造性的解决方案来实现用户输入。

    -

    最常见的解决方案是“用目光进行选择”,即如果用户将头部对准某个物体一段时间,该物体就会被选中。

    -

    让我们来实现“用目光进行选择”功能!我们将从 上一篇文章中的示例 开始,并添加我们在 拾取文章 中创建的 PickHelper。代码如下:

    -
    class PickHelper {
    -  constructor() {
    -    this.raycaster = new THREE.Raycaster();
    -    this.pickedObject = null;
    -    this.pickedObjectSavedColor = 0;
    -  }
    -  pick(normalizedPosition, scene, camera, time) {
    -    // 如果有被选中的物体,则恢复其颜色
    -    if (this.pickedObject) {
    -      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
    -      this.pickedObject = undefined;
    -    }
    -
    -    // 从视锥体发射一条射线
    -    this.raycaster.setFromCamera(normalizedPosition, camera);
    -    // 获取射线相交的物体列表
    -    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
    -    if (intersectedObjects.length) {
    -      // 选择第一个物体。它是最接近的那个
    -      this.pickedObject = intersectedObjects[0].object;
    -      // 保存其颜色
    -      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
    -      // 将其自发光颜色设置为闪烁的红/黄色
    -      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
    -    }
    -  }
    -}
    -
    -

    有关该代码的解释,请参见 拾取文章

    -

    要使用它,我们只需创建一个实例并在渲染循环中调用它:

    -
    +const pickHelper = new PickHelper();
    -
    -...
    -function render(time) {
    -  time *= 0.001;
    -
    -  ...
    -
    -+  // 0, 0 是归一化坐标中视图的中心。
    -+  pickHelper.pick({x: 0, y: 0}, scene, camera, time);
    -
    -

    在原始的拾取示例中,我们将鼠标坐标从 CSS 像素转换为归一化坐标,该坐标在画布上从 -1 到 +1。

    -

    但在这种情况下,我们将始终选择相机所对准的位置,即屏幕中心,因此我们为 xy 都传入 0,这在归一化坐标中就是中心。

    -

    这样,当我们注视物体时,它们就会闪烁

    -

    - -

    -

    通常我们不希望选择是立即发生的。相反,我们要求用户将相机对准他们想要选择的物体几秒钟,以便他们有机会避免意外选择某些东西。

    -

    为此,我们需要某种计量器或指示器,或某种方式来传达用户必须持续注视以及需要注视多长时间。

    -

    一种简单的方法是制作一个双色纹理,并使用纹理偏移在模型上滑动纹理。

    -

    让我们先单独实现这个效果,看看它如何工作,然后再将其添加到 VR 示例中。

    -

    首先,我们创建一个 正交相机

    -
    const left = -2;    // 使用左、右、上、下
    -const right = 2;    // 的值来匹配默认
    -const top = 1;      // 画布大小。
    -const bottom = -1;
    -const near = -1;
    -const far = 1;
    -const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
    -
    -

    当然,如果画布大小改变,我们也需要更新它

    -
    function render(time) {
    -  time *= 0.001;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    const aspect = canvas.clientWidth / canvas.clientHeight;
    -+    camera.left = -aspect;
    -+    camera.right = aspect;
    -    camera.updateProjectionMatrix();
    -  }
    -  ...
    -
    -

    现在我们有了一个相机,它显示中心上下各 2 个单位,左右各 aspect 个单位。

    -

    接下来,让我们制作一个双色纹理。我们将使用 DataTexture, - 它在其他地方示例中也用过。

    -
    function makeDataTexture(data, width, height) {
    -  const texture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat);
    -  texture.minFilter = THREE.NearestFilter;
    -  texture.magFilter = THREE.NearestFilter;
    -  texture.needsUpdate = true;
    -  return texture;
    -}
    -
    -const cursorColors = new Uint8Array([
    -  64, 64, 64, 64,       // 深灰色
    -  255, 255, 255, 255,   // 白色
    -]);
    -const cursorTexture = makeDataTexture(cursorColors, 2, 1);
    -
    -

    然后我们将该纹理应用于一个 TorusGeometry

    -
    const ringRadius = 0.4;
    -const tubeRadius = 0.1;
    -const tubeSegments = 4;
    -const ringSegments = 64;
    -const cursorGeometry = new THREE.TorusGeometry(
    -    ringRadius, tubeRadius, tubeSegments, ringSegments);
    -
    -const cursorMaterial = new THREE.MeshBasicMaterial({
    -  color: 'white',
    -  map: cursorTexture,
    -  transparent: true,
    -  blending: THREE.CustomBlending,
    -  blendSrc: THREE.OneMinusDstColorFactor,
    -  blendDst: THREE.OneMinusSrcColorFactor,
    -});
    -const cursor = new THREE.Mesh(cursorGeometry, cursorMaterial);
    -scene.add(cursor);
    -
    -

    然后在 render 中调整纹理的偏移

    -
    function render(time) {
    -  time *= 0.001;
    -
    -  if (resizeRendererToDisplaySize(renderer)) {
    -    const canvas = renderer.domElement;
    -    const aspect = canvas.clientWidth / canvas.clientHeight;
    -    camera.left = -aspect;
    -    camera.right = aspect;
    -    camera.updateProjectionMatrix();
    -  }
    -
    -+  const fromStart = 0;
    -+  const fromEnd = 2;
    -+  const toStart = -0.5;
    -+  const toEnd = 0.5;
    -+  cursorTexture.offset.x = THREE.MathUtils.mapLinear(
    -+      time % 2,
    -+      fromStart, fromEnd,
    -+      toStart, toEnd);
    -
    -  renderer.render(scene, camera);
    -}
    -
    -

    THREE.MathUtils.mapLinear 将一个在 fromStartfromEnd 之间变化的值映射到 toStarttoEnd 之间的值。在上面的例子中,我们取 time % 2,即一个从 0 到 2 变化的值,并将其映射到从 -0.5 到 0.5 变化的值。

    -

    纹理 使用从 0 到 1 的归一化纹理坐标映射到几何体上。这意味着我们的 2x1 像素图像,设置为默认的 THREE.ClampToEdge 包装模式,如果我们调整纹理坐标为 -0.5,则整个网格将显示第一种颜色;如果调整为 +0.5,则整个网格将显示第二种颜色。在两者之间,由于过滤设置为 THREE.NearestFilter,我们能够将两种颜色之间的过渡移动通过几何体。

    -

    让我们顺便添加一个背景纹理,就像我们在 背景文章 中介绍的那样。我们将只使用一组 2x2 的颜色,但设置纹理的重复属性,使其形成一个 8x8 的网格。这样可以为我们的光标提供一个渲染背景,以便我们检查它在不同颜色上的显示效果。

    -
    +const backgroundColors = new Uint8Array([
    -+    0,   0,   0, 255,  // 黑色
    -+   90,  38,  38, 255,  // 深红色
    -+  100, 175, 103, 255,  // 中等绿色
    -+  255, 239, 151, 255,  // 浅黄色
    -+]);
    -+const backgroundTexture = makeDataTexture(backgroundColors, 2, 2);
    -+backgroundTexture.wrapS = THREE.RepeatWrapping;
    -+backgroundTexture.wrapT = THREE.RepeatWrapping;
    -+backgroundTexture.repeat.set(4, 4);
    -
    -const scene = new THREE.Scene();
    -+scene.background = backgroundTexture;
    -
    -

    现在如果我们运行它,你会看到我们得到了一个类似圆圈的计量器,并且我们可以设置计量器的位置。

    -

    - -

    -

    请注意并尝试以下几点:

    -
      -
    • 我们设置了 cursorMaterialblendingblendSrcblendDst 属性如下:

      -
        blending: THREE.CustomBlending,
      -  blendSrc: THREE.OneMinusDstColorFactor,
      -  blendDst: THREE.OneMinusSrcColorFactor,
      -

      这产生了一种反相效果。注释掉这三行代码,你就能看到区别。我猜测这种反相效果在这里是最好的,因为这样无论光标在什么颜色上,我们都应该能看到它。

      -
    • -
    • 我们使用了 TorusGeometry 而不是 RingGeometry

      -

      出于某些原因,RingGeometry 使用了平面的 UV 映射方案。因此,如果我们使用 RingGeometry,纹理会在环上水平滑动,而不是像上面那样环绕它。

      -

      尝试一下,将 TorusGeometry 改为 RingGeometry(在上面的示例中它只是被注释掉了),你就会明白我的意思。

      -

      (在某种定义下的)正确做法是:要么使用 RingGeometry 但修正纹理坐标,使其环绕环形;要么自己生成环形几何体。但是,圆环体效果很好。直接放置在相机前方,使用 MeshBasicMaterial,它看起来会完全像一个环,并且纹理坐标环绕环形,因此它符合我们的需求。

      -
    • -
    -

    让我们将它与上面的 VR 代码集成起来。

    -
    class PickHelper {
    --  constructor() {
    -+  constructor(camera) {
    -    this.raycaster = new THREE.Raycaster();
    -    this.pickedObject = null;
    --    this.pickedObjectSavedColor = 0;
    -
    -+    const cursorColors = new Uint8Array([
    -+      64, 64, 64, 64,       // 深灰色
    -+      255, 255, 255, 255,   // 白色
    -+    ]);
    -+    this.cursorTexture = makeDataTexture(cursorColors, 2, 1);
    -+
    -+    const ringRadius = 0.4;
    -+    const tubeRadius = 0.1;
    -+    const tubeSegments = 4;
    -+    const ringSegments = 64;
    -+    const cursorGeometry = new THREE.TorusGeometry(
    -+        ringRadius, tubeRadius, tubeSegments, ringSegments);
    -+
    -+    const cursorMaterial = new THREE.MeshBasicMaterial({
    -+      color: 'white',
    -+      map: this.cursorTexture,
    -+      transparent: true,
    -+      blending: THREE.CustomBlending,
    -+      blendSrc: THREE.OneMinusDstColorFactor,
    -+      blendDst: THREE.OneMinusSrcColorFactor,
    -+    });
    -+    const cursor = new THREE.Mesh(cursorGeometry, cursorMaterial);
    -+    // 将光标作为相机的子对象添加
    -+    camera.add(cursor);
    -+    // 并将其移动到相机前方
    -+    cursor.position.z = -1;
    -+    const scale = 0.05;
    -+    cursor.scale.set(scale, scale, scale);
    -+    this.cursor = cursor;
    -+
    -+    this.selectTimer = 0;
    -+    this.selectDuration = 2;
    -+    this.lastTime = 0;
    -  }
    -  pick(normalizedPosition, scene, camera, time) {
    -+    const elapsedTime = time - this.lastTime;
    -+    this.lastTime = time;
    -
    --    // 如果有被选中的物体,则恢复其颜色
    --    if (this.pickedObject) {
    --      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
    --      this.pickedObject = undefined;
    --    }
    -
    -+    const lastPickedObject = this.pickedObject;
    -+    this.pickedObject = undefined;
    -
    -    // 从视锥体发射一条射线
    -    this.raycaster.setFromCamera(normalizedPosition, camera);
    -    // 获取射线相交的物体列表
    -    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
    -    if (intersectedObjects.length) {
    -      // 选择第一个物体。它是最接近的那个
    -      this.pickedObject = intersectedObjects[0].object;
    --      // 保存其颜色
    --      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
    --      // 将其自发光颜色设置为闪烁的红/黄色
    --      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
    -    }
    -
    -+    // 仅当光标击中物体时才显示
    -+    this.cursor.visible = this.pickedObject ? true : false;
    -+
    -+    let selected = false;
    -+
    -+    // 如果我们正在注视的物体与之前相同
    -+    // 则增加选择计时器的时间
    -+    if (this.pickedObject && lastPickedObject === this.pickedObject) {
    -+      this.selectTimer += elapsedTime;
    -+      if (this.selectTimer >= this.selectDuration) {
    -+        this.selectTimer = 0;
    -+        selected = true;
    -+      }
    -+    } else {
    -+      this.selectTimer = 0;
    -+    }
    -+
    -+    // 设置光标材质以显示计时器状态
    -+    const fromStart = 0;
    -+    const fromEnd = this.selectDuration;
    -+    const toStart = -0.5;
    -+    const toEnd = 0.5;
    -+    this.cursorTexture.offset.x = THREE.MathUtils.mapLinear(
    -+        this.selectTimer,
    -+        fromStart, fromEnd,
    -+        toStart, toEnd);
    -+
    -+    return selected ? this.pickedObject : undefined;
    -  }
    -}
    -
    -

    你可以看到上面的代码中,我们添加了所有创建光标几何体、纹理和材质的代码,并将其作为相机的子对象添加,因此它将始终位于相机前方。请注意,我们需要将相机添加到场景中,否则光标将不会被渲染。

    -
    +scene.add(camera);
    -
    -

    然后我们检查这次拾取的物体是否与上次相同。如果是,我们将经过的时间加到计时器中,如果计时器达到其限制,我们就返回选中的项目。

    -

    现在让我们使用它来选择立方体。作为一个简单的例子,我们还将添加 3 个球体。当一个立方体被选中时,我们将隐藏该立方体并显示相应的球体。

    -

    因此,首先我们创建一个球体几何体

    -
    const boxWidth = 1;
    -const boxHeight = 1;
    -const boxDepth = 1;
    --const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    -+const boxGeometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    -+
    -+const sphereRadius = 0.5;
    -+const sphereGeometry = new THREE.SphereGeometry(sphereRadius);
    -
    -

    然后让我们创建 3 对立方体和球体网格。我们将使用 Map,以便我们可以将每个 Mesh 与其对应的伙伴关联起来。

    -
    -const cubes = [
    --  makeInstance(geometry, 0x44aa88,  0),
    --  makeInstance(geometry, 0x8844aa, -2),
    --  makeInstance(geometry, 0xaa8844,  2),
    --];
    -+const meshToMeshMap = new Map();
    -+[
    -+  { x:  0, boxColor: 0x44aa88, sphereColor: 0xFF4444, },
    -+  { x:  2, boxColor: 0x8844aa, sphereColor: 0x44FF44, },
    -+  { x: -2, boxColor: 0xaa8844, sphereColor: 0x4444FF, },
    -+].forEach((info) => {
    -+  const {x, boxColor, sphereColor} = info;
    -+  const sphere = makeInstance(sphereGeometry, sphereColor, x);
    -+  const box = makeInstance(boxGeometry, boxColor, x);
    -+  // 隐藏球体
    -+  sphere.visible = false;
    -+  // 将球体映射到立方体
    -+  meshToMeshMap.set(box, sphere);
    -+  // 将立方体映射到球体
    -+  meshToMeshMap.set(sphere, box);
    -+});
    -
    -

    render 中,当我们旋转立方体时,需要遍历 meshToMeshMap 而不是 cubes

    -
    -cubes.forEach((cube, ndx) => {
    -+let ndx = 0;
    -+for (const mesh of meshToMeshMap.keys()) {
    -  const speed = 1 + ndx * .1;
    -  const rot = time * speed;
    --  cube.rotation.x = rot;
    --  cube.rotation.y = rot;
    --});
    -+  mesh.rotation.x = rot;
    -+  mesh.rotation.y = rot;
    -+  ++ndx;
    -+}
    -
    - -

    现在我们可以使用我们新的 PickHelper 实现来选择其中一个物体。当物体被选中时,我们隐藏该物体并显示其对应的伙伴物体。

    -
    // 0, 0 是归一化坐标中视图的中心。
    --pickHelper.pick({x: 0, y: 0}, scene, camera, time);
    -+const selectedObject = pickHelper.pick({x: 0, y: 0}, scene, camera, time);
    -+if (selectedObject) {
    -+  selectedObject.visible = false;
    -+  const partnerObject = meshToMeshMap.get(selectedObject);
    -+  partnerObject.visible = true;
    -+}
    -
    -

    有了这些,我们就应该有了一个相当不错的“注视选择”实现。

    -

    - -

    -

    希望这个示例能给你一些关于如何实现像 Google Cardboard 级别的“注视选择”用户体验的想法。使用纹理坐标偏移来滑动纹理也是一种常用且有用的技术。

    -

    接下来,让我们允许拥有 VR 控制器的用户指向并移动物体

    - -
    -
    -
    - - - - - - - - diff --git a/manual/zh/webxr-point-to-select.html b/manual/zh/webxr-point-to-select.html deleted file mode 100644 index e8b09b94453ce9..00000000000000 --- a/manual/zh/webxr-point-to-select.html +++ /dev/null @@ -1,416 +0,0 @@ - - - - - VR - 3DOF 指向选择 - - - - - - - - - - - - - -
    -
    -

    VR - 3DOF 指向选择

    -
    -
    -
    -

    注意:本页示例需要支持 VR 的设备和一个指向设备。如果没有,它们将无法工作。请参阅 这篇文章 了解原因。

    -

    上一篇文章 中,我们介绍了一个非常简单的 VR - 示例,用户可以通过注视来选择物体。在本文中,我们将更进一步,让用户使用一个指向设备来选择。

    -

    Three.js 提供了两个 VR 控制器对象,从而相对容易地处理单个 3DOF 控制器或两个 6DOF 控制器的情况。每个控制器都是一个 - Object3D - 对象,可以提供控制器的朝向和位置。它们还会在用户开始按下、正在按下和松开控制器主按钮时,分别触发 selectstartselect - 和 selectend 事件。

    -

    上一个示例 开始,让我们将 PickHelper - 更改为 ControllerPickHelper

    -

    新的实现会在选中对象时发出一个 select 事件。我们只需像这样使用它: -

    - -
    const pickHelper = new ControllerPickHelper(scene);
    -pickHelper.addEventListener('select', (event) => {
    -  event.selectedObject.visible = false;
    -  const partnerObject = meshToMeshMap.get(event.selectedObject);
    -  partnerObject.visible = true;
    -});
    -
    - -

    请记住,我们在之前的代码中使用了 meshToMeshMap - 来将立方体与球体进行映射。这样我们就可以通过一个对象来查找与之对应的另一个对象。在这里,我们只是隐藏了被选中的对象,并显示其对应对象。 -

    - -

    关于 ControllerPickHelper 的具体实现,首先我们需要将 VR - 控制器对象添加到场景中,并为它们添加一些 3D 线条,以可视化用户的指向方向。我们会保存这些控制器和线条。

    - - -
    class ControllerPickHelper {
    -  constructor(scene) {
    -    const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
    -      new THREE.Vector3(0, 0, 0),
    -      new THREE.Vector3(0, 0, -1),
    -    ]);
    -
    -    this.controllers = [];
    -    for (let i = 0; i < 2; ++i) {
    -      const controller = renderer.xr.getController(i);
    -      scene.add(controller);
    -
    -      const line = new THREE.Line(pointerGeometry);
    -      line.scale.z = 5;
    -      controller.add(line);
    -      this.controllers.push({controller, line});
    -    }
    -  }
    -}
    -
    - -

    - 即使没有其他任何操作,仅仅这样设置,我们就可以在场景中看到一条或两条线,显示出用户的指向设备的位置和方向。

    - -

    但我们面临一个问题,我们并不希望 RayCaster - 本身去拾取这些线条。一个简单的解决方案是,将可拾取对象与不可拾取对象分开,方法是将它们分别放到不同的 Object3D - 父对象下面。

    - -
    const scene = new THREE.Scene();
    -+// 用于放置可拾取对象,以便将其与不可拾取对象分离
    -+const pickRoot = new THREE.Object3D();
    -+scene.add(pickRoot);
    -
    -...
    -
    -function makeInstance(geometry, color, x) {
    -  const material = new THREE.MeshPhongMaterial({color});
    -
    -  const cube = new THREE.Mesh(geometry, material);
    --  scene.add(cube);
    -+  pickRoot.add(cube);
    -
    -...
    -
    - -

    接下来让我们添加一些代码,用来通过控制器进行拾取。这是我们第一次通过非相机的方式进行拾取。在我们关于拾取的文章中,用户使用鼠标或手指进行拾取,射线从相机出发,穿过屏幕。在上一篇文章中,我们是根据用户的注视方向进行拾取,也就是依赖相机方向。而这次,我们是从控制器的位置出发进行拾取,不再使用相机。 -

    - -
    class ControllerPickHelper {
    -  constructor(scene) {
    -+    this.raycaster = new THREE.Raycaster();
    -+    this.objectToColorMap = new Map();
    -+    this.controllerToObjectMap = new Map();
    -+    this.tempMatrix = new THREE.Matrix4();
    -
    -    const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
    -      new THREE.Vector3(0, 0, 0),
    -      new THREE.Vector3(0, 0, -1),
    -    ]);
    -
    -    this.controllers = [];
    -    for (let i = 0; i < 2; ++i) {
    -      const controller = renderer.xr.getController(i);
    -      scene.add(controller);
    -
    -      const line = new THREE.Line(pointerGeometry);
    -      line.scale.z = 5;
    -      controller.add(line);
    -      this.controllers.push({controller, line});
    -    }
    -  }
    -+  update(pickablesParent, time) {
    -+    this.reset();
    -+    for (const {controller, line} of this.controllers) {
    -+      // 从控制器发射一条射线
    -+      this.tempMatrix.identity().extractRotation(controller.matrixWorld);
    -+      this.raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
    -+      this.raycaster.ray.direction.set(0, 0, -1).applyMatrix4(this.tempMatrix);
    -+      // 获取射线与对象的交集
    -+      const intersections = this.raycaster.intersectObjects(pickablesParent.children);
    -+      if (intersections.length) {
    -+        const intersection = intersections[0];
    -+        // 调整线条长度,使其刚好触碰到对象
    -+        line.scale.z = intersection.distance;
    -+        // 选中第一个对象(最近的)
    -+        const pickedObject = intersection.object;
    -+        // 保存控制器选中的对象
    -+        this.controllerToObjectMap.set(controller, pickedObject);
    -+        // 如果尚未高亮该对象,则进行高亮
    -+        if (this.objectToColorMap.get(pickedObject) === undefined) {
    -+          // 保存其原始颜色
    -+          this.objectToColorMap.set(pickedObject, pickedObject.material.emissive.getHex());
    -+          // 设置为闪烁的红色/黄色
    -+          pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFF2000 : 0xFF0000);
    -+        }
    -+      } else {
    -+        line.scale.z = 5;
    -+      }
    -+    }
    -+  }
    -}
    -
    - -

    就像之前一样,我们使用了 Raycaster,不过这次射线是从控制器发出的。在以前的 - PickHelper 中,只有一个拾取点(相机),但这里我们有两个控制器,每个都可能在拾取对象。我们在 - controllerToObjectMap 中保存每个控制器所指向的对象,同时在 - objectToColorMap 中保存对象原本的高光颜色,并让射线刚好触碰到对象表面。 -

    - -

    我们需要添加代码,在每一帧重置这些设置。

    - -
    class ControllerPickHelper {
    -
    -  ...
    -
    -+  _reset() {
    -+    // 恢复颜色
    -+    this.objectToColorMap.forEach((color, object) => {
    -+      object.material.emissive.setHex(color);
    -+    });
    -+    this.objectToColorMap.clear();
    -+    this.controllerToObjectMap.clear();
    -+  }
    -  update(pickablesParent, time) {
    -+    this._reset();
    -
    -    ...
    -
    -}
    -
    - -

    接下来,我们希望在用户点击控制器时触发一个 select - 事件。为此,我们可以扩展 three.js 的 EventDispatcher,然后监听来自控制器的 select 事件,如果控制器当前指向某个对象,就向外派发一个带有该对象的自定义 - select 事件。

    - -
    -class ControllerPickHelper {
    -+class ControllerPickHelper extends THREE.EventDispatcher {
    -  constructor(scene) {
    -+    super();
    -    this.raycaster = new THREE.Raycaster();
    -    this.objectToColorMap = new Map();  // 保存颜色和被选中对象
    -    this.controllerToObjectMap = new Map();
    -    this.tempMatrix = new THREE.Matrix4();
    -
    -    const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
    -      new THREE.Vector3(0, 0, 0),
    -      new THREE.Vector3(0, 0, -1),
    -    ]);
    -
    -    this.controllers = [];
    -    for (let i = 0; i < 2; ++i) {
    -      const controller = renderer.xr.getController(i);
    -+      controller.addEventListener('select', (event) => {
    -+        const controller = event.target;
    -+        const selectedObject = this.controllerToObjectMap.get(controller);
    -+        if (selectedObject) {
    -+          this.dispatchEvent({type: 'select', controller, selectedObject});
    -+        }
    -+      });
    -      scene.add(controller);
    -
    -      const line = new THREE.Line(pointerGeometry);
    -      line.scale.z = 5;
    -      controller.add(line);
    -      this.controllers.push({controller, line});
    -    }
    -  }
    -}
    -
    - -

    现在我们只需在渲染循环中调用 update 方法即可:

    - -
    function render(time) {
    -
    -  ...
    -
    -+  pickHelper.update(pickablesParent, time);
    -
    -  renderer.render(scene, camera);
    -}
    -
    - -

    只要你有一个带控制器的 VR 设备,就应该能够用控制器来选择物体。

    - - -

    那如果我们想要能够移动这些物体呢?

    -

    其实相对简单。我们只需要把控制器的 select - 事件监听器代码提取到一个函数中,以便我们可以用于多个用途。

    - -
    class ControllerPickHelper extends THREE.EventDispatcher {
    -  constructor(scene) {
    -    super();
    -
    -    ...
    -
    -    this.controllers = [];
    -
    -+    const selectListener = (event) => {
    -+      const controller = event.target;
    -+      const selectedObject = this.controllerToObjectMap.get(event.target);
    -+      if (selectedObject) {
    -+        this.dispatchEvent({type: 'select', controller, selectedObject});
    -+      }
    -+    };
    -
    -    for (let i = 0; i < 2; ++i) {
    -      const controller = renderer.xr.getController(i);
    --      controller.addEventListener('select', (event) => {
    --        const controller = event.target;
    --        const selectedObject = this.controllerToObjectMap.get(event.target);
    --        if (selectedObject) {
    --          this.dispatchEvent({type: 'select', controller, selectedObject});
    --        }
    --      });
    -+      controller.addEventListener('select', selectListener);
    -
    -       ...
    -
    - -

    然后我们将其同时用于 selectstartselect 事件:

    - -
    class ControllerPickHelper extends THREE.EventDispatcher {
    -  constructor(scene) {
    -    super();
    -
    -    ...
    -
    -    this.controllers = [];
    -
    -    const selectListener = (event) => {
    -      const controller = event.target;
    -      const selectedObject = this.controllerToObjectMap.get(event.target);
    -      if (selectedObject) {
    --        this.dispatchEvent({type: 'select', controller, selectedObject});
    -+        this.dispatchEvent({type: event.type, controller, selectedObject});
    -      }
    -    };
    -
    -    for (let i = 0; i < 2; ++i) {
    -      const controller = renderer.xr.getController(i);
    -      controller.addEventListener('select', selectListener);
    -      controller.addEventListener('selectstart', selectListener);
    -
    -       ...
    -
    - -

    我们还要传递 selectend 事件,这是 three.js 在用户松开控制器按钮时发送的: -

    - -
    class ControllerPickHelper extends THREE.EventDispatcher {
    -  constructor(scene) {
    -    super();
    -
    -    ...
    -
    -    this.controllers = [];
    -
    -    const selectListener = (event) => {
    -      const controller = event.target;
    -      const selectedObject = this.controllerToObjectMap.get(event.target);
    -      if (selectedObject) {
    -        this.dispatchEvent({type: event.type, controller, selectedObject});
    -      }
    -    };
    -
    -+    const endListener = (event) => {
    -+      const controller = event.target;
    -+      this.dispatchEvent({type: event.type, controller});
    -+    };
    -
    -    for (let i = 0; i < 2; ++i) {
    -      const controller = renderer.xr.getController(i);
    -      controller.addEventListener('select', selectListener);
    -      controller.addEventListener('selectstart', selectListener);
    -+      controller.addEventListener('selectend', endListener);
    -
    -       ...
    -
    - -

    现在我们可以修改代码,使得当我们收到 selectstart - 事件时,把被选中的物体从场景中移除,并作为控制器的子对象。这样它就会跟随控制器移动。当我们收到 selectend 事件时,再把它放回原来的位置。

    - -
    const pickHelper = new ControllerPickHelper(scene);
    --pickHelper.addEventListener('select', (event) => {
    --  event.selectedObject.visible = false;
    --  const partnerObject = meshToMeshMap.get(event.selectedObject);
    --  partnerObject.visible = true;
    --});
    -
    -+const controllerToSelection = new Map();
    -+pickHelper.addEventListener('selectstart', (event) => {
    -+  const {controller, selectedObject} = event;
    -+  const existingSelection = controllerToSelection.get(controller);
    -+  if (!existingSelection) {
    -+    controllerToSelection.set(controller, {
    -+      object: selectedObject,
    -+      parent: selectedObject.parent,
    -+    });
    -+    controller.attach(selectedObject);
    -+  }
    -+});
    -+
    -+pickHelper.addEventListener('selectend', (event) => {
    -+  const {controller} = event;
    -+  const selection = controllerToSelection.get(controller);
    -+  if (selection) {
    -+    controllerToSelection.delete(controller);
    -+    selection.parent.attach(selection.object);
    -+  }
    -+});
    -
    - -

    当物体被选中时,我们保存该物体及其原始父级。当用户完成后,我们可以将物体放回原来的地方。

    - -

    我们使用了 Object3D.attach - 来重新设置选中物体的父级。这个函数允许我们在不改变对象位置和朝向的情况下更换其父对象。

    - -

    有了这些,我们现在就可以使用 6DOF 控制器来移动物体,或者使用 3DOF 控制器来改变它们的朝向。

    - - - -

    说实话,我并不确定这个 ControllerPickHelper - 是否是组织代码的最佳方式,但它在展示如何用 Three.js 在 VR 中实现基础交互方面非常实用。

    - - -
    -
    -
    - - - - - - - diff --git a/src/materials/nodes/Line2NodeMaterial.js b/src/materials/nodes/Line2NodeMaterial.js index 5ade4458a4d23e..4e69af766267b6 100644 --- a/src/materials/nodes/Line2NodeMaterial.js +++ b/src/materials/nodes/Line2NodeMaterial.js @@ -1,10 +1,10 @@ import NodeMaterial from './NodeMaterial.js'; import { dashSize, diffuseColor, gapSize, varyingProperty } from '../../nodes/core/PropertyNode.js'; import { attribute } from '../../nodes/core/AttributeNode.js'; -import { cameraProjectionMatrix } from '../../nodes/accessors/Camera.js'; +import { cameraProjectionMatrix, cameraProjectionMatrixInverse, cameraWorldMatrix } from '../../nodes/accessors/Camera.js'; import { materialLineScale, materialLineDashSize, materialLineGapSize, materialLineDashOffset, materialLineWidth } from '../../nodes/accessors/MaterialNode.js'; -import { modelViewMatrix } from '../../nodes/accessors/ModelNode.js'; -import { positionGeometry } from '../../nodes/accessors/Position.js'; +import { modelViewMatrix, modelWorldMatrixInverse } from '../../nodes/accessors/ModelNode.js'; +import { positionGeometry, positionLocal, positionPrevious } from '../../nodes/accessors/Position.js'; import { mix, smoothstep } from '../../nodes/math/MathNode.js'; import { Fn, float, vec2, vec3, vec4, If } from '../../nodes/tsl/TSLBase.js'; import { uv } from '../../nodes/accessors/UV.js'; @@ -518,15 +518,24 @@ class Line2NodeMaterial extends NodeMaterial { } /** - * Setups the position in clip space for the vertex stage of the fat line. - * Overrides the default model-view-projection to return the expanded fat line vertex coordinates. + * Setups the position of the expanded fat line vertex in local space. * * @param {NodeBuilder} builder - The current node builder. - * @return {Node} The position of the fat line vertex in clip space. + * @return {Node} The position of the fat line vertex in local space. */ - setupModelViewProjection( /*builder*/ ) { + setupPosition( builder ) { - return mvpLine; + const localPosition = modelWorldMatrixInverse.mul( cameraWorldMatrix ).mul( cameraProjectionMatrixInverse ).mul( mvpLine ); + + positionLocal.assign( localPosition.xyz.div( localPosition.w ) ); + + if ( builder.needsPreviousData() ) { + + positionPrevious.assign( positionLocal ); + + } + + return super.setupPosition( builder ); } diff --git a/utils/docs/template/tmpl/container.tmpl b/utils/docs/template/tmpl/container.tmpl index 1b921c164cfc7a..ba51458c7e1e60 100644 --- a/utils/docs/template/tmpl/container.tmpl +++ b/utils/docs/template/tmpl/container.tmpl @@ -42,7 +42,7 @@

    Import

    -

    is an addon, and must be imported explicitly, see Installation#Addons.

    +

    is an addon, and must be imported explicitly, see Installation#Addons.

    diff --git a/utils/llms/build.js b/utils/llms/build.js index 4c7a306d9d258d..8ca9bc4abc658e 100644 --- a/utils/llms/build.js +++ b/utils/llms/build.js @@ -170,26 +170,26 @@ When using TSL, use node-based materials: ## Getting Started -- [Installation](https://threejs.org/manual/#en/installation) -- [Creating a Scene](https://threejs.org/manual/#en/creating-a-scene) -- [Fundamentals](https://threejs.org/manual/#en/fundamentals) -- [Responsive Design](https://threejs.org/manual/#en/responsive) +- [Installation](https://threejs.org/manual/#installation) +- [Creating a Scene](https://threejs.org/manual/#creating-a-scene) +- [Fundamentals](https://threejs.org/manual/#fundamentals) +- [Responsive Design](https://threejs.org/manual/#responsive) ## Renderer Guides -- [WebGPURenderer](https://threejs.org/manual/#en/webgpurenderer) +- [WebGPURenderer](https://threejs.org/manual/#webgpurenderer) ## Core Concepts - [TSL Specification](https://threejs.org/docs/#api/en/nodes/TSL): Complete shader language reference -- [Animation System](https://threejs.org/manual/#en/animation-system) -- [Loading 3D Models](https://threejs.org/manual/#en/loading-3d-models) -- [Scene Graph](https://threejs.org/manual/#en/scenegraph) -- [Materials](https://threejs.org/manual/#en/materials) -- [Textures](https://threejs.org/manual/#en/textures) -- [Lights](https://threejs.org/manual/#en/lights) -- [Cameras](https://threejs.org/manual/#en/cameras) -- [Shadows](https://threejs.org/manual/#en/shadows) +- [Animation System](https://threejs.org/manual/#animation-system) +- [Loading 3D Models](https://threejs.org/manual/#loading-3d-models) +- [Scene Graph](https://threejs.org/manual/#scenegraph) +- [Materials](https://threejs.org/manual/#materials) +- [Textures](https://threejs.org/manual/#textures) +- [Lights](https://threejs.org/manual/#lights) +- [Cameras](https://threejs.org/manual/#cameras) +- [Shadows](https://threejs.org/manual/#shadows) ## Essential API