You certainly heard about Non-Fungible-Tokens (NFTs) and how much people are willing to pay for them. Whether or not you agree they are worth the sums they go for, they are at least an interesting concept. Today, we will take you through the principles needed to make generative art NFT.
While an NFT can represent anything unique, this post only considers visual art – specifically “generative on-chain art”.
What are the terms “generative” and “on-chain” referring to?
- The term “generative” refers to the fact that given a set of constraints and a different starting point, we can generate multiple images.
- The term “on-chain” represents the characteristic of NFT data being stored on the blockchain. In contrast, off-chain NFT data would most likely be stored on something like The Interplanetary File System (IPFS).
With these two things in mind, I can already see your wheels turning – we can use code and a different sequence of statements to create a different outcome based on a “seed”. Yet, your brain alerts us to the fact that images are notoriously expensive to be stored on-chain.
Yes, your brain is right… to a degree. If we want to store random images, it is expensive. However, if we want to store similar, easily describable images, we can somewhat compress the information needed to be put on-chain.
This is where engineering meets art, and they dance.
Scalable Vector Graphics (SVG) image format
SVG Format allows us to represent an image using shapes rather than pixels. Each shape has its defining properties: position, size, color, etc. A nice additional benefit of using it is that an SVG image can be scaled without losing quality.
For example, instead of storing a picture of a black circle in the form of an RGB matrix with each element representing a pixel, we need to keep just the coordinates of the circle’s center, its radius, and its color. So a large matrix can be replaced by:
<svg width="600" height="600">
<circle cx="300" cy="300" r="120" fill="black"/>
</svg>
What we are saying here is that the SVG image will have both the width and height of 600 units (pixels by default). Also, the circle element will be located at point (300, 300), with the radius being equal to 120 units and the color being black.
Building Block – The Almighty Circle
The circle has fascinated and continues to fascinate many. Besides the fact that the most famous and ever-evasive irrational number (Pi) is defined using a circle or that the “circle of life moves us all”, it is the simplest shape, and we can use it as a building block to create an image.
However, It is impossible to represent irrational numbers in smart contracts, so we limit the range of values that a circle’s property can have. On-chain storage is expensive, so we further compress information using the binary representation and talk in terms of how many bits a property has. With the property being represented with n_bits, it can have 2**n_bits distinct values.
Our circle will have the following properties: X-coordinate, Y-coordinate, Radius, Fill-Color, and Stroke-Color, with each property being represented with just 2 bits.
As a side note, using binary representation, I can make a joke about Michael Jordan’s mindset – “Either you’re number one or just a 0” – Not an actual quote, Mike, don’t take it personally.
Emergence of Patterns
We can create many random circles on an image and call it abstract art. We, however, are serious engineers, and things have to make sense. As a result, we introduce patterns.
It is worth noting that SVG elements can be nested and grouped together. Oh, I know, let’s have circles that circle around … other circles. Consider an image having a static and dynamic part. The static part will consist of some number of concentric circles and “P0” elements present on them that circle around the center. P0 elements and their motion represent the dynamic part.
What a P0 element looks like:
The SVG representation of the image:
<svg width="600" height="600">
<g>
<circle r="50" cx="300" cy="300"/>
<circle r="100" cx="300" cy="300" fill="none" stroke="black" stroke-width="2"/>
<circle r="20" cx="200" cy="300"/>
<circle r="150" cx="300" cy="300" fill="none" stroke="black" stroke-width="2"/>
<circle r="10" cx="150" cy="300" fill="none" stroke="black" stroke-width="2"/>
</g>
</svg>
The <g> element simply allows us to group different elements together and <animateTransform> allows us to create rotation effect.
Putting it all together
To create a string that corresponds to an SVG element with specific properties, we use string concatenation and Openzeppelin’s Strings library to create three helper functions used by the main code.
function _createSVG
(string memory body)
internal view
returns (string memory svgElement){
svgElement = string.concat('<svg xmlns="http://www.w3.org/2000/svg" ',
'width="', Strings.toString(W),
'" height="', Strings.toString(H), '"><g>',
body, '</g></svg>'
);
}
function _createCircle
(uint cx, uint cy, uint r, string memory fill, string memory stroke, string memory body)
internal pure
returns (string memory svgElement) {
svgElement = string.concat('<circle ',
'cx ="', Strings.toString(cx), '" ',
'cy="', Strings.toString(cy), '" ',
'r="', Strings.toString(r), '" ',
'fill="', fill, '" ',
'stroke="', stroke, '">', body, '</circle>'
);
}
function _createAnimation
(uint cx, uint cy, uint r)
internal pure
returns (string memory){
return string.concat(
'<animateTransform attributeName="transform" attributeType="XML" type="rotate"',
'from="0 ', string.concat(Strings.toString(cx), ' ', Strings.toString(cy), '" '),
'to="360 ', string.concat(Strings.toString(cx), ' ', Strings.toString(cy), '" '),
'dur="', Strings.toString(2+r), '" ', 'repeatCount="indefinite"/>'
);
}
Bit Magic
Since storage is expensive, and we don’t want to waste it, we can pack multiple information pieces inside one simple type – uint256. Let us call it the “seed” since that’s the only thing determining what image will be produced.
Our seed consists of 19 circles, each with its 10-bit representation. Using “bit magic” we can manipulate the seed to extract only the 10 bits of the circle that we are considering.
function _extractCircle
(uint seed, uint idx)
internal view
returns (uint extractedCircle){
extractedCircle = (seed >> (idx*BPC)) & ((2 << BPC) - 1);
}
function _unpackCircle
(uint seed, uint idx)
internal view returns
(uint cx, uint cy, uint r, string memory fill, string memory stroke) {
uint packedCircle = (seed >> (idx*BPC)) & ((2 << BPC) - 1);
cx = 1 + (packedCircle & 3);
cy = 1 + ((packedCircle >> 2) & 3);
r = 1 + ((packedCircle >> 4) & 3);
fill = _getColor((packedCircle >> 6 ) & 3);
stroke = _getColor((packedCircle >> 8 ) & 3);
}
Procedurally generating Images
This is where your creativity comes to shine. You can use different arrangements, colors, shapes, etc. As an example of how the code can produce different results based on seed, we define the _render function and all of its helper functions as:
function _render
(uint seed)
internal view
returns (string memory svg){
svg = _createSVG(string.concat(
_buildStaticPart(seed),
_buildDynamicPart(seed)
));
}
function _buildStaticPart
(uint seed)
internal view
returns (string memory svgPart){
for(uint idx = 0; idx < NSC; ++idx){
(, , , string memory fill, string memory stroke) = _unpackCircle(seed, idx);
svgPart = string.concat(svgPart, _createCircle(
W/2, H/2, (NSC-idx)*SD, fill, stroke, "")
);
}
}
function _buildDynamicPart
(uint seed)
internal view
returns (string memory svgPart){
for(uint idx = NSC; idx < NSC + (NSC-1)*NDC; idx += NDC){
(uint cx, uint cy, uint r, , ) = _unpackCircle(seed, idx);
svgPart = string.concat(svgPart,_createSVG(string.concat(
_createAnimation(W/2, H/2, cx*cy+r+idx),
_buildDynamicSubPart(seed, idx, SD*(1+idx/NSC)))
));
}
}
function _buildDynamicSubPart
(uint seed, uint idx, uint cdist)
internal view
returns (string memory svgPart){
for(uint jdx = 0; jdx < (NDC-1)/2 + 1; ++jdx){
(, , , string memory fill, string memory stroke) = _unpackCircle(seed, idx++);
svgPart = string.concat(svgPart,
_createCircle(
W/2-cdist, H/2, (1+jdx)*DD, fill, stroke, "")
);
svgPart = string.concat(svgPart,
_createCircle(
W/2+cdist, H/2, (1+jdx)*DD, fill, stroke, "")
);
}
for(uint rdx = (NDC-1)/2 + 1; rdx < NDC; ++rdx){
(uint cx, , uint r, string memory fill, string memory stroke) = _unpackCircle(seed, idx++);
svgPart = string.concat(svgPart,
_createCircle(W/2-cdist-((rdx-(NDC-1)/2+1)*DD), H/2, r, fill, stroke,
_createAnimation(W/2-cdist, H/2, r+cx)),
_createCircle(W/2+cdist+((rdx-(NDC-1)/2+1)*DD), H/2, r, fill, stroke,
_createAnimation(W/2+cdist, H/2, r+cx))
);
}
}
Example of a Generated Image
With the random seed = 0x30F0380203C0E83F0E00800C030F0380203C0F038020380, the code produces an image that looks like this:
Used constants and their values: Image width ( W = 600 ), Image height ( H = 600 ), Number of “static” circles ( NSC = 4 ), Number of “dynamic” circles ( NDC = 5 ), Distance between “static” circles ( SD = 45 ), Distance between “dynamic” circles ( DD = 6 ), Number of bits per circle ( BPC = 10 ).
Conclusion
We have successfully created parts that produce generative art. In the end, you are only bound by your creativity and your coding skills. The next steps would need to include complying with the NFT ERC721 or ERC1155 standard so the world can see your beautiful work – check out this excellent tutorial from Patrick Collins.
Mandatory Quiz
- What modifiers does the _render function have – what does that tell you?
- Did we have to use the uint type for the seed – could the information be packed more efficiently?
- Do we need to have 10 bits per circle based on the code implementation?
- Did you check out multiple open positions on our careers page?