so i'm a big fan of the ctm ('connected textures mod') mod in minecraft. in large part because i use the greatwood texture pack, which has a bunch of ctm textures and really shows off what a little bit of variation can do.
texture variation (and geometry variation) are huge things to have. vagrant story is a game that it turns out is a lot like minecraft -- every room is assembled out of meter-large block/halfblock collision (well, and stairs), and its textures are (generally) 32x32 pixels per meter. so only four times the density of vanilla minecraft's 16x16 textures, and a quarter of the fancy smooth 64x64 sphax textures that a lot of people use. but because vagrant story has a whole lot of texture variation (and more complex world geometry, though that turns out to not actually be as big a concern as you might think) vagrant story looks amazing even now, whereas minecraft looks, you know, kind of garbage. it turns out big fields of repeating textures look bad.
so i really wanted to add more intricate texturing modes to the game i'm working on, since i feel like that'll be kind of critical for the visual style. there are a lot of different modes i want to support (automatic wang tilings would be great) but to start i decided to copy the simplest ctm modes:
fixed
, which is a single texture
random
, which is a list of textures with weights, to be picked from randomly
repeat
, which is an x
*y
grid of textures that will be laid out in a repeating pattern
the type i wrote for those initially was
data Texturing
= Fixed FilePath
| Random [(Float, FilePath)]
| Repeat Int Int [FilePath]
deriving (Eq, Ord, Show, Read)
which is pretty obvious how it works, right? i have a big hardcoded texture-loading section that looked like
...
,("sand_loose", "img/tiles/sand_loose.png")
,("sand_loose_side", "img/tiles/sand_loose_side.png")
,("sand_cracked", "img/tiles/sand_cracked.png")
,("sand_cracked_side", "img/tiles/sand_cracked_side.png")
,("sand_silty", "img/tiles/sand_silty.png")
,("sand_silty_side", "img/tiles/sand_silty_side.png")
,("loam_coarse", "img/tiles/loam_coarse.png")
,("loam_coarse_side", "img/tiles/loam_coarse_side.png")
...
and then that would be trivially changed to
...
,("sand_loose", Fixed "img/tiles/sand_loose.png")
,("sand_loose_side", Fixed "img/tiles/sand_loose_side.png")
,("sand_cracked", Fixed "img/tiles/sand_cracked.png")
,("sand_cracked_side", Fixed "img/tiles/sand_cracked_side.png")
,("sand_silty", Fixed "img/tiles/sand_silty.png")
,("sand_silty_side", Fixed "img/tiles/sand_silty_side.png")
,("loam_coarse", Fixed "img/tiles/loam_coarse.png")
,("loam_coarse_side", Fixed "img/tiles/loam_coarse_side.png")
...
and then i could add new textures from there.
the thing was, just using that type above was kind of awkward? since one of the things i wanted to do was load up each image based on the given path, but without discarding the texturing information. but to anybody who's been writing haskell for any amount of time, the issue is obvious: what i actually want is to add a type parameter
data Texturing a
= Fixed a
| Random Float [(Float, a)]
| Repeat Int Int [a]
deriving (Eq, Ord, Show, Read)
instance Functor Texturing where
fmap f t = case t of
Fixed a -> Fixed $ f a
Random t was -> Random t $ (fmap . fmap) f was
Repeating w h as -> Repeating w h $ fmap f as
and now i can
fmap
things through the texturing data without losing the texturing information. this is a concrete example of something having a "functor context" that's invariant under
fmap
(other examples are 'fmapping a list or a tree can't reorder items in the list or tree').
except there's still an issue, since what i really want to do is mapM
-- if i have [Texturing FilePath]
, i don't want to end up with [Texturing (IO Image)]
after doing the file loading, i want IO [Texturing Image]
. that's a different function, traverse
, which is part of the Traversible
typeclass.
this is kind of like, warming up to this texturing data being a Type that has typeclasses, not just some tagged enum: Functor
says it can be mapped over, Foldable
says it can be iterated through, and Traversable
extends that traversal to being able to pull it 'inside out' to do precisely that kind of sequencing above -- do a file load for each thing, but pull them all out into a single wrapper as it happens so that the IO
ends up 'outside' the Texturing
.
so i went and instanced Foldable
and Traversable
, and then i was thinking, well, what other common typeclasses should i be thinking about? it's not Applicative
or Monad
since it can't really combine two things, and it's definitely not Semigroup
or Monoid
for the same reason. since it's not Applicative
it can't be Alternative
, and since it's not Monad
it can't be MonadPlus
. that basically covers all the common typeclasses, so i guess i'm done.
the thing with writing types is that it's generally possible to explicitly make a type into a valid typeclass instance, right? Semigroup
just means "can be added together", and you can trivially implement that by adding in a new constructor like Several [Texturing a]
. it's just like, well, is that really useful? given what the typeclass means, does that make some kind of sense? like i could say, maybe, that it's a semigroup in that adding two values together just dumps their values into a Random
constructor, but then i'd have to make up weights, and that works well enough if the values being combined are Fixed
or Random
already, but Repeat
would just make a huge mess, etc. so Semigroup
doesn't really make sense. Monoid
extends Semigroup
with the addition of an null 'empty' object, so that would mean like, "totally untextured object" i guess, which is something that we actively want to avoid having!
but Applicative
, hmm, that would maybe be useful.
looking at the ctm mod's config files (that's optifine's impl but it's the same config format) reveals a kind of pattern: there are 'base level' methods that do something -- ctm
, ctm_compact
, fixed
, random
, repeat
, vertical
, horizontal
, top
, and overlay
-- but then there are also a bunch of methods that only exist to say "run two of these methods together": horizontal+vertical
, vertical+horizontal
, overlay_ctm
, overlay_random
, overlay_repeat
, and overlay_fixed
. what the mod lacks is a general-purpose way to say "use this one texturing method and then use this other texturing method on top"; they've had to special-case in each combination with its own mode.
Applicative
has two functions: pure :: a -> f a
, which here is trivially just Fixed
, and (<*>) :: f (a -> b) -> f a -> f b
, which has to do with combining two values together. so if i want to combine two texturing values together...
data Texturing a
= Fixed a
| Random Float [(Float, Texturing a)]
| Repeating Int Int [Texturing a]
deriving (Eq, Ord, Show, Read)
note that Random
and Repeating
now take a list of Texturing a
, rather than just a
. this makes Fixed
into a kind of 'terminal value', since the other two constructors will recurse to contain more Texturing a
s; it's only Fixed
that will stop and finally produce an a
. this two type is still a Functor
, Foldable
, and Traversable
, but it's now also an Applicative
:
instance Applicative Texturing where
pure a = Fixed a
tf <*> ta = case tf of
Fixed f -> f <$> ta
Random tw wfs -> Random tw $ second (<*> ta) <$> wfs
Repeating w h fs -> Repeating w h $ (<*> ta) <$> fs
this expresses how it's possible to nest these values arbitrarily, which it turns out is the general-case version of what the ctm mod has to special-case. here, i can say 'random + repeating' to make a repeating brick pattern that's randomly interrupted by a flagstone:
Random 1
[ (0.875, Repeating 2 2
[ Fixed "tile_bricks_bl.png"
, Fixed "tile_bricks_br.png"
, Fixed "tile_bricks_tl.png"
, Fixed "tile_bricks_tr.png"
]
, (0.125, Fixed "tile_flagstone.png"
]
done the other way, it's 'repeating + random', which presents a repeating brick pattern with a random chance of each section having a variant:
Repeating 2 2
[ Random 1
[ (0.75, Fixed "tile_bricks_bl.png")
, (0.25, Fixed "tile_bricks_bl_cracked.png")
]
, Random 1
[ (0.75, Fixed "tile_bricks_br.png")
, (0.25, Fixed "tile_bricks_br_cracked.png")
]
, Random 1
[ (0.75, Fixed "tile_bricks_tl.png")
, (0.25, Fixed "tile_bricks_tl_cracked.png")
]
, Random 1
[ (0.75, Fixed "tile_bricks_tr.png")
, (0.25, Fixed "tile_bricks_tr_cracked.png")
]
]
when i add in the other texturing modes like ctm
or overlay
, those would just be more constructors that take Texturing a
values, and would combine with each other easily and arbitrarily. all these examples don't even use <*>
, they just take advantage of the general restructing afforded to them by the Applicative
rewrite.
i think this, more than anything, is the good part of haskell? its typeclasses are actually useful because a lot of them represent general-purpose data transformations, and that leads you to thinking "would this transformation be useful to have here?", and a lot of the time the answer to that is 'yes'. the conceptual pattern for organizing these values was always out there, but it took haskell being like, "hey this is what a Functor
is, this is what an Applicative
is" for me to consciously realize that was maybe a target i should be reaching for
the final type (as of now) is this:
data Texturing a
= Fixed a
| Random Float [(Float, Texturing a)]
| Repeating Int Int [Texturing a]
deriving (Eq, Ord, Show, Read)
instance Functor Texturing where
fmap f t = case t of
Fixed a -> Fixed $ f a
Random tw was -> Random tw $ (fmap . fmap . fmap) f was
Repeating w h as -> Repeating w h $ (fmap . fmap) f as
instance Foldable Texturing where
foldMap f t = case t of
Fixed a -> f a
Random _ was -> (foldMap . foldMap) f $ snd <$> was
Repeating _ _ as -> (foldMap . foldMap) f as
instance Traversable Texturing where
sequenceA t = case t of
Fixed a -> Fixed <$> a
Random tw was -> Random tw <$> sequenceA (fmap (sequenceA . fmap sequenceA) was)
Repeating w h as -> Repeating w h <$> sequenceA (sequenceA <$> as)
instance Applicative Texturing where
pure a = Fixed a
tf <*> ta = case tf of
Fixed f -> f <$> ta
Random tw wfs -> Random tw $ second (<*> ta) <$> wfs
Repeating w h fs -> Repeating w h $ (<*> ta) <$> fs
which is a little frustrating to read (
sequenceA (fmap (sequenceA . fmap sequenceA) was)
, you don't say) but it all works and very simply specifies the necessary information each tile type needs to be textured, and it shouldn't be particularly difficult to expand for future texturing types.