Yes, as few as 0.1 ETH or more precisely as you can see on
the etherscan contract transaction page
for as few as 0.096212736214 ETH, most of it being the contract itself (0.075760070358 ETH), i.e. all the general
decoding functions that could be embedded once for all in a library
. In other words, the image part of the cost is
only about 0.02 ETH!
Of course the gas price at the time of deploying was low (approximately 20 gwei) but even with a fairly high price (say ten times bigger) this would have resulted, for the image part, to only 0.2 ETH.
This article is a deep dive into the rendering storage and mechanism used to achieve this result.
The project I was working on is called the co-bots and each of the co-bots looks like this:
At first sight, it looks like a standard pixel-art NFT project with a rather simple design. Actually, I was at the time quite involved in the chain-runners community (Runner #9036, imho the state-of-the-art pixel-art NFT project so far. I already spent some time deep studying their rendering strategy. I also already launched my first on-chain NFT project based on the runners' narrative but with a completely different storage strategy. You can learn more about it in the chain-dreamers website.
When it comes to storing image data, there are two main strategies:
- pixel-based approaches (like
.png
or.jpg
files) - vector-based approaches (like
.svg
files) that could also be called a shape-and-layering approach (as I will detail in the next section).
Usually on-chain art leverages the palette representation of a pixel-based image to store at each location the color index of the pixel, i.e. the color to display at a given index in the raster image. You can learn more about pixel representation in the Pillow python package for example.
The storage cost of a color index depends on the size of the palette: when using n
bits for storing such an index,
there will be at most 2^n
colors in the image.
With this representation, for a square image of say 32x32 pixels (standard size for most of the projects so far), the
storage cost is about 32 * 32 * n
. For a standard palette of 8 colors (and so n = 3
), this is
about 2^3 * 32 * 32 = 8192 bits = 1024 bytes
. Hence, for a given collection of few hundreds of traits (the co-bots
have 92, the chain-runners 330), the storage required is about 100kb.
Given this stackoverflow
response, the EVM burns approximately 20k gas for 32 bytes of storage, and consequently 62,500,000 gas for 100kb.
With the above mentioned gas price, this should have resulted in a storage cost of about 1.2 to 12 ETH for the image part only, not even mentioning storing the palette itself. It is, 60 times bigger than what I achieved. And each Co-Bots can not only use 8 colors, but up to 256.
The following section describes the storage strategy used for the co-bots.
The pixel-based is very general and the de facto standard in computer graphics. However, it has a storage cost directly proportional to the number of pixels. On the other hand, a vector-based approach completely ignores the notion of pixels and instead uses mathematical formulas to encode shapes that can be eventually drawn at any scale. The chain-dreamers article gives an in-depth description of how to use this approach to store any kind of image on-chain.
The Co-Bots though are a bit different. They are not any kind of shape but rather a layering of rectangles of
different shapes and colors. The .svg
file format indeed defines a <rect>
element that can be used to draw a
rectangle with given height, with, position and some other parameters as
described in the documentation.
Given these attributes and the target style, I made the follow project-dependent decisions:
- because @smlg (the designer) worked on a
45x45
grid (see the viewBox attribute) I decided to use6 bits
for each coordinate (x
,y
,width
,height
). (Note that2^6 = 64
so it's somehow a lost of granularity, meaning that the grid size could have been63x63
at the same cost). 6 bits for 4 coordinates is convenient as it lets define the whole rectangle coordinates in 3 bytes (3 * 8 = 24 = 4 * 6
). - because the EVM works with slots of 32 bytes (
see doc) and because I found it easier to
have a round number of bytes per rectangle, I decided to allocate a full
byte
for the color index. Consequently, the palette size for the whole Co-Bots could be 256 (even though the designer only used 33).
Eventually:
-
each trait is a non-constant number of layered rectangles. And each co-bots is a combination of 6 traits. In order to avoid a
for
loop in the rendering function (that would require to iteratively concatbytes
which is gassy ; I made the error in the chain-dreamers renderer contract) , I made a quick data analysis of the traits. I then realized that: -
no more than 160 rectangles would eventually be required to generate any co-bot
-
an empty
bytes4
is a valid rectangle (withwidth=0
andheight=0
)
In other words,
- each rectangle is encoded into
3 + 1 = 4 bytes
- one EVM slot of 32 bytes is 8 rectangles
- for up to 160 rectangles,
160 rectangles = 20 * 8 rectangles = 20 bytes32
is enough - using a constant size buffer of
160 * 20 bytes
would be enough to render any co-bots.
When computing the rendering of a given co-bot, the first rectangles are valid while the last one are just empty
non-visible rectangles (but still here!, see the tokenURI
output of any minted
token in etherscan)
A final step to dramatically lower the storage cost of the traits is to use the SStore2 library.
This library writes down directly a bytes
array at a given address in the EVM and returns it for later use (with
different read functions). It has been shown that the bigger
the bytes
the more savings it brings: better store one long bytes than two small ones. Hence, encoding the traits in
an easy-to-retrieve number of bytes removes the pain of concatenating everything to save on gas.
This final result brings the following high-level description of the storage and rendering mechanism:
- for the designer:
- work on a grid of size up to
64x64
- use a color palette of size 256
- create each trait so that each generated NFT will not require more than 160 rectangles (this constraint can be easily released by increasing the buffer size)
- work on a grid of size up to
- for the developer:
- concat all the traits in a long bytes
- use the
SSTORE2
library to store and read portions of it - (to be deployed soon) use the already deployed library embedding the rendering function to save the 0.07 ETH!
I strongly hope this can help the community going more and more on-chain, i.e. democratizing the use of fully decentralized assets.
Stay tuned for the forthcoming release of the library, and please don't hesitate to reach out to me on Twitter or Discord should you have any question or suggestion!