Bloodsplatter sticking to wall help

Hello, I tried making this blood splatter system. For the most part it works, but the part where if finds the face dimensions and offset doesn’t seem to work for all rotations. I am too inexpierienced in this to figure out how to do it properly, any help it appreciated.

-- blood_splatter_module.lua

local module = {}

-- Services
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TweenService       = game:GetService("TweenService")

-- Modules
local Modules         = ReplicatedStorage:WaitForChild("Modules")
local BothModules     = Modules:WaitForChild("Both")
local UtilitiesModules= BothModules:WaitForChild("Utilities")

-- Assets
local Assets        = ReplicatedStorage:WaitForChild("Assets")
local Decals        = Assets:WaitForChild("Decals")
local Blood         = Decals:WaitForChild("Blood")
local BloodStains   = Blood:WaitForChild("BloodStains")
local BloodSplatters= BloodStains:WaitForChild("Splatters")

local Utilities = require(UtilitiesModules)

-- Helper to rotate a direction vector down by given degrees
local function rotateDirectionDown(vector, degrees)
    local radians = math.rad(degrees)
    local right = vector:Cross(Vector3.new(0, 1, 0)).Unit
    if right.Magnitude == 0 then
        right = Vector3.new(1, 0, 0)
    end
    return CFrame.fromAxisAngle(right, radians):VectorToWorldSpace(vector)
end


local function approxEquals(v1, v2, epsilon)
    return (v1 - v2).Magnitude < epsilon
end


local function getClosestFace(result)

    local normal = result.Normal
    local part = result.Instance

    -- Convert world normal to part local space
    local localNormal = part.CFrame:VectorToObjectSpace(normal)

    -- Determine which face it hit
    local HitFace = Enum.NormalId.Front

    local epsilon = 0.001  -- small tolerance
    if approxEquals(localNormal, Vector3.new(0, 1, 0), epsilon) then
        HitFace = Enum.NormalId.Top
    elseif approxEquals(localNormal, Vector3.new(0, -1, 0), epsilon) then
        HitFace = Enum.NormalId.Bottom
    elseif approxEquals(localNormal, Vector3.new(1, 0, 0), epsilon) then
        HitFace = Enum.NormalId.Right
    elseif approxEquals(localNormal, Vector3.new(-1, 0, 0), epsilon) then
        HitFace = Enum.NormalId.Left
    elseif approxEquals(localNormal, Vector3.new(0, 0, -1), epsilon) then
        HitFace = Enum.NormalId.Back
    elseif approxEquals(localNormal, Vector3.new(0, 0, 1), epsilon) then
        HitFace = Enum.NormalId.Front
    end

    return HitFace

end

-- Main blood splatter function
-- Start: origin position
-- Direction: look vector to raycast
-- Range: max distance
module.BloodSplatter = function(Start, Direction, Range)
    -- Attempt direct raycast then angled downwards
    local hit, raycastResult = Utilities.CheckLocation(Start, nil, nil, Direction * Range)
    if not hit then
        local downDir = rotateDirectionDown(Direction, 45)
        hit, raycastResult = Utilities.CheckLocation(Start, nil, nil, downDir * Range)
    end
    if not hit then return end

    local part     = raycastResult.Instance :: Part
    local hitPos   = raycastResult.Position
    local hitNormal= raycastResult.Normal

    if not part or not hitPos or not hitNormal then return end


    -- Spawn splatter
    local template = BloodSplatters:GetChildren()
    local randomTempl = template[math.random(1, #template)]
    local splat = randomTempl:Clone() :: Part
    splat.CFrame = CFrame.new(hitPos,hitPos + hitNormal)
    

    local size = part.Size
    local position = part.Position
    local FaceSize

    local Offset = part.CFrame:ToObjectSpace(splat.CFrame)
    local Offset2D = Vector2.new()

    local HitFace = getClosestFace(raycastResult)


    if HitFace == Enum.NormalId.Top or HitFace == Enum.NormalId.Bottom then
        FaceSize = Vector2.new(size.X, size.Z)
        Offset2D = Vector2.new(Offset.X,Offset.Z)
    elseif HitFace == Enum.NormalId.Front or HitFace == Enum.NormalId.Back then  
        FaceSize = Vector2.new(size.X, size.Y)
        Offset2D = Vector2.new(Offset.X,Offset.Y)
    elseif HitFace == Enum.NormalId.Left or HitFace == Enum.NormalId.Right then
        FaceSize = Vector2.new(size.Z, size.Y)
        Offset2D = Vector2.new(Offset.Z,Offset.Y)
    end

    local AbsoluteOffset =  Vector2.new(math.abs(Offset2D.X),math.abs(Offset2D.Y))

    local SplatSize2D = Vector2.new(splat.Size.X,splat.Size.Y)
    local Overlap = (SplatSize2D - FaceSize) / 2 + AbsoluteOffset


    Overlap = Vector2.new(math.clamp(Overlap.X,0,splat.Size.X),math.clamp(Overlap.Y,0,splat.Size.Y))

    local ClampX = math.clamp(splat.Size.X - Overlap.X,0,FaceSize.X)
    local ClampY = math.clamp(splat.Size.Y - Overlap.Y,0,FaceSize.Y)

    splat.Size =  Vector3.new(ClampX,ClampY,0)
    
    print(Offset2D.X,Offset2D.Y)
    
    if AbsoluteOffset.X > 0 then
        
        if Offset2D.X < 0 then
            splat.CFrame = splat.CFrame * CFrame.new(-Overlap.X / 2,0,0)
        else
            splat.CFrame = splat.CFrame * CFrame.new(Overlap.X / 2,0,0)
        end

    end
    if AbsoluteOffset.Y > 0 then
        

        if Offset2D.Y < 0 then
            splat.CFrame = splat.CFrame * CFrame.new(0,Overlap.Y / 2,0)
        else
            splat.CFrame = splat.CFrame * CFrame.new(0,-Overlap.Y / 2,0)
        end
    end

    splat.Parent = workspace.Effects

    task.delay(60,function()
        splat:Destroy()
    end)

end

return module
1 Like

You are overthinking too much.

Just use raycasting and .Normal that it returns instead.

Why:

Your method would not really work well on more complex geometry such as wedges,cylinders and so on.
And raycasting is 100% reliable and would always work.

You’re complicating it a lot, you can get the surface direction by adding the position + raycast normal from the raycast return.

Example code below:

local raycastParams = ...
local camera = workspace.CurrentCamera
local cameraLookVector = camera.CFrame.LookVector
local raycastDistance = 10

local raycastResult = workspace:Raycast(camera.CFrame.Position, cameraLookVector * raycastDistance, raycastParams)
if raycastResult then
	local bloodPart = Instance.new("Part")

	--this aligns the part with the hit position normal
	bloodPart.CFrame = CFrame.lookAt(raycastResult.Position, raycastResult.Position + raycastResult.Normal)
	bloodPart.Parent = workspace
end

Anyone who says im overthinking, Im not. if you read the code its clear that im trying to reaize the splatter it doesn’t cross over walls.

SM64 surface checking in 2025 i love it

1 Like

I’m back with a solution for you, I have verified that this works to prevent the overlap with other walls:

-- blood_splatter_module.lua

local module = {}

-- Services
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TweenService       = game:GetService("TweenService")

-- Modules
local Modules         = ReplicatedStorage:WaitForChild("Modules")
local BothModules     = Modules:WaitForChild("Both")
local UtilitiesModules= BothModules:WaitForChild("Utilities")

-- Assets
local Assets        = ReplicatedStorage:WaitForChild("Assets")
local Decals        = Assets:WaitForChild("Decals")
local Blood         = Decals:WaitForChild("Blood")
local BloodStains   = Blood:WaitForChild("BloodStains")
local BloodSplatters= BloodStains:WaitForChild("Splatters")

local Utilities = require(UtilitiesModules)

-- Helper to rotate a direction vector down by given degrees
local function rotateDirectionDown(vector, degrees)

    local radians = math.rad(degrees)
    local right = vector:Cross(Vector3.yAxis)

    if right.Magnitude == 0 then right = Vector3.xAxis end

    return CFrame.fromAxisAngle(right, radians):VectorToWorldSpace(vector)
end


-- Main blood splatter function
-- Start: origin position
-- Direction: look vector to raycast
-- Range: max distance

module.BloodSplatter = function(Start, Direction, Range)

    -- Attempt direct raycast then angled downwards
    local hit, raycastResult = Utilities.CheckLocation(Start, nil, nil, Direction * Range)
    if not hit then
        hit, raycastResult = Utilities.CheckLocation(Start, nil, nil, rotateDirectionDown(Direction, 45) * Range)
    end
    if not hit then return end
	

    local part     = raycastResult.Instance :: Part
    local hitPos   = raycastResult.Position
    local hitNormal= raycastResult.Normal


    -- Spawn splatter
    local template = BloodSplatters:GetChildren()
    local randomTempl = template[math.random(1, #template)]
    local splat = randomTempl:Clone() :: Part
    splat.CFrame = CFrame.new(hitPos, hitPos + hitNormal)
    
	local s = splat.Size
	local hsx, hsy = 0.5 * s.X, 0.5 * s.Y
	local c = splat.CFrame
	local o = c.Position
	
	-- Perform wall overlap check and rescaling
	local udhs, rdhs = c.UpVector * hsy, c.RightVector * hsx
	hit, raycastResult = Utilities.CheckLocation(o, nil, nil, udhs)
    if raycastResult then 
		local delta = raycastResult.Distance - hsy
		splat.Size += Vector3.new(0, delta, 0)
		splat.CFrame += c.UpVector * delta * 0.5
	end
	hit, raycastResult = Utilities.CheckLocation(o, nil, nil, -udhs)
    if raycastResult then 
		local delta = raycastResult.Distance - hsy
		splat.Size += Vector3.new(0, delta, 0)
		splat.CFrame -= c.UpVector * delta * 0.5
	end
	hit, raycastResult = Utilities.CheckLocation(o, nil, nil, rdhs)
    if raycastResult then 
		local delta = raycastResult.Distance - hsx
		splat.Size += Vector3.new(delta, 0, 0)
		splat.CFrame += c.RightVector * delta * 0.5
	end
	hit, raycastResult = Utilities.CheckLocation(o, nil, nil, -rdhs)
    if raycastResult then 
		local delta = raycastResult.Distance - hsx
		splat.Size += Vector3.new(delta, 0, 0)
		splat.CFrame -= c.RightVector * delta * 0.5
	end

	-- Parent part and add to debris
    splat.Parent = workspace.Effects

    task.delay(10,function()
        splat:Destroy()
    end)
end


return module

If you want to also resize parts if they are hanging off of the edge of a part, I suggest just making an invisible CanCollide false part for the raycasts to smack

1 Like

Hi, I don’t think this works with rotated walls.

I have found my own solution to this recently. Thank you for trying to assist me.

Code that works for me:

-- blood_splatter_module.lua

local module = {}

-- Services
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TweenService = game:GetService("TweenService")

-- Modules
local Modules = ReplicatedStorage:WaitForChild("Modules")
local BothModules = Modules:WaitForChild("Both")
local UtilitiesModules = BothModules:WaitForChild("Utilities")
local Utilities = require(UtilitiesModules)

-- Assets
local Assets = ReplicatedStorage:WaitForChild("Assets")
local Decals = Assets:WaitForChild("Decals")
local Blood = Decals:WaitForChild("Blood")
local BloodStains = Blood:WaitForChild("BloodStains")
local BloodSplatters = BloodStains:WaitForChild("Splatters")
local BloodTrail = Assets:WaitForChild("Models"):WaitForChild("Other"):WaitForChild("BloodTrail")

-- Utility: vector approx equals
local function approxEquals(v1, v2, epsilon)
    return (v1 - v2).Magnitude < epsilon
end

-- Utility: get closest face of a part hit
local function getClosestFace(result)
    local normal = result.Normal
    local part = result.Instance
    local localNormal = part.CFrame:VectorToObjectSpace(normal)
    local epsilon = 0.001

    if approxEquals(localNormal, Vector3.new(0, 1, 0), epsilon) then
        return Enum.NormalId.Top
    elseif approxEquals(localNormal, Vector3.new(0, -1, 0), epsilon) then
        return Enum.NormalId.Bottom
    elseif approxEquals(localNormal, Vector3.new(1, 0, 0), epsilon) then
        return Enum.NormalId.Right
    elseif approxEquals(localNormal, Vector3.new(-1, 0, 0), epsilon) then
        return Enum.NormalId.Left
    elseif approxEquals(localNormal, Vector3.new(0, 0, -1), epsilon) then
        return Enum.NormalId.Back
    elseif approxEquals(localNormal, Vector3.new(0, 0, 1), epsilon) then
        return Enum.NormalId.Front
    end

    return Enum.NormalId.Front
end

-- Main blood splatter function
module.BloodSplatter = function(startPos, direction, range)
    task.spawn(function()
        -- Try direct raycast, then downward adjustment
        local hit, result = Utilities.CheckLocation(startPos, nil, nil, direction * range)

        if not hit then
            local downAngle = 5
            local tries = 0
            repeat
                tries += 1
                local pitch = math.rad(-downAngle)
                downAngle += 5
                local rotated = (CFrame.Angles(pitch, 0, 0) * CFrame.new(Vector3.zero, direction)).LookVector
                hit, result = Utilities.CheckLocation(startPos, nil, nil, rotated * 100, nil)
                task.wait()
            until hit or tries >= 72
        end

        if not hit then return end

        local part = result.Instance
        local hitPos = result.Position
        local normal = result.Normal

        if not part or not hitPos or not normal then return end

        -- Blood trail
        local trail = BloodTrail:Clone()
        trail.CFrame = CFrame.new(startPos, hitPos)
        local dist = (startPos - hitPos).Magnitude
        local tween = TweenService:Create(trail, TweenInfo.new(dist / 125), { Position = hitPos })
        tween:Play()
        trail.Parent = workspace.Effects.Blood
        tween.Completed:Wait()
        trail:Destroy()

        -- Blood splatter
        local splat = BloodSplatters["Blood 1"]:Clone()
        splat.CFrame = CFrame.new(hitPos, hitPos + normal)
        local localNormal = part.CFrame:VectorToObjectSpace(normal)
        local size, offset = part.Size, part.Position
        local faceSize, faceOffset, offset2D, face2D, splat2D = nil, nil, Vector2.zero, Vector2.zero, Vector2.zero
        local abs = Vector3.new(math.abs(localNormal.X), math.abs(localNormal.Y), math.abs(localNormal.Z))
        local gui = splat.SurfaceGui
        local hitFace = getClosestFace(result)

        local offsetLocal = part.CFrame:ToObjectSpace(splat.CFrame)

        local axis
        if abs.X > abs.Y and abs.X > abs.Z then
            axis = "Side"
            gui.Face = localNormal.X > 0 and Enum.NormalId.Right or Enum.NormalId.Left
            faceOffset = Vector3.new((localNormal.X > 0 and 1 or -1) * size.X / 2, 0, 0)
            faceSize = Vector3.new(0.05, size.Y, size.Z)
            face2D = Vector2.new(size.Y, size.Z)
            splat2D = face2D
            offset2D = Vector2.new(offsetLocal.Y, offsetLocal.Z)
        elseif abs.Y > abs.X and abs.Y > abs.Z then
            axis = "Cap"
            gui.Face = localNormal.Y > 0 and Enum.NormalId.Top or Enum.NormalId.Bottom
            faceOffset = Vector3.new(0, (localNormal.Y > 0 and 1 or -1) * size.Y / 2, 0)
            faceSize = Vector3.new(size.X, 0.05, size.Z)
            face2D = Vector2.new(size.X, size.Z)
            splat2D = face2D
            offset2D = Vector2.new(offsetLocal.X, offsetLocal.Z)
        else
            axis = "Front"
            gui.Face = localNormal.Z < 0 and Enum.NormalId.Front or Enum.NormalId.Back
            faceOffset = Vector3.new(0, 0, (localNormal.Z < 0 and 1 or -1) * size.Z / 2)
            faceSize = Vector3.new(size.X, size.Y, 0.05)
            face2D = Vector2.new(size.X, size.Y)
            splat2D = face2D
            offset2D = Vector2.new(offsetLocal.X, offsetLocal.Y)
        end

        local worldFacePos = part.CFrame * CFrame.new(faceOffset)

        local clampedSize = Vector3.new(
            math.clamp(faceSize.X, 0, 6),
            math.clamp(faceSize.Y, 0, 6),
            math.clamp(faceSize.Z, 0, 6)
        )

        splat.Size = clampedSize
        splat2D = Vector2.new(math.clamp(splat2D.X, 0, 6), math.clamp(splat2D.Y, 0, 6))
        splat.CFrame = worldFacePos * worldFacePos:ToObjectSpace(CFrame.new(hitPos))
        splat.Orientation = part.Orientation
        
        local absOffset = Vector2.new(math.abs(offset2D.X), math.abs(offset2D.Y))
        local overlap = Vector2.new(
            (splat2D.X - face2D.X) / 2 + absOffset.X,
            (splat2D.Y - face2D.Y) / 2 + absOffset.Y
        )
        overlap = Vector2.new(
            math.clamp(overlap.X, 0, splat2D.X),
            math.clamp(overlap.Y, 0, splat2D.Y)
        )

        local xSign = offset2D.X < 0 and 1 or -1
        local ySign = offset2D.Y < 0 and 1 or -1
        local xMove = xSign * (overlap.X / 2)
        local yMove = ySign * (overlap.Y / 2)

        local finalSize
        if axis == "Side" then
            finalSize = Vector3.new(0.1,
                math.clamp(splat.Size.Y - overlap.X, 0, face2D.X),
                math.clamp(splat.Size.Z - overlap.Y, 0, face2D.Y))
            splat.CFrame *= CFrame.new(0, xMove, yMove)
        elseif axis == "Cap" then
            finalSize = Vector3.new(
                math.clamp(splat.Size.X - overlap.X, 0, face2D.X),
                0.1,
                math.clamp(splat.Size.Z - overlap.Y, 0, face2D.Y))
            splat.CFrame *= CFrame.new(xMove, 0, yMove)
        else
            finalSize = Vector3.new(
                math.clamp(splat.Size.X - overlap.X, 0, face2D.X),
                math.clamp(splat.Size.Y - overlap.Y, 0, face2D.Y),
                0.1)
            splat.CFrame *= CFrame.new(xMove, yMove, 0)
        end

        splat.Size = Vector3.new(0.1, 0.1, 0.1)
        TweenService:Create(splat, TweenInfo.new(0.3), { Size = finalSize }):Play()

        task.delay(60, function()
            splat:Destroy()
        end)

        splat.Parent = workspace.Effects.Blood
    end)
end

return module

probably not the most performant way to do it, but it does work.

1 Like

It does work with rotated walls, and also weird angles. You probably have the decals on another face than the Front face, which is where they should be placed:


As you can see here it’s clipping over the wall which im trying to prevent. My code does this proficently

1 Like