I took an earlier crack at fur a few years back, stacking lines atop one another and waving them around, per the common approach.
That way of doing things relies upon having an object with a proper shape, which makes sense with 3D models and all, thus the intermediate objects and such in the previous attempt. Most if not all of our Solar objects are 2D, of course, often being images or sprites whose shapes are described by pixels rather than their bounds.
What I’ve attempted to do is encode the motion in a texture. Generation basically proceeds as described in the second link: randomly place hairs and rotate them along random arcs. We use z-coordinates during construction, but drop them after that.
Our texture components range from 0 to 255 (a byte), so we have 16 * 16 values at hand. What I’ve done is to sample the hairs at 16ths of an angle, starting from 0. Half the byte contains the low “time”, when the hair is first visible in the pixel; the other has the time remaining, its “range”.
This done for three layers of fur, stored in the RGB components. Since hairs won’t be placed everywhere or wave over every pixel, we also need to store the “skin” or “ground” (or empty space around the image); this is contained in the alpha component.
I was running into some issues with the representation: you can reliably land on 256ths, on the Lua side, but that [0 - 255] range is very slightly different and this is reflected on the GPU. Some tests showed that about half the hair-related pixels were always visible, i.e. they had a “low” of 0 and “range” of 15. (The follicles were the primary reason.) These were basically indistinguishable from the skin / ground, so were just baked into the alpha component; this opens up a little extra wiggle room in the range and seems to have cleared up the issues. The grayscale blending probably isn’t the most fur-like, but reflects these considerations.
Anyhow, you update a time–between 0 and 1–for each layer, saying “when” along the rotations the respective hairs happen to be. On the shader side, we check if the corresponding time falls in the [low, low + range) interval; if so, the hair will be blended into that pixel.
(With a second texture, and using uniforms for input, four more layers could be added. I haven’t attempted this. It might be asking too much of the color-blending strategy, too.
Another idea would be to store the “low” in one texture and “range” in another. This would allow filtering and finer time sampling.)
Code:
--- Fur test.
--
-- 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:
--
-- The above copyright notice and this permission notice shall be
-- included in all copies or substantial portions of the Software.
--
-- 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.
--
-- [ MIT license: http://www.opensource.org/licenses/mit-license.php ]
--
-- Standard library imports --
local abs = math.abs
local asin = math.asin
local floor = math.floor
local ipairs = ipairs
local max = math.max
local min = math.min
local pairs = pairs
local pi = math.pi
local random = math.random
local sin = math.sin
local sqrt = math.sqrt
-- Modules --
local widget = require("widget")
-- Plugins --
local memoryBitmap = require("plugin.memoryBitmap")
-- Solar2D globals --
local display = display
--
--
--
local function Quantize (pos, dim)
return floor(pos / dim)
end
local NCols, NRows = 100, 100
local function Visit (col, row, func, arg1, arg2)
if col >= 1 and col <= NCols and row >= 1 and row <= NRows then
func(col, row, arg1, arg2)
end
end
local CellWidth, CellHeight = 4, 4
local function Raycast (x1, y1, x2, y2, func, arg1, arg2)
local coff, roff = Quantize(x1, CellWidth), Quantize(y1, CellHeight)
local x0, c1 = coff * CellWidth, coff + 1
local y0, r1 = roff * CellHeight, roff + 1
local c2, r2 = Quantize(x2, CellWidth) + 1, Quantize(y2, CellHeight) + 1
local cstep, rstep = c1 < c2 and 1 or -1, r1 < r2 and 1 or -1
if c1 == c2 then -- vertical
for row = r1, r2, rstep do
Visit(c1, row, func, arg1, arg2)
end
elseif r1 == r2 then -- horizontal
for col = c1, c2, cstep do
Visit(col, r1, func, arg1, arg2)
end
else
local dc, dr = abs(c1 - c2), abs(r1 - r2)
if dc == dr then -- diagonal
for _ = 1, dc do
Visit(c1, r1, func, arg1, arg2)
c1, r1 = c1 + cstep, r1 + rstep
end
elseif dc < dr then -- more tall than wide
local xoff, dx, dy = x1 - x0, abs(x2 - x1), y2 - y1
local t, m = c1 < c2 and CellWidth - xoff or xoff, dy / dx
-- Columns, first through penultimate:
for col = c1, c2 - cstep, cstep do
local rto = Quantize(y1 + t * m, CellHeight) + 1
for row = r1, rto, rstep do
Visit(col, row, func, arg1, arg2)
end
r1, t = rto, t + CellWidth
end
-- Last column:
for row = r1, r2, rstep do
Visit(c2, row, func, arg1, arg2)
end
else -- more wide than tall
local yoff, dx, dy = y1 - y0, x2 - x1, abs(y2 - y1)
local t, m = r1 < r2 and CellHeight - yoff or yoff, dx / dy
-- Rows, first through penultimate:
for row = r1, r2 - rstep, rstep do
local cto = Quantize(x1 + t * m, CellWidth) + 1
for col = c1, cto, cstep do
Visit(col, row, func, arg1, arg2)
end
c1, t = cto, t + CellHeight
end
-- Last row:
for col = c1, c2, cstep do
Visit(col, r2, func, arg1, arg2)
end
end
end
end
--
--
--
local RowInfo = {}
local Tex = memoryBitmap.newTexture{ width = NCols, height = NRows }
local function Paint (col, row)
Tex:setPixel(col, row, 1, 0, 0, 1)
local info = RowInfo[row]
if info then
info.left, info.right = min(col, info.left), max(col, info.right)
else
RowInfo[row] = { left = col, right = col }
end
end
--
--
--
local Left, Top = 50, 100
local Image = display.newImageRect(Tex.filename, Tex.baseDir, Tex.width * CellWidth, Tex.height * CellHeight)
Image:setStrokeColor(0, 0, 1)
Image.anchorX, Image.x = 0, Left
Image.anchorY, Image.y = 0, Top
Image.strokeWidth = 2
Image:addEventListener("touch", function(event)
local phase, target = event.phase, event.target
if phase == "began" then
display.getCurrentStage():setFocus(target)
local x, y = event.x - Left, event.y - Top
Raycast(x, y, x, y, Paint) -- trivial cast
Tex:invalidate()
target.last_x, target.last_y = event.x, event.y
target.is_touched = true
elseif target.is_touched then
if phase == "moved" then
Raycast(target.last_x - Left, target.last_y - Top, event.x - Left, event.y - Top, Paint)
Tex:invalidate()
target.last_x, target.last_y = event.x, event.y
elseif phase == "ended" or phase == "cancelled" then
display.getCurrentStage():setFocus(nil)
target.is_touched = false
end
else -- swipe?
return
end
return true
end)
--
--
--
local function UpdateTimeInfo (col, row, layer, time)
local index = (row - 1) * NCols + col
local value = layer[index]
if value then
value[1], value[2] = min(value[1], time), max(value[2], time)
else
value = { time, time }
end
layer[index] = value
end
local function EncodeTimes (value)
if value then
local low = value[1]
local range = value[2] - low + 1
return (16 * low + range + 1) / 256 -- we add 1 to as a correction from 256ths to 255ths
else
return 0
end
end
--
--
--
-- In early development, this looked atrocious with filtering. Now it
-- actually seems okay, if still off. The "low" value might often survive,
-- or at least be reasonably close, whereas the "range" will be all over
-- the place.
display.setDefault("magTextureFilter", "nearest")
display.setDefault("minTextureFilter", "nearest")
local Tex2 = memoryBitmap.newTexture{ width = NCols, height = NRows }
display.setDefault("magTextureFilter", "linear")
display.setDefault("minTextureFilter", "linear")
--
--
--
local FullRange = EncodeTimes{ 0, 15 }
local function BakeLayers (layers)
local r, g, b, a, index = layers[1], layers[2], layers[3], layers[4], 1
for row = 1, NRows do
for col = 1, NCols do
local red, green, blue, alpha = EncodeTimes(r[index]), EncodeTimes(g[index]), EncodeTimes(b[index]), a[index] or 0
-- If a pixel is shown at all times, just bake it into the skin instead; this will
-- be the case at all the follicles, for instance. This gives us some slack in the
-- encoding and seems to avoid some artifacts, presumably related to rounding.
if red == FullRange then
alpha, red = alpha + .25, 0
end
if green == FullRange then
alpha, green = alpha + .25, 0
end
if blue == FullRange then
alpha, blue = alpha + .25, 0
end
Tex2:setPixel(col, row, red, blue, green, alpha)
index = index + 1
end
end
Tex2:invalidate()
end
--
--
--
-- For the following, see http://www.plunk.org/~hatch/rightway.html
local function AngleBetween (dot, vmw, vpw)
if dot < 0 then
return pi - 2 * asin(vpw / 2)
else
return 2 * asin(vmw / 2)
end
end
local function SinXOverX (x)
if 1 + x^2 == 1 then
return 1
else
return sin(x) / x
end
end
local function Slerp (vx, vy, wx, wy, angle, t)
local s = 1 - t
local u = s * SinXOverX(s * angle)
local v = t * SinXOverX(t * angle)
return u * vx + v * wx, u * vy + v * wy
end
--
--
--
local function Length (vx, vy, vz)
return sqrt(vx^2 + vy^2 + vz^2)
end
local function GetAngle (vx, vy, vz, wx, wy, wz)
local dot = vx * wx + vy * wy + vz * wz
local vmw = Length(vx - wx, vy - wy, vz - wz)
local vpw = Length(vx + wx, vy + wy, vz + wz)
return AngleBetween(dot, vmw, vpw)
end
local function RandomAxis ()
local ax, ay, az = random(-20, 20) / 50, random(-20, 20) / 50, random(30, 50) / 50
local alen = Length(ax, ay, az)
return ax / alen, ay / alen, az / alen
end
local SegmentLength = 11.5
local function RenderSegment (layer, time, x1, y1, vx, vy)
local x2, y2 = x1 + vx * SegmentLength, y1 + vy * SegmentLength
Raycast(x1, y1, x2, y2, UpdateTimeInfo, layer, time)
return x2, y2
end
--
--
--
local Densities = { 20, 15, 20 }
widget.newButton{
left = Image.contentBounds.xMax + 20,
top = Image.contentBounds.yMin,
label = "Build",
onEvent = function(event)
if event.phase == "ended" then
local alayer = {}
for row, edges in pairs(RowInfo) do
local r0 = (row - 1) * NCols
for col = edges.left, edges.right do
alayer[r0 + col] = .25
end
end
local layers = {}
for _, density in ipairs(Densities) do
local layer = {}
for row, edges in pairs(RowInfo) do
for col = edges.left, edges.right do
if random(100) < density then
local n = random(2, 6)
local vx, vy, vz = RandomAxis()
local wx, wy, wz = RandomAxis()
local angle = GetAngle(vx, vy, vz, wx, wy, wz)
local sina = SinXOverX(angle)
vx, vy = vx / sina, vy / sina
wx, wy = wx / sina, wy / sina
for j = 0, 15 do
local t, x, y = j / 16, col * CellWidth, row * CellHeight
for _ = 1, n do
x, y = RenderSegment(layer, j, x, y, Slerp(vx, vy, wx, wy, angle, t))
t = random(j * 3, j * 3 + 5) / 50 -- slightly shifted
end
end
end
end
end
layers[#layers + 1] = layer
end
layers[#layers + 1] = alayer
BakeLayers(layers)
end
end
}
--
--
--
local Image2 = display.newImageRect(Tex2.filename, Tex2.baseDir, 200, 200)
Image2:setStrokeColor(1, 0, 0)
Image2.anchorX, Image2.x = 0, Left
Image2.anchorY, Image2.y = 0, Top + 450
Image2.strokeWidth = 2
--
--
--
graphics.defineEffect{
category = "filter", name = "fur",
vertexData = {
{ index = 0, name = "t1", min = 0, max = 1, default = 0 },
{ index = 1, name = "t2", min = 0, max = 1, default = 0 },
{ index = 2, name = "t3", min = 0, max = 1, default = 0 },
},
fragment = [[
P_COLOR vec4 FragmentKernel (P_UV vec2 uv)
{
P_COLOR vec4 rgba = texture2D(CoronaSampler0, uv);
P_COLOR float blank = step(dot(rgba, rgba), 0.);
// Decode the RGB components, into the lower bound of the initial 16ths bin,
// plus the range (in 16ths) from there, cf. EncodeTimes() above. The sample
// is in 255ths rather than 256ths, so the rounding might be slightly off.
P_UV vec3 hoist = rgba.xyz * 16.;
P_UV vec3 range = fract(hoist);
P_UV vec3 low = (hoist - range) / 16.;
// See if the time falls within the bounds for each component.
P_UV vec3 when = CoronaVertexUserData.xyz - low;
P_COLOR vec3 ge_lower = step(vec3(0.), when);
P_COLOR vec3 le_upper = step(when, range);
P_COLOR vec3 bounded = ge_lower * le_upper;
// Accumulate the tint for any valid component. This is not a very sophisticated
// blending policy, but always-visible pixels are easy, cf. BakeLayers() above.
P_COLOR float fur_tint = dot(bounded, vec3(.25));
P_COLOR vec4 gray = vec4(rgba.a + fur_tint);
// Tint the final grayscale value. If the pixel was hidden, clear anything done.
return CoronaColorScale(gray) * (1. - blank);
}
]]
}
--
--
--
local Image3 = display.newImageRect(Tex2.filename, Tex2.baseDir, 300, 300)
Image3.fill.effect = "filter.custom.fur"
Image3:setStrokeColor(0, 1, 0)
Image3.anchorX, Image3.x = 0, Left
Image3.anchorY, Image3.y = 0, Top + 700
Image3.strokeWidth = 2
Image3:setFillColor(.7, .3, .2) -- vair
transition.to(Image3.fill.effect, { t1 = 1, time = 1300, transition = easing.continuousLoop, iterations = 0 })
transition.to(Image3.fill.effect, { t2 = 1, time = 2300, transition = easing.continuousLoop, iterations = 0 })
transition.to(Image3.fill.effect, { t3 = 1, time = 1300, transition = easing.continuousLoop, iterations = 0 })
and build.settings:
settings =
{
plugins = {
["plugin.memoryBitmap"] = { publisherId = "com.coronalabs" }
}
}
It is very much a test program. You draw in the big box up top: the only thing this does it give some left- and right-hand boundaries to parts of the “image”. I tend to draw something like a set of parentheses, possibly with some waviness. (In practice you would probably provide an image and visit the pixels with any alpha.)
When you hit “Build”, it will scoop up any rows you drew and make the encoded times + alpha texture, showing it in the middle box. The rendered fur, based on that, will be drawn in the bottom box.