How to use the Nostr social network via the iris.to web client

Why Nostr?

Social networks like Twitter, Facebook, Whatsapp, and others are run by companies. When using these networks to stay in touch with friends

  • You have to trust the company with the content of your conversations.
  • Usually the companies mine the data and inject targeted advertising into your feed.
  • Secret algorithms are used to emphasize or de-emphasize information and one has to trust the company that it is not using these mechanisms to influence public opinion.

However there is now an alternative called Nostr.

  • Nostr is an open protocol and a network of participating relays and clients.
  • In a similar fashion as Bitcoin, the network is distributed (i.e. there are multiple relays).
  • Accounts are just a pair of secret and public key. I.e. similar as with Bitcoin there is no registration using email and/or mobile phone number required.
  • Because users are not bound to a particular client (or relay), they can without effort switch clients and relays if the interface becomes cluttered with ads or if the quality of service is low in any other way.
  • Nostr even lets you add Bitcoin Lightning payment links to all of your post so viewers of your post can give you small tips if they like your content.

I have tested a few clients (for desktop and web) and at the moment in my opinion the web application iris.to is the best choice. Now I am going to give you a non-technical guide on how to get started using this web application.

Using the Iris.to Nostr client web application

When opening the website the first time one is greeted with a login page. The login page asks you for your display name and here I just entered test for demonstration purposes. When clicking Go, the web application creates a public and private key pair. Iris.to login

The next page shows you a selection of popular accounts which you can choose to add to your feed by pressing one or more of the Follow buttons. Iris.to follow accounts

The next page lets you choose a unique user name for the iris.to website. If you for example choose testit, you get a human-readable Nostr address (a so called NIP-05 name) called testit@iris.to. Of course you can always choose to get a new Nostr identity from another website. If you have a private homepage with the URL https://myhomepage.org, you can even create your own Nostr identity testit@myhomepage.org. More about this later. Iris.to select Nostr address

If you navigate to the account page, you will see your public key, which is a long string starting with npub.... Iris.to account public key

If you scroll down, you will see a button to copy your private key to the clipboard. The secret key is a long string starting with nsec.... Now is a good time to store this private key securely, otherwise you will permanently loose access to this account. If you loose your secret key, your only option is to start over with a new account. You will need this key if you get a new PC or if you want to use Nostr on another device or client. Depending on your browser’s privacy settings, you might need the key next time you restart the browser. Iris.to account secret key

When clicking on the home icon, one gets presented with two options:

  • Following: show messages from accounts you follow
  • Global: show messages from your extended social network Iris.to home screen

When clicking on Following, you will see a real-time feed from accounts you follow. Also the top of the page shows you a button to copy a link to your public feed to the clipboard. If you haven’t chosen a human-readable Nostr identity, the link will be something like https://iris.to/npub.... If you have chosen the name testit, your public link will be https://iris.to/testit. If you are using your private homepage to create a Nostr identity, you can also use https://iris.to/testit@myhomepage.org as public link. Iris.to feed

That’s it :)

Enjoy!

Also you can follow me.

BTW, if you are looking for new type of content, you can search using the hash-tag #grownostr.

BTW, if you want to send/receive zaps (small tips using Bitcoin Lightning), I can recommend the Alby web wallet.

Advanced Users

This section is for advanced users only and is purely optional.

Nostr extension login (somewhat advanced)

You might have noticed that in my screenshot the login page has a clickable Nostr extension login link which makes login easier. This is because I am using the Firefox extension nos2x-fox which manages logging in without revealing the private key to the web application. In my case if I go to the account page, there is no option to copy the private key because the web application does not know it. In a similar fashion, desktop clients can post messages to relay servers without revealing the private key. Iris.to Nostr extension login

Nostr address using private homepage (more advanced)

Note that the account page also has a Copy hex button which lets you copy your public key in hexadecimal form. In order to use your homepage to give you a check mark verifying the Nostr address testit@myhomepage.org, you need to place a JSON file at https://myhomepage.org/.well-known/nostr.json with the following content:

{"names":{"testit":"<your public hex key>"}}

Also you need to enable CORS. You can do this by adding a .htaccess file with the following content in the .well-known folder:

Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "GET"
Header set Access-Control-Allow-Headers "Content-Type, Authorization"

Procedural generation of global cloud cover

This article is about generating realistic planetary cloud cover. In an initial attempt I applied random rotation fields to a sphere with Worley noise and posted it on Reddit. However the results did not look convincing. A helpful comment by mr_bitshift pointed out the publication Bridson et al. “Curl noise for procedural fluid flow”. Also in a response to a later Reddit post smcameron shared an impressive result of a gas giant generated using curl noise.

In two dimensions curl noise is fairly easy to understand and implement. For a thorough description of 2D curl noise see Keith Peters’ article “Curl noise, demystified”. Basically one starts with a potential field such as multiple octaves of Worley noise. One then extracts the 2D gradient vectors and rotates them by 90°.

Curl vectors

To generate curl vectors for a spherical surface one can use 3D Worley noise and sample the gradients on the surface of the sphere. The gradient vectors then need to be projected onto the sphere. This can be achieved by projecting the gradient vector onto the local normal vector of the sphere using vector projection. By subtracting the projected vector from the gradient vector one obtains the tangential component of the gradient vector.

Project vector onto sphere

latex formula

The resulting vector p needs to be rotated around the normal n by 90°. This can be achieved by rotating the vector p into a TBN system, rotating by 90° around N and then transforming back. The GLSL functions for the rotation (without OpenGL tests) are shown below:

#version 410 core

vec3 orthogonal_vector(vec3 n)
{
  vec3 b;
  if (abs(n.x) <= abs(n.y)) {
    if (abs(n.x) <= abs(n.z))
      b = vec3(1, 0, 0);
    else
      b = vec3(0, 0, 1);
  } else {
    if (abs(n.y) <= abs(n.z))
      b = vec3(0, 1, 0);
    else
      b = vec3(0, 0, 1);
  };
  return normalize(cross(n, b));
}

mat3 oriented_matrix(vec3 n)
{
  vec3 o1 = orthogonal_vector(n);
  vec3 o2 = cross(n, o1);
  return transpose(mat3(n, o1, o2));
}

// note that axis needs to be a unit vector
vec3 rotate_vector(vec3 axis, vec3 v, float cos_angle, float sin_angle)
{
  mat3 orientation = oriented_matrix(axis);
  vec3 oriented = orientation * v;
  mat2 rotation = mat2(cos_angle, sin_angle, -sin_angle, cos_angle);
  vec3 rotated = vec3(oriented.x, rotation * oriented.yz);
  return transpose(orientation) * rotated;
}

In OpenGL one can create a cubemap where each pixel on each surface contains a 3D warp vector.

  • Using a fragment shader the cubemap is initialised to be an identity transform for unit vectors.
  • A second fragment shader is used to initialise a cubemap with the curl vectors which are tangential to the sphere.
  • A third fragment shader is called multiple times to renormalize and increment the identity transform to become a warp field.
  • A final fragment shader uses the cubemap warp field to perform lookups in a 3D Worley noise field to generate a cubemap of the global cloud cover.

Update warp vectors

If one uses octaves of Worley noise one obtains vortices rotating in one direction. To obtain prevailing winds and vortices with different direction of rotation depending on the latitude one can use the function (1+sin(2.5*latitude))/2 to mix positive and negative Worley noise.

Mixing positive and negative Worley noise to obtain prevailing winds

Below is a result obtained using the method described in this article.

Example of resulting cloud cover

Also see here for a video.

See cover.clj for source code.

Enjoy!

Update

Another detail I forgot to mention is that the fragment shaders and the cubemap texture lookups use modified vectors to avoid performing lookups in the texture clamping regions which would lead to seams in the cloud cover. I.e. when converting fragment coordinates, one increases the range of the index by half a pixel on both ends:

vec2 x = (gl_FragCoord.xy - 0.5) / (size - 1);

Furthermore when performing lookups, two coordinates of the lookup vector are scaled down by half a pixel:

vec3 convert_cubemap_index(vec3 idx, int size)
{
  vec3 scale;
  if (abs(idx.x) >= abs(idx.y)) {
    if (abs(idx.x) >= abs(idx.z))
      scale = vec3(size, size - 1, size - 1);
    else
      scale = vec3(size - 1, size - 1, size);
  } else {
    if (abs(idx.y) >= abs(idx.z))
      scale = vec3(size - 1, size, size - 1);
    else
      scale = vec3(size - 1, size - 1, size);
  };
  return idx * scale / size;
}

The following picture illustrates the two related conversions.

Index conversions for cubemaps

Ode to Clojure

I have been playing with Chat GPT and I saw on Reddit that someone had the idea to ask it to write an ode to Clojure. I tried it a few times as well and here is my favourite version:

Oh Clojure, a language so elegant and fine,
With your concision and power, you blow the mind.

Your Lisp roots run deep, and your functional core
Makes coding a joy, and never a bore.

Your homoiconicity is a thing of beauty,
Allowing code and data to flow with ease and duty.

Your interoperability with Java is a sight to see,
Allowing us to use the best of both worlds, merrily.

Your REPL is a treasure, allowing us to explore,
And test our code in a way that we adore.

Oh Clojure, you are a true work of art,
A language that is loved by many a heart.

Here’s to you, dear Clojure, may you continue to shine,
A beacon of simplicity, power, and design.

Blue Noise Dithering

Initial seed pattern

This is a short description on blue noise dithering. For more detail see the 1993 paper about blue noise dithering.

Initially a binary pattern BP is created where N pixels (about 10 percent) are set to 1 and the rest to 0.

random mask

The binary pattern then is convolved with the following filter function to generate the density array DA:

latex formula

You can use a sigma value of 1.5.

The convolution is wrapped around to facilitate a tileable result:

latex formula

Maxima of the density array are called clusters and minima are called voids.

The 1 value in BP with the highest density value DA (tightest cluster) is set to 0 and DA is updated accordingly. Now the 0 value in BP with the lowest density value DA (largest void) is set to 1 (and DA is updated).

This is repeated until disolving the tightest cluster creates the largest void. This is done to spread the 1 values evenly.

seed

Phase 1

In phase 1 of the dithering algorithm the 1 values of a copy of the seed pattern are removed one by one starting where the density DA is the highest. A copy of the density array DA is updated accordingly. The corresponding positions in the resulting dither array are set to N-1, N-2, …, 0.

phase1

Phase 2

In phase 2 starting with the seed pattern a mask is filled with 1 values where the density DA is the lowest. The density array DA is updated while filling in 1 values. Phase 2 stops when half of the values in the mask are 1. The corresponding positions in the dither array are set to N, N+1, …, (M * M) / 2 - 1

phase2

Phase 3

In phase 3 the density array DA is recomputed using the boolean negated mask from the previous phase (0 becomes 1 and 1 becomes 0). Now the mask is filled with 1 values where the density DA is the highest (clusters of 0s) always updating DA. Phase 3 stops when all the values in the mask are 1. The corresponding positions in the dither array are set to (M * M) / 2, …, M * M - 1.

phase3

Result

The result can be normalised to 0 to 255 in order to inspect it. The blue noise dither array looks as follows:

random mask

Here is an example with constant offsets when sampling 3D clouds without dithering.

no dithering

Here is the same scene using dithering to set the sampling offsets.

dithering

One can apply a blur filter to reduce the noise.

blur

Note how the blurred image shows more detail than the image with constant offsets even though the sampling rate is the same.

Let me know any comments/suggestions in the comments below.

Test Driven Development with OpenGL

Test Driven Development

Test driven development (TDD) undoubtedly helps a great deal in preventing development grinding to a halt once a project’s size surpasses a few lines of code.

  1. The reason for first writing a failing test is to ensure that the test is actually failing and testing the next code change.
  2. A minimal change to the code is performed to pass the new test while also still passing all previously written tests.
  3. If necessary the code is refactored/simplified. The reason to do this after passing the test is so that one does not have to worry about passing the test and writing clean code at the same time.

Testing rendering output

One can test OpenGL programs by rendering test images and comparing them with a saved image (a test fixture). In order to automate this, one can perform offscreen rendering and do a pixel-wise image comparison with the saved image.

Using the Clojure programming language and the Lightweight Java Game Library (LWJGL) one can perform offscreen rendering with a Pbuffer object using the following macro (of course this approach is not limited to Clojure and LWJGL):

(defn setup-rendering
  "Common code for setting up rendering"
  [width height]
  (GL11/glViewport 0 0 width height)
  (GL11/glEnable GL11/GL_DEPTH_TEST)
  (GL11/glEnable GL11/GL_CULL_FACE)
  (GL11/glCullFace GL11/GL_BACK)
  (GL11/glDepthFunc GL11/GL_GEQUAL)
  (GL45/glClipControl GL20/GL_LOWER_LEFT GL45/GL_ZERO_TO_ONE))

(defmacro offscreen-render
  "Macro to use a pbuffer for offscreen rendering"
  [width height & body]
  `(let [pixels#  (BufferUtils/createIntBuffer (* ~width ~height))
         pbuffer# (Pbuffer. ~width ~height (PixelFormat. 24 8 24 0 0) nil nil)
         data#    (int-array (* ~width ~height))]
     (.makeCurrent pbuffer#)
     (setup-rendering ~width ~height)
     (try
       ~@body
       (GL11/glReadPixels 0 0 ~width ~height GL12/GL_BGRA GL11/GL_UNSIGNED_BYTE pixels#)
       (.get pixels# data#)
       {:width ~width :height ~height :data data#}
       (finally
         (.releaseContext pbuffer#)
         (.destroy pbuffer#)))))

Note that the code sets up reversed-z rendering as discussed in an earlier article

Using the Midje testing library one can for example test a command for clearing the color buffer as follows:

(fact "Render background color"
  (offscreen-render 160 120 (clear (matrix [1.0 0.0 0.0])))
  => (is-image "test/sfsim25/fixtures/render/red.png"))

The checker is-image is implemented using ImageJ:

(defn is-image
  "Compare RGB components of image and ignore alpha values."
  [filename]
  (fn [other]
      (let [img (slurp-image filename)]
        (and (= (:width img) (:width other))
             (= (:height img) (:height other))
             (= (map #(bit-and % 0x00ffffff) (:data img))
                (map #(bit-and % 0x00ffffff) (:data other)))))))

(defn slurp-image
  "Load an RGB image"
  [^String file-name]
  (let [img (.openImage (Opener.) file-name)]
    (.convertToRGB (ImageConverter. img))
    {:width (.getWidth img)
     :height (.getHeight img)
     :data (.getPixels (.getProcessor img))}))

The image is recorded initially by using the checker record-image instead of is-image and verifying the result manually.

(defn record-image
  "Use this test function to record the image the first time."
  [filename]
  (fn [other]
      (spit-image filename other)))

(defn spit-image
  "Save RGB image as PNG file"
  [^String file-name {:keys [width height data]}]
  (let [processor (ColorProcessor. width height data)
        img       (ImagePlus.)]
    (.setProcessor img processor)
    (.saveAsPng (FileSaver. img) file-name)))

One can use this approach (and maybe only this approach) to test code for handling vertex array objects, textures, and for loading shaders.

Testing shader code

Above approach has the drawback that it can only test complete rendering programs. Also the output is limited to 24-bit RGB images. The tests are therefore more like integration tests and they are not suitable for unit testing shader functions.

However it is possible to use a Pbuffer just as a rendering context and perform rendering to a floating-point texture. One can use a texture with a single pixel as a framebuffer. A single pixel of a uniformly colored quad is drawn. The floating point channels of the texture’s RGB pixel then can be compared with the expected value.

(defn shader-test [setup probe & shaders]
  (fn [uniforms args]
      (let [result (promise)]
        (offscreen-render 1 1
          (let [indices  [0 1 3 2]
                vertices [-1.0 -1.0 0.5, 1.0 -1.0 0.5, -1.0 1.0 0.5, 1.0 1.0 0.5]
                program  (make-program :vertex [vertex-passthrough]
                                       :fragment (conj shaders (apply probe args)))
                vao      (make-vertex-array-object program indices vertices [:point 3])
                tex      (texture-render 1 1 true
                                         (use-program program)
                                         (apply setup program uniforms)
                                         (render-quads vao))
                img      (texture->vectors tex 1 1)]
            (deliver result (get-vector img 0 0))
            (destroy-texture tex)
            (destroy-vertex-array-object vao)
            (destroy-program program)))
        @result)))

(def vertex-passthrough "#version 410 core
in highp vec3 point;
void main()
{
  gl_Position = vec4(point, 1);
}")

(defmacro texture-render
  "Macro to render to a texture"
  [width height floating-point & body]
  `(let [fbo# (GL45/glCreateFramebuffers)
         tex# (GL11/glGenTextures)]
     (try
       (GL30/glBindFramebuffer GL30/GL_FRAMEBUFFER fbo#)
       (GL11/glBindTexture GL11/GL_TEXTURE_2D tex#)
       (GL42/glTexStorage2D GL11/GL_TEXTURE_2D 1
         (if ~floating-point GL30/GL_RGB32F GL11/GL_RGBA8) ~width ~height)
       (GL32/glFramebufferTexture GL30/GL_FRAMEBUFFER GL30/GL_COLOR_ATTACHMENT0 tex# 0)
       (GL20/glDrawBuffers (make-int-buffer (int-array [GL30/GL_COLOR_ATTACHMENT0])))
       (GL11/glViewport 0 0 ~width ~height)
       ~@body
       {:texture tex# :target GL11/GL_TEXTURE_2D}
       (finally
         (GL30/glBindFramebuffer GL30/GL_FRAMEBUFFER 0)
         (GL30/glDeleteFramebuffers fbo#)))))

(defn texture->vectors
  "Extract floating-point vectors from texture"
  [texture width height]
  (with-2d-texture (:texture texture)
    (let [buf  (BufferUtils/createFloatBuffer (* width height 3))
          data (float-array (* width height 3))]
      (GL11/glGetTexImage GL11/GL_TEXTURE_2D 0 GL12/GL_BGR GL11/GL_FLOAT buf)
      (.get buf data)
      {:width width :height height :data data})))

Furthermore it is possible to compose the fragment shader by linking the shader function under test with a main function. I.e. it is possible to link the shader function under test with a main function implemented just for probing the shader.

The shader-test function defines a test function using the probing shader and the shader under test. The new test function then can be used using the Midje tabular environment. In the following example the GLSL function phase is tested. Note that parameters in the probing shaders are set using the weavejester/comb templating library.

(def phase-probe
  (template/fn [g mu] "#version 410 core
out lowp vec3 fragColor;
float phase(float g, float mu);
void main()
{
  float result = phase(<%= g %>, <%= mu %>);
  fragColor = vec3(result, 0, 0);
}"))

(def phase-test (shader-test (fn [program]) phase-probe phase-function))

(tabular "Shader function for scattering phase function"
         (fact (mget (phase-test [] [?g ?mu]) 0) => (roughly ?result))
         ?g  ?mu ?result
         0   0   (/ 3 (* 16 PI))
         0   1   (/ 6 (* 16 PI))
         0  -1   (/ 6 (* 16 PI))
         0.5 0   (/ (* 3 0.75) (* 8 PI 2.25 (pow 1.25 1.5)))
         0.5 1   (/ (* 6 0.75) (* 8 PI 2.25 (pow 0.25 1.5))))

Note that using mget the red channel of the pixel is extracted. Sometimes it might be more desirable to check all channels of the RGB pixel.

Here is the actual implementation of the tested function:

#version 410 core

float M_PI = 3.14159265358;

float phase(float g, float mu)
{
  return 3 * (1 - g * g) * (1 + mu * mu) /
    (8 * M_PI * (2 + g * g) * pow(1 + g * g - 2 * g * mu, 1.5));
}

The empty function (fn [program]) is specified as a setup function. In general the setup function is used to initialise uniforms used in the shader under test.

Here is an example of tests using uniform values:

(def transmittance-track-probe
  (template/fn [px py pz qx qy qz] "#version 410 core
out lowp vec3 fragColor;
vec3 transmittance_track(vec3 p, vec3 q);
void main()
{
  vec3 p = vec3(<%= px %>, <%= py %>, <%= pz %>);
  vec3 q = vec3(<%= qx %>, <%= qy %>, <%= qz %>);
  fragColor = transmittance_track(p, q);
}"))

(def transmittance-track-test
  (transmittance-shader-test
    (fn [program height-size elevation-size elevation-power radius max-height]
        (uniform-int program :height_size height-size)
        (uniform-int program :elevation_size elevation-size)
        (uniform-float program :elevation_power elevation-power)
        (uniform-float program :radius radius)
        (uniform-float program :max_height max-height))
    transmittance-track-probe transmittance-track
    shaders/transmittance-forward shaders/horizon-angle
    shaders/elevation-to-index shaders/interpolate-2d
    shaders/convert-2d-index shaders/is-above-horizon))

(tabular "Shader function to compute transmittance between two points in the atmosphere"
         (fact (mget (transmittance-track-test [17 17 1 6378000.0 100000.0]
                                               [?px ?py ?pz ?qx ?qy ?qz]) 0)
               => (roughly ?result 1e-6))
         ?px ?py ?pz     ?qx ?qy ?qz     ?result
         0   0   6478000 0   0   6478000 1
         0   0   6428000 0   0   6478000 0.5
         0   0   6453000 0   0   6478000 0.75
         0   0   6428000 0   0   6453000 (/ 0.5 0.75))

Here a setup function initialising 5 uniform values is specified.

Mocking shader functions

If each shader function is implemented as a separate string (loaded from a separate file), one can easily link with mock functions when testing shaders. Here is an example of a probing shader which also contains mocks to allow the shader to be unit tested in isolation:

(def cloud-track-base-probe
  (template/fn [px qx n decay scatter density ir ig ib]
"#version 410 core
out lowp vec3 fragColor;
vec3 transmittance_forward(vec3 point, vec3 direction)
{
  float distance = 10 - point.x;
  float transmittance = exp(-<%= decay %> * distance);
  return vec3(transmittance, transmittance, transmittance);
}
vec3 ray_scatter_forward(vec3 point, vec3 direction, vec3 light)
{
  float distance = 10 - point.x;
  float amount = <%= scatter %> * (1 - pow(2, -distance));
  return vec3(0, 0, amount);
}
float cloud_density(vec3 point)
{
  return <%= density %>;
}
float phase(float g, float mu)
{
  return 1.0 + 0.5 * mu;
}
vec3 cloud_track_base(vec3 p, vec3 q, int n, vec3 incoming);
void main()
{
  vec3 p = vec3(<%= px %>, 0, 0);
  vec3 q = vec3(<%= qx %>, 0, 0);
  vec3 incoming = vec3(<%= ir %>, <%= ig %>, <%= ib %>);
  fragColor = cloud_track_base(p, q, <%= n %>, incoming);
}
"))

Let me know if you have any comments or suggestions.

Enjoy!

Updates:

  • submitted to Reddit
  • submitted to Hackernews
  • Rhawk187 pointed out that exact image comparisons are also problematic because updates to graphics drivers can cause subtle changes. This can be adressed by allowing a small average difference between the expected and actual image.

The code of make-vertex-array-object and render-quads is added here for reference.

(defn make-vertex-array-object
  "Create vertex array object and vertex buffer objects"
  [program indices vertices attributes]
  (let [vertex-array-object (GL30/glGenVertexArrays)]
    (GL30/glBindVertexArray vertex-array-object)
    (let [array-buffer (GL15/glGenBuffers)
          index-buffer (GL15/glGenBuffers)]
      (GL15/glBindBuffer GL15/GL_ARRAY_BUFFER array-buffer)
      (GL15/glBufferData GL15/GL_ARRAY_BUFFER
                         (make-float-buffer (float-array vertices)) GL15/GL_STATIC_DRAW)
      (GL15/glBindBuffer GL15/GL_ELEMENT_ARRAY_BUFFER index-buffer)
      (GL15/glBufferData GL15/GL_ELEMENT_ARRAY_BUFFER
                         (make-int-buffer (int-array indices)) GL15/GL_STATIC_DRAW)
      (let [attribute-pairs (partition 2 attributes)
            sizes           (map second attribute-pairs)
            stride          (apply + sizes)
            offsets         (reductions + (cons 0 (butlast sizes)))]
        (doseq [[i [attribute size] offset] (map list (range) attribute-pairs offsets)]
          (GL20/glVertexAttribPointer (GL20/glGetAttribLocation
                                        ^int program (name attribute))
                                        ^int size
                                      GL11/GL_FLOAT false
                                        ^int (* stride Float/BYTES)
                                        ^int (* offset Float/BYTES))
          (GL20/glEnableVertexAttribArray i))
        {:vertex-array-object vertex-array-object
         :array-buffer        array-buffer
         :index-buffer        index-buffer
         :nrows               (count indices)
         :ncols               (count attribute-pairs)}))))

(defn render-quads
  "Render one or more quads"
  [vertex-array-object]
  (setup-vertex-array-object vertex-array-object)
  (GL11/glDrawElements GL11/GL_QUADS
    ^int (:nrows vertex-array-object) GL11/GL_UNSIGNED_INT 0))