├── selene.toml ├── stylua.toml ├── default.project.json ├── .gitignore ├── aftman.toml ├── package.json ├── wally.toml ├── demo.project.json ├── LICENSE ├── src ├── Settings.lua ├── VectorMap.lua └── init.lua ├── demo └── client │ └── WindController │ ├── WindLines.lua │ └── init.client.lua └── README.md /selene.toml: -------------------------------------------------------------------------------- 1 | std = "roblox" 2 | -------------------------------------------------------------------------------- /stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 120 2 | quote_style = "ForceDouble" 3 | -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "WindShake", 3 | "tree": { 4 | "$path": "src" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project build files 2 | /*.rbxl 3 | /*.rbxlx 4 | /*.rbxm 5 | /*.rbxmx 6 | /sourcemap.json 7 | 8 | # Roblox Studio lock files 9 | /*.rbxlx.lock 10 | /*.rbxl.lock 11 | 12 | # VS Code files 13 | .vscode/* 14 | -------------------------------------------------------------------------------- /aftman.toml: -------------------------------------------------------------------------------- 1 | # This file lists tools managed by Aftman, a cross-platform toolchain manager. 2 | # For more information, see https://github.com/LPGhatguy/aftman 3 | 4 | # To add a new tool, add an entry to this table. 5 | [tools] 6 | rojo = "rojo-rbx/rojo@7.3.0" 7 | wally = "upliftgames/wally@0.3.2" 8 | selene = "Kampfkarren/selene@0.25.0" 9 | stylua = "JohnnyMorganz/stylua@0.17.1" 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boatbomber/windshake", 3 | "version": "2.0.3", 4 | "license": "MIT", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/boatbomber/WindShake.git" 8 | }, 9 | "contributors": [ 10 | "boatbomber" 11 | ], 12 | "bugs": { 13 | "url": "https://github.com/boatbomber/WindShake/issues" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "boatbomber/windshake" 3 | description = "High performance wind effect for leaves and foliage" 4 | version = "2.0.3" 5 | license = "MIT" 6 | authors = ["boatbomber (https://boatbomber.com)"] 7 | registry = "https://github.com/upliftgames/wally-index" 8 | realm = "shared" 9 | include = ["src", "src/**", "wally.toml", "wally.lock", "default.project.json", "README.md", "LICENSE"] 10 | exclude = ["**"] 11 | -------------------------------------------------------------------------------- /demo.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Wind Demo", 3 | "tree": { 4 | "$className": "DataModel", 5 | 6 | "StarterPlayer": { 7 | "$className": "StarterPlayer", 8 | 9 | "StarterPlayerScripts": { 10 | "$className": "StarterPlayerScripts", 11 | 12 | "WindController": { 13 | "$path": "demo/Client/WindController", 14 | "WindShake": { 15 | "$path": "src" 16 | } 17 | } 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 boatbomber 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Settings.lua: -------------------------------------------------------------------------------- 1 | --!strict 2 | local Settings = {} 3 | 4 | export type Class = { 5 | Destroy: (any) -> (), 6 | WindPower: number?, 7 | WindSpeed: number?, 8 | PivotOffset: CFrame?, 9 | WindDirection: Vector3?, 10 | PivotOffsetInverse: CFrame?, 11 | } 12 | 13 | local function Normalize(vec: Vector3) 14 | return vec.Magnitude > 0 and vec.Unit or Vector3.zero 15 | end 16 | 17 | function Settings.new(object: BasePart | Bone | ModuleScript): Class 18 | local objectSettings = {} 19 | 20 | -- Initial settings 21 | local WindPower = object:GetAttribute("WindPower") 22 | local WindSpeed = object:GetAttribute("WindSpeed") 23 | local WindDirection = object:GetAttribute("WindDirection") 24 | 25 | objectSettings.WindPower = if type(WindPower) == "number" 26 | then WindPower 27 | else nil 28 | 29 | objectSettings.WindSpeed = if type(WindSpeed) == "number" 30 | then WindSpeed 31 | else nil 32 | 33 | objectSettings.WindDirection = if typeof(WindDirection) == "Vector3" 34 | then Normalize(WindDirection) 35 | else nil 36 | 37 | objectSettings.PivotOffset = if object:IsA("BasePart") 38 | then object.PivotOffset 39 | else nil 40 | 41 | objectSettings.PivotOffsetInverse = if objectSettings.PivotOffset 42 | then objectSettings.PivotOffset:Inverse() 43 | else nil 44 | 45 | -- Update settings on event 46 | local Conns = {} :: { 47 | [string]: RBXScriptConnection 48 | } 49 | 50 | Conns.PowerConnection = object:GetAttributeChangedSignal("WindPower"):Connect(function() 51 | WindPower = object:GetAttribute("WindPower") 52 | objectSettings.WindPower = if type(WindPower) == "number" then WindPower else nil 53 | end) 54 | 55 | Conns.SpeedConnection = object:GetAttributeChangedSignal("WindSpeed"):Connect(function() 56 | WindSpeed = object:GetAttribute("WindSpeed") 57 | objectSettings.WindSpeed = if type(WindSpeed) == "number" then WindSpeed else nil 58 | end) 59 | 60 | Conns.DirectionConnection = object:GetAttributeChangedSignal("WindDirection"):Connect(function() 61 | WindDirection = object:GetAttribute("WindDirection") 62 | objectSettings.WindDirection = if typeof(WindDirection) == "Vector3" then Normalize(WindDirection) else nil 63 | end) 64 | 65 | if object:IsA("BasePart") then 66 | Conns.PivotConnection = object:GetPropertyChangedSignal("PivotOffset"):Connect(function() 67 | local pivotOffset = object.PivotOffset 68 | objectSettings.PivotOffset = pivotOffset 69 | objectSettings.PivotOffsetInverse = pivotOffset:Inverse() 70 | end) 71 | end 72 | 73 | -- Cleanup function for when shake is removed or object is unloaded 74 | function objectSettings.Destroy(_self: any) 75 | for i, conn in pairs(Conns) do 76 | conn:Disconnect() 77 | end 78 | end 79 | 80 | return objectSettings 81 | end 82 | 83 | return Settings 84 | -------------------------------------------------------------------------------- /demo/client/WindController/WindLines.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | local Terrain = workspace:FindFirstChildOfClass("Terrain") 3 | 4 | local EMPTY_TABLE = {} 5 | local OFFSET = Vector3.new(0, 0.1, 0) 6 | 7 | local module = {} 8 | 9 | module.UpdateConnection = nil 10 | module.UpdateQueue = table.create(10) 11 | 12 | function module:Init(Settings) 13 | -- Set defaults 14 | module.Lifetime = Settings.Lifetime or 3 15 | module.Direction = Settings.Direction or Vector3.new(1, 0, 0) 16 | module.Speed = Settings.Speed or 6 17 | 18 | -- Clear any old stuff 19 | if module.UpdateConnection then 20 | module.UpdateConnection:Disconnect() 21 | module.UpdateConnection = nil 22 | end 23 | 24 | for _, WindLine in module.UpdateQueue do 25 | WindLine.Attachment0:Destroy() 26 | WindLine.Attachment1:Destroy() 27 | WindLine.Trail:Destroy() 28 | end 29 | table.clear(module.UpdateQueue) 30 | 31 | module.LastSpawned = os.clock() 32 | local SpawnRate = 1 / (Settings.SpawnRate or 25) 33 | 34 | -- Setup logic loop 35 | module.UpdateConnection = RunService.Heartbeat:Connect(function() 36 | local Clock = os.clock() 37 | 38 | -- Spawn handler 39 | if Clock - module.LastSpawned > SpawnRate then 40 | module:Create() 41 | module.LastSpawned = Clock 42 | end 43 | 44 | -- Update queue handler 45 | debug.profilebegin("Wind Lines") 46 | for i, WindLine in module.UpdateQueue do 47 | local AliveTime = Clock - WindLine.StartClock 48 | if AliveTime >= WindLine.Lifetime then 49 | -- Destroy the objects 50 | WindLine.Attachment0:Destroy() 51 | WindLine.Attachment1:Destroy() 52 | WindLine.Trail:Destroy() 53 | 54 | -- unordered remove at this index 55 | local Length = #module.UpdateQueue 56 | module.UpdateQueue[i] = module.UpdateQueue[Length] 57 | module.UpdateQueue[Length] = nil 58 | 59 | continue 60 | end 61 | 62 | WindLine.Trail.MaxLength = 20 - (20 * (AliveTime / WindLine.Lifetime)) 63 | 64 | local SeededClock = (Clock + WindLine.Seed) * (WindLine.Speed * 0.2) 65 | local StartPos = WindLine.Position 66 | WindLine.Attachment0.WorldPosition = (CFrame.new(StartPos, StartPos + WindLine.Direction) * CFrame.new( 67 | 0, 68 | 0, 69 | WindLine.Speed * -AliveTime 70 | )).Position + Vector3.new( 71 | math.sin(SeededClock) * 0.5, 72 | math.sin(SeededClock) * 0.8, 73 | math.sin(SeededClock) * 0.5 74 | ) 75 | 76 | WindLine.Attachment1.WorldPosition = WindLine.Attachment0.WorldPosition + OFFSET 77 | end 78 | debug.profileend() 79 | end) 80 | end 81 | 82 | function module:Cleanup() 83 | if module.UpdateConnection then 84 | module.UpdateConnection:Disconnect() 85 | module.UpdateConnection = nil 86 | end 87 | 88 | for _, WindLine in module.UpdateQueue do 89 | WindLine.Attachment0:Destroy() 90 | WindLine.Attachment1:Destroy() 91 | WindLine.Trail:Destroy() 92 | end 93 | table.clear(module.UpdateQueue) 94 | end 95 | 96 | function module:Create(Settings) 97 | debug.profilebegin("Add Wind Line") 98 | 99 | Settings = Settings or EMPTY_TABLE 100 | 101 | local Lifetime = Settings.Lifetime or module.Lifetime 102 | local Position = Settings.Position 103 | or ( 104 | workspace.CurrentCamera.CFrame 105 | * CFrame.Angles(math.rad(math.random(-30, 70)), math.rad(math.random(-80, 80)), 0) 106 | ) 107 | * CFrame.new(0, 0, math.random(200, 600) * -0.1).Position 108 | local Direction = Settings.Direction or module.Direction 109 | local Speed = Settings.Speed or module.Speed 110 | if Speed <= 0 then 111 | return 112 | end 113 | 114 | local Attachment0 = Instance.new("Attachment") 115 | local Attachment1 = Instance.new("Attachment") 116 | 117 | local Trail = Instance.new("Trail") 118 | Trail.Attachment0 = Attachment0 119 | Trail.Attachment1 = Attachment1 120 | Trail.WidthScale = NumberSequence.new({ 121 | NumberSequenceKeypoint.new(0, 0.3), 122 | NumberSequenceKeypoint.new(0.2, 1), 123 | NumberSequenceKeypoint.new(0.8, 1), 124 | NumberSequenceKeypoint.new(1, 0.3), 125 | }) 126 | Trail.Transparency = NumberSequence.new(0.7) 127 | Trail.FaceCamera = true 128 | Trail.Parent = Attachment0 129 | 130 | Attachment0.WorldPosition = Position 131 | Attachment1.WorldPosition = Position + OFFSET 132 | 133 | local WindLine = { 134 | Attachment0 = Attachment0, 135 | Attachment1 = Attachment1, 136 | Trail = Trail, 137 | Lifetime = Lifetime + (math.random(-10, 10) * 0.1), 138 | Position = Position, 139 | Direction = Direction, 140 | Speed = Speed + (math.random(-10, 10) * 0.1), 141 | StartClock = os.clock(), 142 | Seed = math.random(1, 1000) * 0.1, 143 | } 144 | 145 | module.UpdateQueue[#module.UpdateQueue + 1] = WindLine 146 | 147 | Attachment0.Parent = Terrain 148 | Attachment1.Parent = Terrain 149 | 150 | debug.profileend() 151 | end 152 | 153 | return module 154 | -------------------------------------------------------------------------------- /demo/client/WindController/init.client.lua: -------------------------------------------------------------------------------- 1 | local WIND_DIRECTION = Vector3.new(1, 0, 0.3) 2 | local WIND_SPEED = 20 3 | local WIND_POWER = 0.5 4 | local SHAKE_DISTANCE = 150 5 | 6 | local WindLines = require(script.WindLines) 7 | local WindShake = require(script.WindShake) 8 | 9 | WindLines:Init({ 10 | Direction = WIND_DIRECTION, 11 | Speed = WIND_SPEED, 12 | Lifetime = 1.5, 13 | SpawnRate = 11, 14 | }) 15 | 16 | WindShake:SetDefaultSettings({ 17 | WindSpeed = WIND_SPEED, 18 | WindDirection = WIND_DIRECTION, 19 | WindPower = WIND_POWER, 20 | }) 21 | WindShake:Init({ 22 | MatchWorkspaceWind = true, 23 | }) 24 | 25 | -- We told it to match the workspace wind, so it'll have computed 26 | -- new default settings based on workspace.GlobalWind. 27 | -- To make sure our dev guis match this, we'll read back the default settings. 28 | WIND_SPEED = script.WindShake:GetAttribute("WindSpeed") 29 | WIND_DIRECTION = script.WindShake:GetAttribute("WindDirection") 30 | WIND_POWER = script.WindShake:GetAttribute("WindPower") 31 | 32 | -- Demo dynamic settings 33 | 34 | local Gui = Instance.new("ScreenGui") 35 | 36 | local CountLabel = Instance.new("TextLabel") 37 | CountLabel.Text = string.format("Leaf Count: %d Active, %d Inactive, 77760 Total", 0, 0) 38 | CountLabel.BackgroundTransparency = 0.3 39 | CountLabel.BackgroundColor3 = Color3.new() 40 | CountLabel.TextStrokeTransparency = 0.8 41 | CountLabel.Size = UDim2.new(0.6, 0, 0, 27) 42 | CountLabel.Position = UDim2.new(0.2, 0, 1, -35) 43 | CountLabel.Font = Enum.Font.RobotoMono 44 | CountLabel.TextSize = 25 45 | CountLabel.TextColor3 = Color3.new(1, 1, 1) 46 | CountLabel.Parent = Gui 47 | 48 | local SpeedInput = Instance.new("TextBox") 49 | SpeedInput.Text = string.format("Wind Speed: %.1f", WIND_SPEED) 50 | SpeedInput.PlaceholderText = "Input Speed" 51 | SpeedInput.BackgroundTransparency = 0.8 52 | SpeedInput.TextStrokeTransparency = 0.8 53 | SpeedInput.Size = UDim2.new(0.2, 0, 0, 20) 54 | SpeedInput.Position = UDim2.new(0, 5, 0.45, 0) 55 | SpeedInput.Font = Enum.Font.RobotoMono 56 | SpeedInput.TextXAlignment = Enum.TextXAlignment.Left 57 | SpeedInput.TextSize = 18 58 | SpeedInput.TextColor3 = Color3.new(1, 1, 1) 59 | SpeedInput.FocusLost:Connect(function() 60 | local newSpeed = tonumber(SpeedInput.Text:match("[%d%.]+")) 61 | if newSpeed then 62 | WIND_SPEED = math.clamp(newSpeed, 0, 50) 63 | WindLines.Speed = WIND_SPEED 64 | WindShake:UpdateAllObjectSettings({ WindSpeed = WIND_SPEED }) 65 | WindShake:SetDefaultSettings({ WindSpeed = WIND_SPEED }) 66 | end 67 | SpeedInput.Text = string.format("Wind Speed: %.1f", WIND_SPEED) 68 | end) 69 | SpeedInput.Parent = Gui 70 | 71 | local PowerInput = Instance.new("TextBox") 72 | PowerInput.Text = string.format("Wind Power: %.1f", WIND_POWER) 73 | PowerInput.PlaceholderText = "Input Power" 74 | PowerInput.BackgroundTransparency = 0.8 75 | PowerInput.TextStrokeTransparency = 0.8 76 | PowerInput.Size = UDim2.new(0.2, 0, 0, 20) 77 | PowerInput.Position = UDim2.new(0, 5, 0.45, 25) 78 | PowerInput.Font = Enum.Font.RobotoMono 79 | PowerInput.TextXAlignment = Enum.TextXAlignment.Left 80 | PowerInput.TextSize = 18 81 | PowerInput.TextColor3 = Color3.new(1, 1, 1) 82 | PowerInput.FocusLost:Connect(function() 83 | local newPower = tonumber(PowerInput.Text:match("[%d%.]+")) 84 | if newPower then 85 | WIND_POWER = math.clamp(newPower, 0, 10) 86 | WindShake:UpdateAllObjectSettings({ WindPower = WIND_POWER }) 87 | WindShake:SetDefaultSettings({ WindPower = WIND_POWER }) 88 | end 89 | PowerInput.Text = string.format("Wind Power: %.1f", WIND_POWER) 90 | end) 91 | PowerInput.Parent = Gui 92 | 93 | local DirInput = Instance.new("TextBox") 94 | DirInput.Text = string.format("Wind Direction: %.1f,%.1f,%.1f", WIND_DIRECTION.X, WIND_DIRECTION.Y, WIND_DIRECTION.Z) 95 | DirInput.PlaceholderText = "Input Direction" 96 | DirInput.BackgroundTransparency = 0.8 97 | DirInput.TextStrokeTransparency = 0.8 98 | DirInput.Size = UDim2.new(0.2, 0, 0, 20) 99 | DirInput.Position = UDim2.new(0, 5, 0.45, 50) 100 | DirInput.Font = Enum.Font.RobotoMono 101 | DirInput.TextXAlignment = Enum.TextXAlignment.Left 102 | DirInput.TextSize = 18 103 | DirInput.TextColor3 = Color3.new(1, 1, 1) 104 | DirInput.FocusLost:Connect(function() 105 | local Inputs = table.create(3) 106 | for Num in string.gmatch(DirInput.Text, "%-?[%d%.]+") do 107 | Inputs[#Inputs + 1] = tonumber(Num) 108 | end 109 | 110 | local newDir = 111 | Vector3.new(Inputs[1] or WIND_DIRECTION.X, Inputs[2] or WIND_DIRECTION.Y, Inputs[3] or WIND_DIRECTION.Z).Unit 112 | if newDir then 113 | WIND_DIRECTION = newDir 114 | WindLines.Direction = newDir 115 | WindShake:UpdateAllObjectSettings({ WindDirection = newDir }) 116 | WindShake:SetDefaultSettings({ WindDirection = newDir }) 117 | end 118 | 119 | DirInput.Text = 120 | string.format("Wind Direction: %.1f, %.1f, %.1f", WIND_DIRECTION.X, WIND_DIRECTION.Y, WIND_DIRECTION.Z) 121 | end) 122 | DirInput.Parent = Gui 123 | 124 | local DistanceInput = Instance.new("TextBox") 125 | DistanceInput.Text = string.format("Shake Distance: %.1f", SHAKE_DISTANCE) 126 | DistanceInput.PlaceholderText = "Input Distance" 127 | DistanceInput.BackgroundTransparency = 0.8 128 | DistanceInput.TextStrokeTransparency = 0.8 129 | DistanceInput.Size = UDim2.new(0.2, 0, 0, 20) 130 | DistanceInput.Position = UDim2.new(0, 5, 0.45, 75) 131 | DistanceInput.Font = Enum.Font.RobotoMono 132 | DistanceInput.TextXAlignment = Enum.TextXAlignment.Left 133 | DistanceInput.TextSize = 18 134 | DistanceInput.TextColor3 = Color3.new(1, 1, 1) 135 | DistanceInput.FocusLost:Connect(function() 136 | local newDistance = tonumber(DistanceInput.Text:match("[%d%.]+")) 137 | if newDistance then 138 | SHAKE_DISTANCE = math.clamp(newDistance, 5, 500) 139 | WindShake.RenderDistance = SHAKE_DISTANCE 140 | end 141 | DistanceInput.Text = string.format("Shake Distance: %.1f", SHAKE_DISTANCE) 142 | end) 143 | DistanceInput.Parent = Gui 144 | 145 | Gui.Parent = game.Players.LocalPlayer:WaitForChild("PlayerGui") 146 | 147 | task.defer(function() 148 | while task.wait(0.1) do 149 | local Active, Handled = WindShake.Active, WindShake.Handled 150 | CountLabel.Text = string.format( 151 | "Leaf Count: %d Active, %d Inactive, %d Not Streamed In (77760 Total)", 152 | Active, 153 | Handled - Active, 154 | 77760 - Handled 155 | ) 156 | end 157 | end) 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WindShake 2 | 3 | High performance wind effect for leaves and foliage 4 | 5 | *by boatbomber* 6 | 7 | I wanted to have massive forests full of moving leaves. Not for any project of mine, I just wanted to make it cuz it sounded like fun. So I did. And now you guys benefit! 8 | 9 | This module handled 77,750+ leaf meshes while the game ran at over 400 FPS on my machine. It's pretty darn fast, all things considered. 10 | 11 | Demo: 12 | https://www.youtube.com/watch?v=WdJr7k9Uqfw 13 | 14 | ----- 15 | 16 | # Source: 17 | 18 | **GitHub:** 19 | 20 | https://github.com/boatbomber/WindShake 21 | 22 | **Library:** 23 | 24 | https://www.roblox.com/library/6377120469/WindShake 25 | 26 | **Uncopylocked Demo:** 27 | 28 | https://www.roblox.com/games/6342320514/Wind-Demo 29 | 30 | ----- 31 | 32 | # API: 33 | 34 | ## Properties 35 | 36 | ```Lua 37 | number WindShake.RenderDistance 38 | ``` 39 | *Sets the render distance for active objects in studs. Default 150* 40 | 41 | ```Lua 42 | number WindShake.MaxRefreshRate 43 | ``` 44 | *Sets the maximum dynamic refresh rate for active objects in seconds. Default 1/60* 45 | 46 | 47 | ## Functions 48 | 49 | ```Lua 50 | function WindShake:Init(config: { 51 | MatchWorkspaceWind: boolean?, 52 | }?) 53 | ``` 54 | *Initializes the wind shake logic and adds shake to all tagged objects* 55 | 56 | **Parameters:** 57 | - `config` *[Optional Dictionary]* 58 | 59 | Configuration for the initialization 60 | - `MatchWorkspaceWind` *[Optional Boolean]* 61 | 62 | Whether to match the wind settings to the Workspace's GlobalWind setting. Default false 63 | 64 | 65 | **Returns:** 66 | * `void` 67 | 68 | ```Lua 69 | function WindShake:Cleanup() 70 | ``` 71 | *Halts and clears the wind shake logic and all object shakes* 72 | 73 | **Returns:** 74 | * `void` 75 | 76 | ```Lua 77 | function WindShake:Pause() 78 | ``` 79 | *Halts the wind shake logic without clearing* 80 | 81 | **Returns:** 82 | * `void` 83 | 84 | ```Lua 85 | function WindShake:Resume() 86 | ``` 87 | *Restarts the wind shake logic without clearing* 88 | 89 | **Returns:** 90 | * `void` 91 | 92 | ```Lua 93 | function WindShake:AddObjectShake(Object, Settings) 94 | ``` 95 | *Adds an object to be shaken* 96 | 97 | **Parameters:** 98 | - `Object` *[BasePart]* 99 | The Object to apply shaking to 100 | 101 | - `Settings` *[Optional Dictionary]* 102 | The settings to apply to this object's shake (See below for Settings structure) 103 | 104 | **Returns:** 105 | * `void` 106 | 107 | ```Lua 108 | function WindShake:RemoveObjectShake(Object) 109 | ``` 110 | *Removes shake from an object* 111 | 112 | **Parameters:** 113 | - `Object` *[BasePart]* 114 | The Object to remove shaking from 115 | 116 | **Returns:** 117 | * `void` 118 | 119 | ```Lua 120 | function WindShake:SetDefaultSettings(Settings) [DEPRECATED] 121 | ``` 122 | > Deprecated in favor of setting the Attributes of the WindShake modulescript 123 | 124 | *Sets the default settings for future object shake additions* 125 | 126 | 127 | **Parameters:** 128 | 129 | - `Settings` *[Dictionary]* 130 | The settings to use as default (See below for Settings structure) 131 | 132 | **Returns:** 133 | * `void` 134 | 135 | ```Lua 136 | function WindShake:UpdateObjectSettings(Object, Settings) [DEPRECATED] 137 | ``` 138 | > Deprecated in favor of setting the Attributes of the Object 139 | 140 | *Updates the shake settings of an object already added* 141 | 142 | **Parameters:** 143 | - `Object` *[BasePart]* 144 | The Object to apply shake settings to 145 | 146 | - `Settings` *[Dictionary]* 147 | The settings to apply to this object's shake (See below for Settings structure) 148 | 149 | **Returns:** 150 | * `void` 151 | 152 | ```Lua 153 | function WindShake:UpdateAllObjectSettings(Settings) 154 | ``` 155 | *Updates the shake settings of all active shakes* 156 | 157 | **Parameters:** 158 | 159 | - `Settings` *[Dictionary]* 160 | The settings to apply to all objects' shake (See below for Settings structure) 161 | 162 | **Returns:** 163 | * `void` 164 | 165 | ```Lua 166 | function WindShake:MatchWorkspaceWind() 167 | ``` 168 | *Sets the wind settings to match the current workspace GlobalWind* 169 | 170 | > When `:Init()` is called with the `MatchWorkspaceWind` config set to true, this is called automatically 171 | 172 | **Returns:** 173 | * `void` 174 | 175 | ## Events 176 | 177 | ```Lua 178 | RBXScriptSignal WindShake.ObjectShakeAdded(Object) 179 | ``` 180 | *Fires when an object is added to WindShake* 181 | 182 | **Parameters:** 183 | 184 | - `Object` *[BasePart]* 185 | The object that was added 186 | 187 | ```Lua 188 | RBXScriptSignal WindShake.ObjectShakeRemoved(Object) 189 | ``` 190 | *Fires when an object is removed from WindShake* 191 | 192 | **Parameters:** 193 | 194 | - `Object` *[BasePart]* 195 | The object that was removed 196 | 197 | ```Lua 198 | RBXScriptSignal WindShake.ObjectShakeUpdated(Object) 199 | ``` 200 | *Fires when an object's settings are updated through the update APIs* 201 | 202 | **Parameters:** 203 | 204 | - `Object` *[BasePart]* 205 | The object that had its settings updated 206 | 207 | ```Lua 208 | RBXScriptSignal WindShake.Resumed() 209 | ``` 210 | *Fires when WindShake begins shaking the objects* 211 | 212 | ```Lua 213 | RBXScriptSignal WindShake.Paused() 214 | ``` 215 | *Fires when WindShake stops shaking the objects* 216 | 217 | ## Types 218 | 219 | ```Lua 220 | Settings = { 221 | WindDirection: Vector3 to shake towards (Initially 0.5,0,0.5) 222 | WindSpeed: Positive number that defines how fast to shake (Initially 20) 223 | WindPower: Positive number that defines how much to shake (Initially 0.5) 224 | 225 | --If one of these is not defined, it will use default for that one, 226 | --so you can pass a table with just one or two settings and the rest 227 | --will be default so you don't need to make the full table every time. 228 | } 229 | ``` 230 | 231 | ----- 232 | 233 | # Usage Example: 234 | 235 | ```Lua 236 | local WIND_DIRECTION = Vector3.new(1,0,0.3) 237 | local WIND_SPEED = 25 238 | local WIND_POWER = 0.4 239 | 240 | local WindShake = require(script.WindShake) 241 | 242 | WindShake:SetDefaultSettings({ 243 | WindSpeed = WIND_SPEED; 244 | WindDirection = WIND_DIRECTION; 245 | WindPower = WIND_POWER; 246 | }) 247 | 248 | WindShake:Init() -- Anything with the WindShake tag will now shake 249 | 250 | ``` 251 | -------------------------------------------------------------------------------- /src/VectorMap.lua: -------------------------------------------------------------------------------- 1 | --!strict 2 | 3 | local VectorMap = {} 4 | VectorMap.__index = VectorMap 5 | 6 | export type Class = typeof(setmetatable({} :: { 7 | _voxelSize: number, 8 | 9 | _voxels: { 10 | [Vector3]: { 11 | [string]: { any } 12 | } 13 | }, 14 | }, VectorMap)) 15 | 16 | function VectorMap.new(voxelSize: number?) 17 | return setmetatable({ 18 | _voxelSize = voxelSize or 50, 19 | _voxels = {}, 20 | }, VectorMap) 21 | end 22 | 23 | function VectorMap._debugDrawVoxel(self: Class, voxelKey: Vector3) 24 | local box = Instance.new("Part") 25 | box.Name = tostring(voxelKey) 26 | box.Anchored = true 27 | box.CanCollide = false 28 | box.Transparency = 1 29 | box.Size = Vector3.one * self._voxelSize 30 | box.Position = (voxelKey * self._voxelSize) + (Vector3.one * (self._voxelSize / 2)) 31 | box.Parent = workspace 32 | 33 | local selection = Instance.new("SelectionBox") 34 | selection.Color3 = Color3.new(0, 0, 1) 35 | selection.Adornee = box 36 | selection.Parent = box 37 | 38 | task.delay(1 / 30, box.Destroy, box) 39 | end 40 | 41 | function VectorMap.AddObject(self: Class, position: Vector3, object: any) 42 | local className = object.ClassName 43 | local voxelSize = self._voxelSize 44 | 45 | local voxelKey = Vector3.new( 46 | math.floor(position.X / voxelSize), 47 | math.floor(position.Y / voxelSize), 48 | math.floor(position.Z / voxelSize) 49 | ) 50 | 51 | local voxel = self._voxels[voxelKey] 52 | 53 | if voxel == nil then 54 | self._voxels[voxelKey] = { 55 | [className] = { object }, 56 | } 57 | elseif voxel[className] == nil then 58 | voxel[className] = { object } 59 | else 60 | table.insert(voxel[className], object) 61 | end 62 | 63 | return voxelKey 64 | end 65 | 66 | function VectorMap.RemoveObject(self: Class, voxelKey: Vector3, object: any) 67 | local voxel = self._voxels[voxelKey] 68 | 69 | if voxel == nil then 70 | return 71 | end 72 | 73 | local className = object.ClassName 74 | if voxel[className] == nil then 75 | return 76 | end 77 | 78 | local classBucket = voxel[className] 79 | for index, storedObject in classBucket do 80 | if storedObject == object then 81 | -- Swap remove to avoid shifting 82 | local n = #classBucket 83 | classBucket[index] = classBucket[n] 84 | classBucket[n] = nil 85 | break 86 | end 87 | end 88 | 89 | -- Remove empty class bucket 90 | if #classBucket == 0 then 91 | voxel[className] = nil 92 | 93 | -- Remove empty voxel 94 | if next(voxel) == nil then 95 | self._voxels[voxelKey] = nil 96 | end 97 | end 98 | end 99 | 100 | function VectorMap.GetVoxel(self: Class, voxelKey: Vector3) 101 | return self._voxels[voxelKey] 102 | end 103 | 104 | function VectorMap.ForEachObjectInRegion(self: Class, top: Vector3, bottom: Vector3, callback: (string, any) -> ()) 105 | local voxelSize = self._voxelSize 106 | local xMin, yMin, zMin = math.min(bottom.X, top.X), math.min(bottom.Y, top.Y), math.min(bottom.Z, top.Z) 107 | local xMax, yMax, zMax = math.max(bottom.X, top.X), math.max(bottom.Y, top.Y), math.max(bottom.Z, top.Z) 108 | 109 | for x = math.floor(xMin / voxelSize), math.floor(xMax / voxelSize) do 110 | for z = math.floor(zMin / voxelSize), math.floor(zMax / voxelSize) do 111 | for y = math.floor(yMin / voxelSize), math.floor(yMax / voxelSize) do 112 | local voxel = self._voxels[Vector3.new(x, y, z)] 113 | if not voxel then 114 | continue 115 | end 116 | 117 | for className, objects in voxel do 118 | for _, object in objects do 119 | callback(className, object) 120 | end 121 | end 122 | end 123 | end 124 | end 125 | end 126 | 127 | function VectorMap.ForEachObjectInView(self: Class, camera: Camera, distance: number, callback: (string, any) -> ()) 128 | local voxelSize = self._voxelSize 129 | local cameraCFrame = camera.CFrame 130 | local cameraPos = cameraCFrame.Position 131 | local rightVec, upVec = cameraCFrame.RightVector, cameraCFrame.UpVector 132 | 133 | local distance2 = distance / 2 134 | local farPlaneHeight2 = math.tan(math.rad((camera.FieldOfView + 5) / 2)) * distance 135 | local farPlaneWidth2 = farPlaneHeight2 * (camera.ViewportSize.X / camera.ViewportSize.Y) 136 | local farPlaneCFrame = cameraCFrame * CFrame.new(0, 0, -distance) 137 | local farPlaneTopLeft = farPlaneCFrame * Vector3.new(-farPlaneWidth2, farPlaneHeight2, 0) 138 | local farPlaneTopRight = farPlaneCFrame * Vector3.new(farPlaneWidth2, farPlaneHeight2, 0) 139 | local farPlaneBottomLeft = farPlaneCFrame * Vector3.new(-farPlaneWidth2, -farPlaneHeight2, 0) 140 | local farPlaneBottomRight = farPlaneCFrame * Vector3.new(farPlaneWidth2, -farPlaneHeight2, 0) 141 | 142 | local frustumCFrameInverse = (cameraCFrame * CFrame.new(0, 0, -distance2)):Inverse() 143 | 144 | local rightNormal = upVec:Cross(farPlaneBottomRight - cameraPos).Unit 145 | local leftNormal = upVec:Cross(farPlaneBottomLeft - cameraPos).Unit 146 | local topNormal = rightVec:Cross(cameraPos - farPlaneTopRight).Unit 147 | local bottomNormal = rightVec:Cross(cameraPos - farPlaneBottomRight).Unit 148 | 149 | local minBound = 150 | cameraPos:Min(farPlaneTopLeft):Min(farPlaneTopRight):Min(farPlaneBottomLeft):Min(farPlaneBottomRight) 151 | local maxBound = 152 | cameraPos:Max(farPlaneTopLeft):Max(farPlaneTopRight):Max(farPlaneBottomLeft):Max(farPlaneBottomRight) 153 | 154 | minBound = Vector3.new( 155 | math.floor(minBound.X / voxelSize), 156 | math.floor(minBound.Y / voxelSize), 157 | math.floor(minBound.Z / voxelSize) 158 | ) 159 | maxBound = Vector3.new( 160 | math.floor(maxBound.X / voxelSize), 161 | math.floor(maxBound.Y / voxelSize), 162 | math.floor(maxBound.Z / voxelSize) 163 | ) 164 | 165 | local function isPointInView(point: Vector3): boolean 166 | -- Check if point lies outside frustum OBB 167 | local relativeToOBB = frustumCFrameInverse * point 168 | if 169 | relativeToOBB.X > farPlaneWidth2 170 | or relativeToOBB.X < -farPlaneWidth2 171 | or relativeToOBB.Y > farPlaneHeight2 172 | or relativeToOBB.Y < -farPlaneHeight2 173 | or relativeToOBB.Z > distance2 174 | or relativeToOBB.Z < -distance2 175 | then 176 | return false 177 | end 178 | 179 | -- Check if point lies outside a frustum plane 180 | local lookToCell = point - cameraPos 181 | if 182 | rightNormal:Dot(lookToCell) < 0 183 | or leftNormal:Dot(lookToCell) > 0 184 | or topNormal:Dot(lookToCell) < 0 185 | or bottomNormal:Dot(lookToCell) > 0 186 | then 187 | return false 188 | end 189 | 190 | return true 191 | end 192 | 193 | for x = minBound.X, maxBound.X do 194 | local xMin = x * voxelSize 195 | local xMax = xMin + voxelSize 196 | local xPos = math.clamp(farPlaneCFrame.X, xMin, xMax) 197 | 198 | for y = minBound.Y, maxBound.Y do 199 | local yMin = y * voxelSize 200 | local yMax = yMin + voxelSize 201 | local yPos = math.clamp(farPlaneCFrame.Y, yMin, yMax) 202 | 203 | for z = minBound.Z, maxBound.Z do 204 | local zMin = z * voxelSize 205 | local zMax = zMin + voxelSize 206 | 207 | local voxelNearestPoint = Vector3.new(xPos, yPos, math.clamp(farPlaneCFrame.Z, zMin, zMax)) 208 | if isPointInView(voxelNearestPoint) then 209 | -- Found the first in frustum, now binary search for the last 210 | local entry, exit = z, minBound.Z - 1 211 | local left = z 212 | local right = maxBound.Z 213 | 214 | while left <= right do 215 | local mid = math.floor((left + right) / 2) 216 | local midPos = Vector3.new( 217 | xPos, 218 | yPos, 219 | math.clamp(farPlaneCFrame.Z, mid * voxelSize, mid * voxelSize + voxelSize) 220 | ) 221 | 222 | if isPointInView(midPos) then 223 | exit = mid 224 | left = mid + 1 225 | else 226 | right = mid - 1 227 | end 228 | end 229 | 230 | for fillZ = entry, exit do 231 | local voxel = self._voxels[Vector3.new(x, y, fillZ)] 232 | if voxel then 233 | for className, objects in voxel do 234 | for _, object in objects do 235 | callback(className, object) 236 | end 237 | end 238 | end 239 | end 240 | 241 | break 242 | end 243 | end 244 | end 245 | end 246 | end 247 | 248 | function VectorMap.ClearAll(self: Class) 249 | table.clear(self._voxels) 250 | end 251 | 252 | return VectorMap 253 | -------------------------------------------------------------------------------- /src/init.lua: -------------------------------------------------------------------------------- 1 | --[=[ 2 | 3 | WindShake- High performance wind effect for leaves and foliage 4 | by: boatbomber, MaximumADHD 5 | 6 | Docs: https://devforum.roblox.com/t/wind-shake-high-performance-wind-effect-for-leaves-and-foliage/1039806/1 7 | 8 | --]=] 9 | 10 | --!strict 11 | local CollectionService = game:GetService("CollectionService") 12 | local RunService = game:GetService("RunService") 13 | local Settings = require(script.Settings) 14 | local VectorMap = require(script.VectorMap) 15 | 16 | local COLLECTION_TAG = "WindShake" -- The CollectionService tag to be watched and mounted automatically 17 | 18 | -- Use the script's attributes as the default settings. 19 | -- The table provided is a fallback if the attributes 20 | -- are undefined or using the wrong value types. 21 | 22 | local FALLBACK_SETTINGS = { 23 | WindDirection = Vector3.new(0.5, 0, 0.5), 24 | WindPower = 0.5, 25 | WindSpeed = 20, 26 | } 27 | 28 | type Settings = Settings.Class 29 | 30 | ----------------------------------------------------------------------------------------------------------------- 31 | 32 | local Paused = Instance.new("BindableEvent") 33 | local Resumed = Instance.new("BindableEvent") 34 | local ObjectShakeAdded = Instance.new("BindableEvent") 35 | local ObjectShakeRemoved = Instance.new("BindableEvent") 36 | local ObjectShakeUpdated = Instance.new("BindableEvent") 37 | 38 | 39 | local WindShake = { 40 | RenderDistance = 150, 41 | MaxRefreshRate = 1 / 60, 42 | SharedSettings = Settings.new(script), 43 | 44 | ObjectMetadata = {} :: { 45 | [Instance]: { 46 | ChunkKey: Vector3, 47 | Settings: Settings, 48 | Seed: number, 49 | Origin: CFrame, 50 | LastUpdate: number, 51 | } 52 | }, 53 | 54 | VectorMap = VectorMap.new(), 55 | Handled = 0, 56 | Active = 0, 57 | 58 | _partList = table.create(500) :: { any }, -- ('any' because Studio and Luau LSP disagree on the type) 59 | _cframeList = table.create(500) :: { CFrame }, 60 | 61 | ObjectShakeAdded = ObjectShakeAdded.Event, 62 | ObjectShakeRemoved = ObjectShakeRemoved.Event, 63 | ObjectShakeUpdated = ObjectShakeUpdated.Event, 64 | 65 | Paused = Paused.Event, 66 | Resumed = Resumed.Event, 67 | 68 | Initialized = nil :: boolean?, 69 | AddedConnection = nil :: RBXScriptConnection?, 70 | UpdateConnection = nil :: RBXScriptConnection?, 71 | RemovedConnection = nil :: RBXScriptConnection?, 72 | WorkspaceWindConnection = nil :: RBXScriptConnection?, 73 | } 74 | 75 | type WindShake = typeof(WindShake) 76 | 77 | export type WindShakeSettings = { 78 | WindDirection: Vector3?, 79 | WindSpeed: number?, 80 | WindPower: number?, 81 | } 82 | 83 | local function Connect(self: WindShake, event: RBXScriptSignal, callback: (self: WindShake, Args...) -> ()): RBXScriptConnection 84 | return event:Connect(function(...) 85 | return callback(self, ...) 86 | end) 87 | end 88 | 89 | function WindShake.AddObjectShake(self: WindShake, object: BasePart | Bone, settingsTable: WindShakeSettings?) 90 | if typeof(object) ~= "Instance" then 91 | return 92 | end 93 | 94 | if not (object:IsA("BasePart") or object:IsA("Bone")) then 95 | return 96 | end 97 | 98 | local metadata = self.ObjectMetadata 99 | 100 | if metadata[object] then 101 | return 102 | end 103 | 104 | metadata[object] = { 105 | ChunkKey = self.VectorMap:AddObject( 106 | if object:IsA("Bone") then object.WorldPosition else object.Position, 107 | object 108 | ), 109 | 110 | Settings = Settings.new(object), 111 | Seed = math.random(5000) * 0.32, 112 | 113 | Origin = if object:IsA("Bone") 114 | then object.WorldCFrame 115 | else object.CFrame, 116 | 117 | LastUpdate = os.clock(), 118 | } 119 | 120 | if settingsTable then 121 | self:UpdateObjectSettings(object, settingsTable) 122 | end 123 | 124 | ObjectShakeAdded:Fire(object) 125 | self.Handled += 1 126 | 127 | return 128 | end 129 | 130 | function WindShake.RemoveObjectShake(self: WindShake, object: BasePart | Bone) 131 | if typeof(object) ~= "Instance" then 132 | return 133 | end 134 | 135 | if not (object:IsA("BasePart") or object:IsA("Bone")) then 136 | return 137 | end 138 | 139 | local metadata = self.ObjectMetadata 140 | local objMeta = metadata[object] 141 | 142 | if objMeta then 143 | self.Handled -= 1 144 | metadata[object] = nil 145 | objMeta.Settings:Destroy() 146 | self.VectorMap:RemoveObject(objMeta.ChunkKey, object) 147 | 148 | if object:IsA("BasePart") then 149 | object.CFrame = objMeta.Origin 150 | elseif object:IsA("Bone") then 151 | object.WorldCFrame = objMeta.Origin 152 | end 153 | end 154 | 155 | ObjectShakeRemoved:Fire(object) 156 | return 157 | end 158 | 159 | function WindShake.Update(self: WindShake, deltaTime: number) 160 | debug.profilebegin("WindShake") 161 | 162 | local active = 0 163 | 164 | debug.profilebegin("Update") 165 | 166 | local now = os.clock() 167 | local slowerDeltaTime = deltaTime * 3 168 | local step = math.min(1, deltaTime * 5) 169 | 170 | -- Reuse tables to avoid garbage collection 171 | local bulkMoveIndex = 0 172 | local partList = self._partList 173 | local cframeList = self._cframeList 174 | table.clear(partList) 175 | table.clear(cframeList) 176 | 177 | -- Cache hot values 178 | local objectMetadata = self.ObjectMetadata 179 | local camera = workspace.CurrentCamera 180 | local cameraPos = camera.CFrame.Position 181 | 182 | local renderDistance = self.RenderDistance 183 | local maxRefreshRate = self.MaxRefreshRate 184 | local sharedSettings = self.SharedSettings 185 | 186 | local sharedWindPower = assert(sharedSettings.WindPower) 187 | local sharedWindSpeed = assert(sharedSettings.WindSpeed) 188 | local sharedWindDirection = assert(sharedSettings.WindDirection) 189 | 190 | -- Update objects in view at their respective refresh rates 191 | self.VectorMap:ForEachObjectInView(camera, renderDistance, function(className: string, object: BasePart | Bone) 192 | local objMeta = objectMetadata[object] 193 | local lastUpdate = objMeta.LastUpdate or 0 194 | local isBone = className == "Bone" 195 | 196 | -- Determine if the object refresh rate 197 | local objectCFrame = if isBone then (object :: Bone).WorldCFrame else object.CFrame 198 | local distanceAlpha = ((cameraPos - objectCFrame.Position).Magnitude / renderDistance) 199 | local distanceAlphaSq = distanceAlpha * distanceAlpha 200 | local jitter = (1 / math.random(60, 120)) 201 | local refreshRate = (slowerDeltaTime * distanceAlphaSq) + maxRefreshRate 202 | 203 | if (now - lastUpdate) + jitter <= refreshRate then 204 | -- It is not yet time to update 205 | return 206 | end 207 | 208 | objMeta.LastUpdate = now 209 | active += 1 210 | 211 | local objSettings = objMeta.Settings 212 | 213 | local windDirection = (objSettings.WindDirection or sharedWindDirection) 214 | if windDirection.Magnitude < 1e-5 then 215 | return 216 | end 217 | 218 | local amp = (objSettings.WindPower or sharedWindPower) * 0.2 219 | 220 | if amp < 1e-5 then 221 | return 222 | end 223 | 224 | local freq = now * ((objSettings.WindSpeed or sharedWindSpeed) * 0.08) 225 | 226 | if freq < 1e-5 then 227 | return 228 | end 229 | 230 | local seed = objMeta.Seed 231 | local animValue = (math.noise(freq, 0, seed) + 0.4) * amp 232 | local lerpAlpha = math.clamp(step + distanceAlphaSq, 0.1, 0.5) 233 | local lowAmp = amp / 3 234 | 235 | local origin = objMeta.Origin * (objSettings.PivotOffset or CFrame.identity) 236 | local localWindDirection = origin:VectorToObjectSpace(windDirection) 237 | 238 | if isBone then 239 | local bone: Bone = object :: Bone 240 | bone.Transform = bone.Transform:Lerp( 241 | ( 242 | CFrame.fromAxisAngle(localWindDirection:Cross(Vector3.yAxis), -animValue) 243 | * CFrame.Angles( 244 | math.noise(seed, 0, freq) * lowAmp, 245 | math.noise(seed, freq, 0) * lowAmp, 246 | math.noise(freq, seed, 0) * lowAmp 247 | ) 248 | ) + (localWindDirection * animValue * amp), 249 | lerpAlpha 250 | ) 251 | else 252 | bulkMoveIndex += 1 253 | partList[bulkMoveIndex] = object 254 | cframeList[bulkMoveIndex] = objectCFrame:Lerp( 255 | ( 256 | origin 257 | * CFrame.fromAxisAngle(localWindDirection:Cross(Vector3.yAxis), -animValue) 258 | * CFrame.Angles( 259 | math.noise(seed, 0, freq) * lowAmp, 260 | math.noise(seed, freq, 0) * lowAmp, 261 | math.noise(freq, seed, 0) * lowAmp 262 | ) 263 | * (objSettings.PivotOffsetInverse or CFrame.identity) 264 | ) + (windDirection * animValue * (amp * 2)), 265 | lerpAlpha 266 | ) 267 | end 268 | end) 269 | 270 | self.Active = active 271 | 272 | debug.profileend() 273 | 274 | workspace:BulkMoveTo(partList, cframeList, Enum.BulkMoveMode.FireCFrameChanged) 275 | 276 | debug.profileend() 277 | end 278 | 279 | function WindShake.Pause(self: WindShake) 280 | if self.UpdateConnection then 281 | self.UpdateConnection:Disconnect() 282 | self.UpdateConnection = nil 283 | end 284 | 285 | self.Active = 0 286 | self.Running = false 287 | 288 | Paused:Fire() 289 | end 290 | 291 | function WindShake.Resume(self: WindShake) 292 | if self.Running then 293 | return 294 | end 295 | 296 | -- Connect updater 297 | self.UpdateConnection = Connect(self, RunService.Heartbeat, self.Update) 298 | self.Running = true 299 | 300 | Resumed:Fire() 301 | end 302 | 303 | function WindShake.Init(self: WindShake, config: { MatchWorkspaceWind: boolean? }?) 304 | if self.Initialized then 305 | return 306 | end 307 | 308 | -- Define attributes if they're undefined. 309 | local power = script:GetAttribute("WindPower") 310 | local speed = script:GetAttribute("WindSpeed") 311 | local direction = script:GetAttribute("WindDirection") 312 | 313 | if typeof(power) ~= "number" then 314 | script:SetAttribute("WindPower", FALLBACK_SETTINGS.WindPower) 315 | end 316 | 317 | if typeof(speed) ~= "number" then 318 | script:SetAttribute("WindSpeed", FALLBACK_SETTINGS.WindSpeed) 319 | end 320 | 321 | if typeof(direction) ~= "Vector3" then 322 | script:SetAttribute("WindDirection", FALLBACK_SETTINGS.WindDirection) 323 | end 324 | 325 | -- Clear any old stuff. 326 | self:Cleanup() 327 | self.Initialized = true 328 | 329 | -- Wire up tag listeners. 330 | local windShakeAdded = CollectionService:GetInstanceAddedSignal(COLLECTION_TAG) 331 | self.AddedConnection = Connect(self, windShakeAdded, self.AddObjectShake) 332 | 333 | local windShakeRemoved = CollectionService:GetInstanceRemovedSignal(COLLECTION_TAG) 334 | self.RemovedConnection = Connect(self, windShakeRemoved, self.RemoveObjectShake) 335 | 336 | for _, object in CollectionService:GetTagged(COLLECTION_TAG) do 337 | if object:IsA("BasePart") or object:IsA("Bone") then 338 | self:AddObjectShake(object) 339 | end 340 | end 341 | 342 | -- Wire up workspace wind. 343 | if config and config.MatchWorkspaceWind then 344 | self:MatchWorkspaceWind() 345 | 346 | self.WorkspaceWindConnection = workspace:GetPropertyChangedSignal("GlobalWind"):Connect(function() 347 | self:MatchWorkspaceWind() 348 | end) 349 | end 350 | 351 | -- Automatically start. 352 | self:Resume() 353 | end 354 | 355 | function WindShake.Cleanup(self: WindShake) 356 | if not self.Initialized then 357 | return 358 | end 359 | 360 | self:Pause() 361 | 362 | if self.AddedConnection then 363 | self.AddedConnection:Disconnect() 364 | self.AddedConnection = nil 365 | end 366 | 367 | if self.RemovedConnection then 368 | self.RemovedConnection:Disconnect() 369 | self.RemovedConnection = nil 370 | end 371 | 372 | if self.WorkspaceWindConnection then 373 | self.WorkspaceWindConnection:Disconnect() 374 | self.WorkspaceWindConnection = nil 375 | end 376 | 377 | table.clear(self.ObjectMetadata) 378 | self.VectorMap:ClearAll() 379 | 380 | self.Handled = 0 381 | self.Active = 0 382 | self.Initialized = false 383 | end 384 | 385 | function WindShake.UpdateObjectSettings(self: WindShake, object: Instance, settingsTable: WindShakeSettings) 386 | if typeof(object) ~= "Instance" then 387 | return 388 | end 389 | 390 | if typeof(settingsTable) ~= "table" then 391 | return 392 | end 393 | 394 | if not self.ObjectMetadata[object] and (object ~= script) then 395 | return 396 | end 397 | 398 | for key, value in pairs(settingsTable) do 399 | object:SetAttribute(key, value) 400 | end 401 | 402 | ObjectShakeUpdated:Fire(object) 403 | return 404 | end 405 | 406 | function WindShake.UpdateAllObjectSettings(self: WindShake, settingsTable: WindShakeSettings) 407 | if typeof(settingsTable) ~= "table" then 408 | return 409 | end 410 | 411 | for obj, _objMeta in self.ObjectMetadata do 412 | for key, value in pairs(settingsTable) do 413 | obj:SetAttribute(key, value) 414 | end 415 | 416 | ObjectShakeUpdated:Fire(obj) 417 | end 418 | end 419 | 420 | function WindShake.SetDefaultSettings(self: WindShake, settingsTable: WindShakeSettings) 421 | self:UpdateObjectSettings(script, settingsTable) 422 | end 423 | 424 | function WindShake.MatchWorkspaceWind(self: WindShake) 425 | local workspaceWind = workspace.GlobalWind 426 | local windDirection = workspaceWind.Unit 427 | local windSpeed, windPower = 0, 0 428 | 429 | local windMagnitude = workspaceWind.Magnitude 430 | if windMagnitude > 0 then 431 | windPower = if windMagnitude > 1 then math.log10(windMagnitude) + 0.2 else 0.3 432 | windSpeed = if windMagnitude < 100 then (windMagnitude * 1.2) + 5 else 125 433 | end 434 | 435 | self:SetDefaultSettings({ 436 | WindDirection = windDirection, 437 | WindSpeed = windSpeed, 438 | WindPower = windPower, 439 | }) 440 | end 441 | 442 | return WindShake 443 | --------------------------------------------------------------------------------