├── .gitignore ├── LICENSE ├── README.md ├── dist ├── bundle.js ├── cloud.jpg ├── grass.jpg └── index.html ├── package-lock.json ├── package.json ├── rollup.config.js └── src ├── index.js └── shaders ├── glsl ├── grass.frag.glsl └── grass.vert.glsl └── grass.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright © 2020-2021 James Smyth 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ThreeJS Grass Demo 2 | 3 | A simple demo showing stylized grass blowing in the wind. 4 | -------------------------------------------------------------------------------- /dist/cloud.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/James-Smyth/three-grass-demo/086fe4fcc178e174e6f06bf21de07847e3f84819/dist/cloud.jpg -------------------------------------------------------------------------------- /dist/grass.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/James-Smyth/three-grass-demo/086fe4fcc178e174e6f06bf21de07847e3f84819/dist/grass.jpg -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webgl-grass", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@rollup/plugin-node-resolve": { 8 | "version": "9.0.0", 9 | "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-9.0.0.tgz", 10 | "integrity": "sha512-gPz+utFHLRrd41WMP13Jq5mqqzHL3OXrfj3/MkSyB6UBIcuNt9j60GCbarzMzdf1VHFpOxfQh/ez7wyadLMqkg==", 11 | "dev": true, 12 | "requires": { 13 | "@rollup/pluginutils": "^3.1.0", 14 | "@types/resolve": "1.17.1", 15 | "builtin-modules": "^3.1.0", 16 | "deepmerge": "^4.2.2", 17 | "is-module": "^1.0.0", 18 | "resolve": "^1.17.0" 19 | } 20 | }, 21 | "@rollup/pluginutils": { 22 | "version": "3.1.0", 23 | "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", 24 | "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", 25 | "dev": true, 26 | "requires": { 27 | "@types/estree": "0.0.39", 28 | "estree-walker": "^1.0.1", 29 | "picomatch": "^2.2.2" 30 | }, 31 | "dependencies": { 32 | "estree-walker": { 33 | "version": "1.0.1", 34 | "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", 35 | "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", 36 | "dev": true 37 | } 38 | } 39 | }, 40 | "@types/estree": { 41 | "version": "0.0.39", 42 | "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", 43 | "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", 44 | "dev": true 45 | }, 46 | "@types/node": { 47 | "version": "14.11.1", 48 | "resolved": "https://registry.npmjs.org/@types/node/-/node-14.11.1.tgz", 49 | "integrity": "sha512-oTQgnd0hblfLsJ6BvJzzSL+Inogp3lq9fGgqRkMB/ziKMgEUaFl801OncOzUmalfzt14N0oPHMK47ipl+wbTIw==", 50 | "dev": true 51 | }, 52 | "@types/resolve": { 53 | "version": "1.17.1", 54 | "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", 55 | "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", 56 | "dev": true, 57 | "requires": { 58 | "@types/node": "*" 59 | } 60 | }, 61 | "builtin-modules": { 62 | "version": "3.1.0", 63 | "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.1.0.tgz", 64 | "integrity": "sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw==", 65 | "dev": true 66 | }, 67 | "deepmerge": { 68 | "version": "4.2.2", 69 | "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", 70 | "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", 71 | "dev": true 72 | }, 73 | "esm": { 74 | "version": "3.2.25", 75 | "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", 76 | "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==" 77 | }, 78 | "estree-walker": { 79 | "version": "0.6.1", 80 | "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", 81 | "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", 82 | "dev": true 83 | }, 84 | "is-module": { 85 | "version": "1.0.0", 86 | "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", 87 | "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", 88 | "dev": true 89 | }, 90 | "path-parse": { 91 | "version": "1.0.6", 92 | "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", 93 | "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", 94 | "dev": true 95 | }, 96 | "picomatch": { 97 | "version": "2.2.2", 98 | "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", 99 | "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", 100 | "dev": true 101 | }, 102 | "resolve": { 103 | "version": "1.17.0", 104 | "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", 105 | "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", 106 | "dev": true, 107 | "requires": { 108 | "path-parse": "^1.0.6" 109 | } 110 | }, 111 | "rollup-plugin-string": { 112 | "version": "3.0.0", 113 | "resolved": "https://registry.npmjs.org/rollup-plugin-string/-/rollup-plugin-string-3.0.0.tgz", 114 | "integrity": "sha512-vqyzgn9QefAgeKi+Y4A7jETeIAU1zQmS6VotH6bzm/zmUQEnYkpIGRaOBPY41oiWYV4JyBoGAaBjYMYuv+6wVw==", 115 | "dev": true, 116 | "requires": { 117 | "rollup-pluginutils": "^2.4.1" 118 | } 119 | }, 120 | "rollup-pluginutils": { 121 | "version": "2.8.2", 122 | "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", 123 | "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", 124 | "dev": true, 125 | "requires": { 126 | "estree-walker": "^0.6.1" 127 | } 128 | }, 129 | "three": { 130 | "version": "0.120.1", 131 | "resolved": "https://registry.npmjs.org/three/-/three-0.120.1.tgz", 132 | "integrity": "sha512-ktaCRFUR7JUZcKec+cBRz+oBex5pOVaJhrtxvFF2T7on53o9UkEux+/Nh1g/4zeb4t/pbxIFcADbn/ACu3LC1g==" 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webgl-grass", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "dev": "rollup -c -w" 9 | }, 10 | "author": " ", 11 | "license": "SEE LICENSE IN FILE", 12 | "dependencies": { 13 | "esm": "^3.2.25", 14 | "three": "^0.120.1" 15 | }, 16 | "devDependencies": { 17 | "@rollup/plugin-node-resolve": "^9.0.0", 18 | "rollup-plugin-string": "^3.0.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { string } from "rollup-plugin-string"; 2 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 3 | 4 | export default { 5 | input:'src/index.js', 6 | output:{ 7 | file:'dist/bundle.js', 8 | format:'iife' 9 | }, 10 | plugins: [ 11 | string({ 12 | include: "**/*.glsl" 13 | }), 14 | nodeResolve() 15 | ] 16 | }; 17 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; 3 | import grassShader from './shaders/grass.js'; 4 | const scene = new THREE.Scene(); 5 | const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000); 6 | 7 | // Parameters 8 | const PLANE_SIZE = 30; 9 | const BLADE_COUNT = 100000; 10 | const BLADE_WIDTH = 0.1; 11 | const BLADE_HEIGHT = 0.8; 12 | const BLADE_HEIGHT_VARIATION = 0.6; 13 | 14 | const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); 15 | renderer.setSize(window.innerWidth, window.innerHeight); 16 | document.body.appendChild(renderer.domElement); 17 | 18 | // Controls 19 | const controls = new OrbitControls(camera, renderer.domElement); 20 | controls.enablePan = false; 21 | controls.enableZoom = false; 22 | controls.minPolarAngle = 1.1; 23 | controls.maxPolarAngle = 1.45; 24 | controls.enableDamping = true; 25 | controls.dampingFactor = 0.1; 26 | controls.target.set(0, 0, 0); 27 | 28 | // Camera 29 | camera.position.set(-7, 3, 7); 30 | camera.lookAt(controls.target); 31 | camera.setFocalLength(15); 32 | 33 | // Grass Texture 34 | const grassTexture = new THREE.TextureLoader().load('grass.jpg'); 35 | const cloudTexture = new THREE.TextureLoader().load('cloud.jpg'); 36 | cloudTexture.wrapS = cloudTexture.wrapT = THREE.RepeatWrapping; 37 | 38 | // Time Uniform 39 | const startTime = Date.now(); 40 | const timeUniform = { type: 'f', value: 0.0 }; 41 | 42 | // Grass Shader 43 | const grassUniforms = { 44 | textures: { value: [grassTexture, cloudTexture] }, 45 | iTime: timeUniform 46 | }; 47 | 48 | const grassMaterial = new THREE.ShaderMaterial({ 49 | uniforms: grassUniforms, 50 | vertexShader: grassShader.vert, 51 | fragmentShader: grassShader.frag, 52 | vertexColors: true, 53 | side: THREE.DoubleSide 54 | }); 55 | 56 | generateField(); 57 | 58 | const animate = function () { 59 | const elapsedTime = Date.now() - startTime; 60 | controls.update(); 61 | grassUniforms.iTime.value = elapsedTime; 62 | window.requestAnimationFrame(animate); 63 | renderer.render(scene, camera); 64 | }; 65 | 66 | animate(); 67 | 68 | window.addEventListener('resize', () => { 69 | camera.aspect = window.innerWidth / window.innerHeight; 70 | camera.updateProjectionMatrix(); 71 | renderer.setSize(window.innerWidth, window.innerHeight); 72 | }); 73 | 74 | function convertRange (val, oldMin, oldMax, newMin, newMax) { 75 | return (((val - oldMin) * (newMax - newMin)) / (oldMax - oldMin)) + newMin; 76 | } 77 | 78 | function generateField () { 79 | const positions = []; 80 | const uvs = []; 81 | const indices = []; 82 | const colors = []; 83 | 84 | for (let i = 0; i < BLADE_COUNT; i++) { 85 | const VERTEX_COUNT = 5; 86 | const surfaceMin = PLANE_SIZE / 2 * -1; 87 | const surfaceMax = PLANE_SIZE / 2; 88 | const radius = PLANE_SIZE / 2; 89 | 90 | const r = radius * Math.sqrt(Math.random()); 91 | const theta = Math.random() * 2 * Math.PI; 92 | const x = r * Math.cos(theta); 93 | const y = r * Math.sin(theta); 94 | 95 | const pos = new THREE.Vector3(x, 0, y); 96 | 97 | const uv = [convertRange(pos.x, surfaceMin, surfaceMax, 0, 1), convertRange(pos.z, surfaceMin, surfaceMax, 0, 1)]; 98 | 99 | const blade = generateBlade(pos, i * VERTEX_COUNT, uv); 100 | blade.verts.forEach(vert => { 101 | positions.push(...vert.pos); 102 | uvs.push(...vert.uv); 103 | colors.push(...vert.color); 104 | }); 105 | blade.indices.forEach(indice => indices.push(indice)); 106 | } 107 | 108 | const geom = new THREE.BufferGeometry(); 109 | geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3)); 110 | geom.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(uvs), 2)); 111 | geom.setAttribute('color', new THREE.BufferAttribute(new Float32Array(colors), 3)); 112 | geom.setIndex(indices); 113 | geom.computeVertexNormals(); 114 | geom.computeFaceNormals(); 115 | 116 | const mesh = new THREE.Mesh(geom, grassMaterial); 117 | scene.add(mesh); 118 | } 119 | 120 | function generateBlade (center, vArrOffset, uv) { 121 | const MID_WIDTH = BLADE_WIDTH * 0.5; 122 | const TIP_OFFSET = 0.1; 123 | const height = BLADE_HEIGHT + (Math.random() * BLADE_HEIGHT_VARIATION); 124 | 125 | const yaw = Math.random() * Math.PI * 2; 126 | const yawUnitVec = new THREE.Vector3(Math.sin(yaw), 0, -Math.cos(yaw)); 127 | const tipBend = Math.random() * Math.PI * 2; 128 | const tipBendUnitVec = new THREE.Vector3(Math.sin(tipBend), 0, -Math.cos(tipBend)); 129 | 130 | // Find the Bottom Left, Bottom Right, Top Left, Top right, Top Center vertex positions 131 | const bl = new THREE.Vector3().addVectors(center, new THREE.Vector3().copy(yawUnitVec).multiplyScalar((BLADE_WIDTH / 2) * 1)); 132 | const br = new THREE.Vector3().addVectors(center, new THREE.Vector3().copy(yawUnitVec).multiplyScalar((BLADE_WIDTH / 2) * -1)); 133 | const tl = new THREE.Vector3().addVectors(center, new THREE.Vector3().copy(yawUnitVec).multiplyScalar((MID_WIDTH / 2) * 1)); 134 | const tr = new THREE.Vector3().addVectors(center, new THREE.Vector3().copy(yawUnitVec).multiplyScalar((MID_WIDTH / 2) * -1)); 135 | const tc = new THREE.Vector3().addVectors(center, new THREE.Vector3().copy(tipBendUnitVec).multiplyScalar(TIP_OFFSET)); 136 | 137 | tl.y += height / 2; 138 | tr.y += height / 2; 139 | tc.y += height; 140 | 141 | // Vertex Colors 142 | const black = [0, 0, 0]; 143 | const gray = [0.5, 0.5, 0.5]; 144 | const white = [1.0, 1.0, 1.0]; 145 | 146 | const verts = [ 147 | { pos: bl.toArray(), uv: uv, color: black }, 148 | { pos: br.toArray(), uv: uv, color: black }, 149 | { pos: tr.toArray(), uv: uv, color: gray }, 150 | { pos: tl.toArray(), uv: uv, color: gray }, 151 | { pos: tc.toArray(), uv: uv, color: white } 152 | ]; 153 | 154 | const indices = [ 155 | vArrOffset, 156 | vArrOffset + 1, 157 | vArrOffset + 2, 158 | vArrOffset + 2, 159 | vArrOffset + 4, 160 | vArrOffset + 3, 161 | vArrOffset + 3, 162 | vArrOffset, 163 | vArrOffset + 2 164 | ]; 165 | 166 | return { verts, indices }; 167 | } 168 | -------------------------------------------------------------------------------- /src/shaders/glsl/grass.frag.glsl: -------------------------------------------------------------------------------- 1 | uniform sampler2D texture1; 2 | uniform sampler2D textures[4]; 3 | 4 | varying vec2 vUv; 5 | varying vec2 cloudUV; 6 | varying vec3 vColor; 7 | 8 | void main() { 9 | float contrast = 1.5; 10 | float brightness = 0.1; 11 | vec3 color = texture2D(textures[0], vUv).rgb * contrast; 12 | color = color + vec3(brightness, brightness, brightness); 13 | color = mix(color, texture2D(textures[1], cloudUV).rgb, 0.4); 14 | gl_FragColor.rgb = color; 15 | gl_FragColor.a = 1.; 16 | } 17 | -------------------------------------------------------------------------------- /src/shaders/glsl/grass.vert.glsl: -------------------------------------------------------------------------------- 1 | varying vec2 vUv; 2 | varying vec2 cloudUV; 3 | 4 | varying vec3 vColor; 5 | uniform float iTime; 6 | 7 | void main() { 8 | vUv = uv; 9 | cloudUV = uv; 10 | vColor = color; 11 | vec3 cpos = position; 12 | 13 | float waveSize = 10.0f; 14 | float tipDistance = 0.3f; 15 | float centerDistance = 0.1f; 16 | 17 | if (color.x > 0.6f) { 18 | cpos.x += sin((iTime / 500.) + (uv.x * waveSize)) * tipDistance; 19 | }else if (color.x > 0.0f) { 20 | cpos.x += sin((iTime / 500.) + (uv.x * waveSize)) * centerDistance; 21 | } 22 | 23 | float diff = position.x - cpos.x; 24 | cloudUV.x += iTime / 20000.; 25 | cloudUV.y += iTime / 10000.; 26 | 27 | vec4 worldPosition = vec4(cpos, 1.); 28 | vec4 mvPosition = projectionMatrix * modelViewMatrix * vec4(cpos, 1.0); 29 | gl_Position = mvPosition; 30 | } 31 | -------------------------------------------------------------------------------- /src/shaders/grass.js: -------------------------------------------------------------------------------- 1 | import vert from './glsl/grass.vert.glsl'; 2 | import frag from './glsl/grass.frag.glsl'; 3 | export default { frag, vert }; 4 | --------------------------------------------------------------------------------