Java-Gaming.org    
Featured games (81)
games approved by the League of Dukes
Games in Showcase (497)
Games in Android Showcase (114)
games submitted by our members
Games in WIP (563)
games currently in development
News: Read the Java Gaming Resources, or peek at the official Java tutorials
 
    Home     Help   Search   Login   Register   
Pages: [1]
  ignore  |  Print  
  JSquish - DXT Compression Library  (Read 13088 times)
0 Members and 1 Guest are viewing this topic.
Offline Spasi
« Posted 2006-10-25 23:26:48 »

Hi everyone, this is the first version of JSquish (~1.82MB).

JSquish is a Java port of the DXT compression library Squish. See this article for details. I will release the full source code after I fix a minor implementation issue.

The above archive contains the library JAR, which is also executable. It's a tiny LWJGL application that can be used to compare the different DXT compression methods. There are four methods that can be switched (press 'C'):

  • Uncompressed
        The original texture
  • Compressed - Driver
        The texture is compressed by the OpenGL driver (using the EXT_texture_compression_s3tc formats)
  • Compressed - Range Fit
        The texture is compressed using the Range Fit method. This is a very fast method, with comparable results to the driver one.
  • Compressed - Cluster Fit
        The texture is compressed using the Cluster Fit method. This method was the real motivation for writing a Java port of the Squish library. It's an embarrassingly slow method (only useful for offline compression), but gives amazing results compared to the driver implementation.

I urge you to try your own textures (press 'O' to load a new image) and see the quality difference of the Cluster Fit method. Try relatively small images first, to get a feeling of how slow it is. Grin

SCREENSHOTS (click for hi-res)

Uncompressed
Compressed - Cluster Fit
Compressed - Driver
Compressed - Range Fit
Offline pepijnve

Junior Member




Java games rock!


« Reply #1 - Posted 2006-10-26 09:43:58 »

Cheesy I made the exact same thing some time ago. Time to do some performance comparisons Wink

It's supposed to go in the product I'm working on, but I've been hesitant due to possible patent issues. Any idea whether squish falls under the s3tc patent or not?
Offline pepijnve

Junior Member




Java games rock!


« Reply #2 - Posted 2006-10-26 10:17:42 »

Our public facing interfaces are almost identical, which isn't surprising given the common heritage. The only big differences I see is that I javafied it (CLUSTER_FIT instead of kColourClusterFit) and I use byte[] instead of int[]. I ran a test with the following code with 100 warmup iterations and 100 benchmark iterations (should I use larger values here?)
1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
BufferedImage image = ImageIO.read(new File("C:\\test.png"));
int width = image.getWidth();
int height = image.getHeight();

int flags = gr.zdimensions.jsquish.Squish.kDxt1 | gr.zdimensions.jsquish.Squish.kColourClusterFit;
int[] input = getImageData(image);
int storageRequirements = gr.zdimensions.jsquish.Squish.getStorageRequirements(width, height, flags);
int[] output = new int[storageRequirements];

for (int i = 0; i < WARMUP_ITERATIONS; i++) {
    gr.zdimensions.jsquish.Squish.compressImage(input, width, height, output, flags);
}

long start = System.currentTimeMillis();
for (int i = 0; i < BENCHMARK_ITERATIONS; i++) {
    gr.zdimensions.jsquish.Squish.compressImage(input, width, height, output, flags);
}
long end = System.currentTimeMillis();

System.out.println("average time = " + (end - start) / BENCHMARK_ITERATIONS + "ms");


The same code was also run against my own implementation giving the following results:
JSquish: 334ms
MySquish: 210ms

Hooray Wink I'm curious where the large difference comes from though. I would have expected my version to be slower since I used byte[] and have to do a & 0xFF each time I want to use a value.
Games published by our own members! Check 'em out!
Legends of Yore - The Casual Retro Roguelike
Offline Spasi
« Reply #3 - Posted 2006-10-26 12:04:43 »

Cheesy I made the exact same thing some time ago. Time to do some performance comparisons Wink

It's supposed to go in the product I'm working on, but I've been hesitant due to possible patent issues. Any idea whether squish falls under the s3tc patent or not?

Heheh! Given the compression quality though, I was suspecting that someone might have done this already. Wink

I'm not sure on the patent issue, but there are another two OS implementations already. I don't think I've anything to worry about, but the case may be different in a commercial product (if that's what your product is).

Hooray Wink I'm curious where the large difference comes from though. I would have expected my version to be slower since I used byte[] and have to do a & 0xFF each time I want to use a value.

That's exactly what I plan to do next. I've seen this before in several places, using bytes and & 0xFF is much faster than the equivalent with ints. Probably the & overhead is tiny compared to the memory/bandwidth gains. This is the minor implementation issue I mentioned in my previous post. I will also javafy the interface a bit.

Anyway, the bottleneck in Cluster Fit is the solveLeastSquares method that is being called ~500 times per 4x4 block, on average. It takes around 97% of the total execution time, but it is plain floating point math and has nothing to do with bytes vs ints. So, I can't see how your implementation runs so much faster than mine. Maybe the test image you tried is too simple? From my tests I've found that the algorithm performance depends very much on the image contents, it takes a lot of time to find the optimal solution for complex images.
Offline pepijnve

Junior Member




Java games rock!


« Reply #4 - Posted 2006-10-26 12:36:42 »

I've compared my implementation of solveLeastSquares with yours. The biggest difference is that I inlined all the Vec3 stuff. Profiling showed that construction and manipulation of these objects was a hotspot, so I completely removed that stuff. Since that method is essentially the inner loop of the algorithm it makes a big difference. I've attached my cluster fit implementation so you can compare.
Offline Riven
« League of Dukes »

JGO Overlord


Medals: 799
Projects: 4
Exp: 16 years


Hand over your head.


« Reply #5 - Posted 2006-10-26 14:06:35 »

There is a LOT of room to optimize that code:

This is what I found:
1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
28  
29  
30  
31  
32  
33  
34  
35  
36  
37  
38  
39  
40  
    // zero where non-determinate
   float aX, aY, aZ;
    float bX, bY, bZ;
    if (beta2_sum == 0f) {
      aX = alphax_sumX / alpha2_sum;
      aY = alphax_sumY / alpha2_sum;
      aZ = alphax_sumZ / alpha2_sum;
      bX = bY = bZ = 0f;
    } else if (alpha2_sum == 0f) {
      aX = aY = aZ = 0f;
      bX = betax_sumX / beta2_sum;
      bY = betax_sumY / beta2_sum;
      bZ = betax_sumZ / beta2_sum;
    } else {
      float factor = (alpha2_sum * beta2_sum - alphabeta_sum * alphabeta_sum);

      aX = ((alphax_sumX * beta2_sum) - (betax_sumX * alphabeta_sum)) / factor;
      aY = ((alphax_sumY * beta2_sum) - (betax_sumY * alphabeta_sum)) / factor;
      aZ = ((alphax_sumZ * beta2_sum) - (betax_sumZ * alphabeta_sum)) / factor;

      bX = ((betax_sumX * alpha2_sum) - (alphax_sumX * alphabeta_sum)) / factor;
      bY = ((betax_sumY * alpha2_sum) - (alphax_sumY * alphabeta_sum)) / factor;
      bZ = ((betax_sumZ * alpha2_sum) - (alphax_sumZ * alphabeta_sum)) / factor;
    }

    // clamp the output to [0, 1]
   aX = Math.max(0, Math.min(1, aX));
    aY = Math.max(0, Math.min(1, aY));
    aZ = Math.max(0, Math.min(1, aZ));
    bX = Math.max(0, Math.min(1, bX));
    bY = Math.max(0, Math.min(1, bY));
    bZ = Math.max(0, Math.min(1, bZ));

    // clamp to the grid
   aX = (int)(GRID_X * aX + 0.5f) / GRID_X;
    aY = (int)(GRID_Y * aY + 0.5f) / GRID_Y;
    aZ = (int)(GRID_Z * aZ + 0.5f) / GRID_Z;
    bX = (int)(GRID_X * bX + 0.5f) / GRID_X;
    bY = (int)(GRID_Y * bY + 0.5f) / GRID_Y;
    bZ = (int)(GRID_Z * bZ + 0.5f) / GRID_Z;


If you see this, all bells should be ringing:
1  
2  
3  
4  
a = b / x;
c = d / x;
e = f / x;
g = h / x;


Replace that with:
1  
2  
3  
4  
5  
float inv_x = 1.0f / x;
a = b * inv_x;
c = d * inv_x;
e = f * inv_x;
g = h * inv_x;


Do that everywhere, and you'll see significantly better performance (if it's the bottleneck)


Further, I benchmarked this a while ago, so in the latest VMs this might not be an issue anymore, but this:
1  
    aX = Math.max(0, Math.min(1, aX));

there are 2 assignments and 2 comparisons here, worstcase scenario. The following code makes that either 0 or 1 assignments and 1 or 2 comparisons:
1  
   if(aX < 0.0f) ax = 0.0f; else if(aX > 1.0f) ax = 1.0f;


Let me know the results Smiley

Hi, appreciate more people! Σ ♥ = ¾
Learn how to award medals... and work your way up the social rankings
Offline pepijnve

Junior Member




Java games rock!


« Reply #6 - Posted 2006-10-26 15:01:37 »

Thanks for the suggestions. These changes gave the following results:
base: 210ms
replace division by multiplication: 209ms
replace Math.min/max with if/else: 176ms

I've attached an updated version. My profiler now shows the following hotspots:
ClusterFit#compress4: 46%
ClusterFit#compress3: 21%
ClusterFit#solveLeastSquares: 8%
I don't really have any ideas on how I could improve compress3/4 though.
Offline Spasi
« Reply #7 - Posted 2006-10-26 15:34:23 »

Thank you both. After using pepijnve's code and adding Riven's (a.k.a. Java Performance God) suggestions, my implementation is now 4 (four) times faster! Apparently inlining made a huge difference. I was expecting Hotspot to do a better job and optimize away most of the method calls when using vectors. Undecided

Anyway, I've updated the archive with the new version. You can also download the library separately (265kb) if you want to compare it again.
Offline pepijnve

Junior Member




Java games rock!


« Reply #8 - Posted 2006-10-26 15:58:21 »

I found the big difference between the two versions. My implementation was based on squish 1.7, while yours is probably based on 1.9. The main difference is that cluster fit now goes through multiple iterations. After updating my implementation average time went up to 266ms again. I then ran the test with the updated JSquish and that gave 147ms Shocked I profiled your version, and I didn't see compress3 popping up anywhere which seemed kind of strange, so I then peeked at ColourFit#compress. Your implementation differs from the original squish version. Squish does
1  
2  
3  
4  
5  
6  
7  
8  
9  
bool isDxt1 = ( ( m_flags & kDxt1 ) != 0 );
if( isDxt1 ) {
  Compress3( block );
  if( !m_colours->IsTransparent() ) {
    Compress4( block );
  }
} else {
  Compress4( block );
}

while you do
1  
2  
3  
4  
5  
6  
bool isDxt1 = ( ( m_flags & kDxt1 ) != 0 );
if( isDxt1 && m_colours->IsTransparent() ) {
  Compress3( block );
} else {
  Compress4( block );
}

which is a subtle difference that might cause a slight loss of quality. Squish uses the best result of compress3 and compress4, while your using either one or the other. No idea if this makes a big difference in practice though.
Offline Riven
« League of Dukes »

JGO Overlord


Medals: 799
Projects: 4
Exp: 16 years


Hand over your head.


« Reply #9 - Posted 2006-10-26 16:05:52 »

Apparently inlining made a huge difference. I was expecting Hotspot to do a better job and optimize away most of the method calls when using vectors. Undecided

Take a look at this thread about it. It's horrific.

Hi, appreciate more people! Σ ♥ = ¾
Learn how to award medals... and work your way up the social rankings
Games published by our own members! Check 'em out!
Legends of Yore - The Casual Retro Roguelike
Offline Riven
« League of Dukes »

JGO Overlord


Medals: 799
Projects: 4
Exp: 16 years


Hand over your head.


« Reply #10 - Posted 2006-10-26 16:11:05 »

Your:
1  
    aX = aX < 0f ? 0f : (aX > 1f ? 1f : aX);


is not equals to:
1  
   if(aX < 0.0f) ax = 0.0f; else if(aX > 1.0f) ax = 1.0f;


(assignment-count wise)


Tell me the diff, if any - it might save you a few cycles.

Hi, appreciate more people! Σ ♥ = ¾
Learn how to award medals... and work your way up the social rankings
Offline Spasi
« Reply #11 - Posted 2006-10-26 16:25:16 »

which is a subtle difference that might cause a slight loss of quality. Squish uses the best result of compress3 and compress4, while your using either one or the other. No idea if this makes a big difference in practice though.

Indeed, that was a piece of code that really confused me. I did a few tests last night as I was scratching my head and found that compress4 always produces the best result, that's why I removed the compress3 for RGB images. 3+4 makes the algorithm 100% slower for no apparent improvement, but I guess it's too obvious to be a mistake and there must be a reason 3 is there. I'll investigate further.

It's horrific.

Yeah, I remember your tests. I prefer code simplicity over performance, but thankfully in this case the problem was only in one particular method, so it was no big deal to optimize. In general though, we need escape analysis + stack allocation + optimizations urgently.
Offline Spasi
« Reply #12 - Posted 2006-10-26 16:28:23 »

Btw, here's my solveLeastSquares implementation.
Offline Riven
« League of Dukes »

JGO Overlord


Medals: 799
Projects: 4
Exp: 16 years


Hand over your head.


« Reply #13 - Posted 2006-10-26 16:40:45 »

I'd advice you to loose all Vec's from your algorithm.

You have to consider the Vec's can be all over the heap, which is kinda slow, compared to storing continious blocks of vec-data (float[]) in cpu-cache..


1  
2  
3  
4  
5  
6  
7  
8  
9  
      final Vec w = weighted[i];

      alpha2_sum += a * a;
      beta2_sum += b * b;
      alphabeta_sum += a * b;

      alphax_sumX += w.x() * a;
      alphax_sumY += w.y() * a;
      alphax_sumZ += w.z() * a;


Give this a try, even if you have to modify quite some code:
1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
      final float wx = weighted[i];
      final float wy = weighted[i+1];
      final float wz = weighted[i+2]; // requires weighted to be a float[]

      alpha2_sum += a * a;
      beta2_sum += b * b;
      alphabeta_sum += a * b;

      alphax_sumX += wx * a;
      alphax_sumY += wy * a;
      alphax_sumZ += wz * a;

      betax_sumX += wx * b;
      betax_sumY += wy * b;
      betax_sumZ += wz * b;


I'm not saying it will definitly boost performance, but it's worth it, it might be 2x faster, or not make much difference.

It's pretty much trial-and-error now.

Hi, appreciate more people! Σ ♥ = ¾
Learn how to award medals... and work your way up the social rankings
Offline Spasi
« Reply #14 - Posted 2006-10-26 16:49:58 »

Will do. Thanks again. Btw, Range Fit can be optimized accordingly.

I'm now wondering how the original Squish implementation compares to ours. He does use hand-coded SSE, but still a comparison would be interesting.
Offline Riven
« League of Dukes »

JGO Overlord


Medals: 799
Projects: 4
Exp: 16 years


Hand over your head.


« Reply #15 - Posted 2006-10-26 16:57:59 »

You can't beat handcoded SSE (4 values / instruction) in Java. The VM only uses SSE with 1 value, which is not nearly as efficient as 4.

In the past I did some benchmarks with it through JNI, and even that was faster (50%) then plain float[] math (1100ns JNI overhead per call).

As long as your API is run AOT, who cares! (damn, that's a lame way to defend Java Smiley)


Hi, appreciate more people! Σ ♥ = ¾
Learn how to award medals... and work your way up the social rankings
Offline Spasi
« Reply #16 - Posted 2006-10-27 01:57:15 »

I finished the adjustments, so here's JSquish in binary and source form.The library and the test application can also be downloaded separately.

What changed:

    - I switched everything to byte arrays. No performance difference at all, but at least no memory is wasted.
    - I did a few optimizations here and there.
    - I changed the public interface to use enums for options and added more entry points with default values.
    - I reverted compress to use both compress3 & compress4. Made some more tests and both are affecting the final image after all (altough with negligible difference).

I even tried to do gamma correction, but it didn't work out well. I moved everything to linear space in ColourSet, run the compression, then moved back to gamma space when writing the compressed block. I was expecting to see a nice, subtle difference, but instead I got a somewhat darker image and minor artifacts. I believe the problem lies with the error computation or the grid clamping, but couldn't figure a way to solve it. Anyway, it's good enough as it is.

Btw, I forgot to mention that Java 5.0 is required and Java 6.0 highly recommended (up to 20% faster on the client VM).
Offline pepijnve

Junior Member




Java games rock!


« Reply #17 - Posted 2006-10-27 08:17:44 »

I've been pulling my hair out trying to find the cause of the remaining performance difference between our two implementations (mine was a bit slower Smiley). Going over both codebases with a fine toothed comb I think I found a bug in your implementation of computeWeightedCovariance. In your implementation you pass in a Matrix instance that is reused. I checked the original squish code and you definetly should be resetting the matrix values to 0 before you start the accumulation loop. Otherwise you'll be reusing covariance values from a previous calculation. Sadly enough when I corrected this in JSquish the performance gap only got larger Sad I'm kind of stumped as to what could be causing this, especially since my profiler is telling me the exact opposite. The search continues...
Offline oNyx

JGO Coder


Medals: 2


pixels! :x


« Reply #18 - Posted 2006-10-27 08:57:18 »

Heh. Awesome \Grin/

弾幕 ☆ @mahonnaiseblog
Offline pepijnve

Junior Member




Java games rock!


« Reply #19 - Posted 2006-10-27 09:16:18 »

Woohoo! The culprit was a stupid stupid bug. I had written
1  
axis = bestend - bestend;

instead of
1  
axis = bestend - beststart;

which caused incorrect results obviously, but also caused solveLeastSquares to be called twice as much as in JSquish. This tiny change chopped 80ms off of the average time, bringing it to 100ms. Time for a celebration Cheesy
Thanks Spasi and Riven for all the information. It's defintely been an interesting learning experience.
Offline Spasi
« Reply #20 - Posted 2006-10-27 14:08:00 »

In your implementation you pass in a Matrix instance that is reused. I checked the original squish code and you definetly should be resetting the matrix values to 0 before you start the accumulation loop. Otherwise you'll be reusing covariance values from a previous calculation. Sadly enough when I corrected this in JSquish the performance gap only got larger

Thanks a lot pepijnve, it was indeed a bug and fixing it improved performance considerably. I wonder why such a bug didn't have any effect on the compression quality though. Anyway, I have updated the library archives.

This tiny change chopped 80ms off of the average time, bringing it to 100ms.

Great! Could you tell me how JSquish performs under your benchmark?
Offline pepijnve

Junior Member




Java games rock!


« Reply #21 - Posted 2006-10-27 14:47:21 »

103ms. The difference is due to a reordering of some of the code. I rearranged things so for each image I only have to make a single instance of SingleColour, Range and ClusterFit. These methods have a reset method to intialize any fields now. It's not pretty but it produces less garbage. I chose this approach over your approach of making most of the fields static because I wanted to be able to compress multiple images in parallel.
Offline elias

Senior Member





« Reply #22 - Posted 2006-12-04 15:33:15 »

First of all, thank you for porting this to java - we're now using DXT for most of the Tribal Trouble textures. However, I think decompressing DXT in jsquish is broken, since ColourBlock.unpack565() doesn't account for the byte sign properly. I've changed unpack565 to:

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
    private static int unpack565(final byte[] packed, final int pOffset, final int[] colour, final int cOffset) {
        int b1 = packed[pOffset + 0 + cOffset * 2] & 0xff;
        int b2 = packed[pOffset + 1 + cOffset * 2] & 0xff;
        // build the packed value
       int value = b1 | b2 << 8;

        // get the components in the stored range
       int red = (value >> 11) & 0x1f;
        int green = (value >> 5) & 0x3f;
        int blue = value & 0x1f;

        // scale up to 8 bits
       colour[0 + cOffset * 4] = (red << 3) | (red >> 2);
        colour[1 + cOffset * 4] = (green << 2) | (green >> 4);
        colour[2 + cOffset * 4] = (blue << 3) | (blue >> 2);
        colour[3 + cOffset * 4] = 255;

        // return the value
       return value & 0xffff;
    }


(adding masking to the two halves of the 565 color) which seems to fix this.

 - elias

Offline Spasi
« Reply #23 - Posted 2006-12-04 16:51:19 »

Thanks, I hadn't tested decompressing.
Offline pepijnve

Junior Member




Java games rock!


« Reply #24 - Posted 2006-12-07 08:01:29 »

Shouldn't
1  
2  
int b1 = packed[pOffset + 0 + cOffset * 2] & 0xff;
int b2 = packed[pOffset + 1 + cOffset * 2] & 0xff;

be
1  
2  
int b1 = packed[pOffset + 0] & 0xff;
int b2 = packed[pOffset + 1] & 0xff;

?

If not, why do you need to add cOffset * 2 to the packed offset?
Offline Orangy Tang

JGO Kernel


Medals: 56
Projects: 11


Monkey for a head


« Reply #25 - Posted 2006-12-07 11:56:45 »

Btw, I forgot to mention that Java 5.0 is required and Java 6.0 highly recommended (up to 20% faster on the client VM).
Dang. Any chance of a 1.4 compatible version? Or can the whole process be run offline and saved to a DDS or similar?

[ TriangularPixels.com - Play Growth Spurt, Rescue Squad and Snowman Village ] [ Rebirth - game resource library ]
Offline pepijnve

Junior Member




Java games rock!


« Reply #26 - Posted 2006-12-07 14:04:58 »

Squish on its own only performs the DXT compression. The output is just a bunch of DXT blocks. It's pretty straightforward to write these to a DDS file afterwards, so offline processing is definitely possible.
Offline Spasi
« Reply #27 - Posted 2006-12-07 15:01:29 »

Dang. Any chance of a 1.4 compatible version? Or can the whole process be run offline and saved to a DDS or similar?

It could work on even older versions, but I'm too used to 1.5 features right now. Smiley There are a couple of enums, some static imports and maybe some efor loops, you're free to modify the source if you need it.

If not, why do you need to add cOffset * 2 to the packed offset?

You're right, that was not optimal. I've uploaded the library + archives again.
Offline Spasi
« Reply #28 - Posted 2007-12-13 15:46:13 »

Update: Fixed a bug with GL_COMPRESSED_RGBA_S3TC_DXT1_EXT compression. Thanks to Daniel Senff.
Pages: [1]
  ignore  |  Print  
 
 
You cannot reply to this message, because it is very, very old.

 

Add your game by posting it in the WIP section,
or publish it in Showcase.

The first screenshot will be displayed as a thumbnail.

BurntPizza (22 views)
2014-09-19 03:14:18

Dwinin (37 views)
2014-09-12 09:08:26

Norakomi (65 views)
2014-09-10 13:57:51

TehJavaDev (91 views)
2014-09-10 06:39:09

Tekkerue (45 views)
2014-09-09 02:24:56

mitcheeb (66 views)
2014-09-08 06:06:29

BurntPizza (49 views)
2014-09-07 01:13:42

Longarmx (36 views)
2014-09-07 01:12:14

Longarmx (42 views)
2014-09-07 01:11:22

Longarmx (38 views)
2014-09-07 01:10:19
List of Learning Resources
by Longor1996
2014-08-16 10:40:00

List of Learning Resources
by SilverTiger
2014-08-05 19:33:27

Resources for WIP games
by CogWheelz
2014-08-01 16:20:17

Resources for WIP games
by CogWheelz
2014-08-01 16:19:50

List of Learning Resources
by SilverTiger
2014-07-31 16:29:50

List of Learning Resources
by SilverTiger
2014-07-31 16:26:06

List of Learning Resources
by SilverTiger
2014-07-31 11:54:12

HotSpot Options
by dleskov
2014-07-08 01:59:08
java-gaming.org is not responsible for the content posted by its members, including references to external websites, and other references that may or may not have a relation with our primarily gaming and game production oriented community. inquiries and complaints can be sent via email to the info‑account of the company managing the website of java‑gaming.org
Powered by MySQL Powered by PHP Powered by SMF 1.1.18 | SMF © 2013, Simple Machines | Managed by Enhanced Four Valid XHTML 1.0! Valid CSS!