Sign in to follow this  
Tanel

Problem with Rendering Effect from Third Surface

Recommended Posts

Hi,

I'm trying to implement GaussianBlurEffect for final touch-up of alpha values in my effect plugin (feathering, basically).

To accomplish this, I built extra surface where core of my effect is rendered,

then I call GaussianBlurEffect to render from tempSurface to dstArgs.

The problem is that blur effect comes out distorted.

Zoomed-up screenshots below.

Instead of turning this (original):

problem0.gif

to this (desired effect):

problem1.gif

I get this (distortion):

problem2.gif

Amount of distortion varies by size of selection,

so I guess I have some bounds misalignment going on, but I couldn't diagnose the exact cause.

Can you point out what's wrong here? I'm completely stuck in this... :?

Simplified code sample from VS2005:

// MAIN RENDER
       public override unsafe void Render(EffectConfigToken parameters, RenderArgs dstArgs, RenderArgs srcArgs, Rectangle[] rois, int startIndex, int length)
       {
           // BUILD A SURFACE "tempSurface" FOR BACKROUND RENDER
           Size surfaceSize = new Size(srcArgs.Surface.Width, srcArgs.Surface.Height);
           Surface tempSurface = new Surface(surfaceSize);
           RenderArgs temp = new RenderArgs(tempSurface);

           // CALL THE BACKROUND RENDER ON tempSurface
           RenderAlpha(parameters, tempSurface, srcArgs, rois, startIndex, length);

           EffectPluginConfigToken token = (EffectPluginConfigToken)parameters;
           PdnRegion selectionRegion = EnvironmentParameters.GetSelection(srcArgs.Bounds);
           Rectangle selection = EnvironmentParameters.GetSelection(srcArgs.Bounds).GetBoundsInt();

           // RENDER BLUR FROM tempSurface TO dstArgs
           PropertyBasedEffectConfigToken blurToken = new PropertyBasedEffectConfigToken(this.blurProps);
           blurToken.SetPropertyValue(GaussianBlurEffect.PropertyNames.Radius, token.Feather);
           this.blurEffect.SetRenderInfo(blurToken, dstArgs, temp);
           base.OnSetRenderInfo(blurToken, dstArgs, temp);
           this.blurEffect.Render(rois, startIndex, length);

           for (int i = startIndex; i < startIndex + length; ++i)
           {
               Rectangle roi = rois[i];

               for (int y = roi.Top; y < roi.Bottom; ++y)
               {
                   ColorBgra* dstPtr = dstArgs.Surface.GetPointAddress(roi.X, roi.Y);
                   ColorBgra NewPixel;
                   ColorBgra OrigPixel;
                   byte a, ro, go, bo, ry, by, gy, ay;

                   for (int x = roi.Left; x < roi.Right; ++x)
                   {
                       NewPixel = dstArgs.Surface[x, y];
                       //r = NewPixel.R;
                       //g = NewPixel.G;
                       //b = NewPixel.B;
                       a = NewPixel.A; // THIS SHOULD BE BLURRED ALPHA

                       if (selectionRegion.IsVisible(x, y))
                       {
                           OrigPixel = srcArgs.Surface[x, y];
                           ro = OrigPixel.R;
                           go = OrigPixel.G;
                           bo = OrigPixel.B;
                           //ao = OrigPixel.A;


                           // FINAL PIXEL TO CONSIST OF ORIGINAL RGB + BLURRED ALPHA:
                           ry = Utility.ClampToByte(ro);
                           gy = Utility.ClampToByte(go);
                           by = Utility.ClampToByte(bo);
                           ay = Utility.ClampToByte(a);
                           NewPixel = ColorBgra.FromBgra(by, gy, ry, ay);


                           *dstPtr = NewPixel;

                           //++srcPtr;
                           //++tmpPtr;
                           ++dstPtr;
                       }
                   }
               }
           }
       }


       // BACKROUND RENDER
       private unsafe void RenderAlpha(EffectConfigToken parameters, Surface surface, RenderArgs srcArgs, Rectangle[] rois, int startIndex, int length)
       {
           EffectPluginConfigToken token = (EffectPluginConfigToken)parameters;

           for (int i = startIndex; i < startIndex + length; ++i)
           {
               Rectangle roi = rois[i];

               for (int y = roi.Top; y < roi.Bottom; ++y)
               {
                   ColorBgra* ptr = surface.GetPointAddress(roi.X, roi.Y);
                   ColorBgra OrigPixel;
                   byte ro, go, bo, ao, ry, by, gy, ay;

                   for (int x = roi.Left; x < roi.Right; ++x)
                   {
                       OrigPixel = srcArgs.Surface[x, y];
                       ro = OrigPixel.R;
                       go = OrigPixel.G;
                       bo = OrigPixel.B;
                       ao = OrigPixel.A;

                       //CODE SNIPPED AT THIS POINT, (200 LINES OF HARDCORE MATH, BORING REALLY 
                       //FOR MATTER OF SIMPLICITY IN THIS SAMPLE I JUST USE:

                       ry = Utility.ClampToByte(ro);
                       gy = Utility.ClampToByte(go);
                       by = Utility.ClampToByte(bo);
                       ay = Utility.ClampToByte(ro); // GET ALPHA FROM RED IN THIS SAMPLE
                       OrigPixel = ColorBgra.FromBgra(by, gy, ry, ay);

                       *ptr = OrigPixel;
                       ++ptr;

                   }
               }
           }
       }

Share this post


Link to post
Share on other sites

Tanel,

I hate to break this to you, but this will never work as you intend :(

Let's see if I can find a way to explain this; It's very late and my brain is turning into cream-cheese...

Each call to Render() processes one or more rectangles (rios). Render will be called many times per image, possibly simultaneously on multiple threads, and the regions will not necessarily be processed in sequential order.

This create several, fundamental, problems with your concept;


  • [*:16x2zkp3]You are creating a new work surface each time Render() is called, so performance will be appalling, and you are likely to hit memory problems.
    [*:16x2zkp3]You are creating a new work surface each time Render() is called, so each work surface will only contain a small strip of the final image. When blur is called, it only has part of the info it needs to complete the blur; the lines above and below are missing.
    [*:16x2zkp3]Even if you were to find a way to create a work surface only once (a dispose of it in a timely fashion), you will never have the full image available for the blur.

Is my explanation understandable?

Share this post


Link to post
Share on other sites

HInt: Render() (or OnRender(), if you want to change this to a property-based effect), is called dozens-hundreds of time, and you can't really predict it.

OnSetRenderInfo() is only called first when the dialog is initially shown, and then once every time the user makes a change in the GUI, assuming you call FinishTokenUpdate().

Share this post


Link to post
Share on other sites

Here's the source code to my Film plugin, which you be able to look at if you need an example of working with a 3rd surface.

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;
using PaintDotNet;
using PaintDotNet.Effects;
using PaintDotNet.PropertySystem;
using PaintDotNet.IndirectUI;

namespace pyrochild.effects.film
{
   public class Film
       : PropertyBasedEffect
   {
       public static string StaticName
       {
           get
           {
               string s = "Film";
#if DEBUG
               s += " BETA";
#endif
               return s;
           }
       }

       public Film()
           : base(StaticName,
           new Bitmap(typeof(Film), "Icon.png"),
           SubmenuNames.Photo,
           EffectFlags.Configurable)
       {
           baca = new BrightnessAndContrastAdjustment();
           ane = new AddNoiseEffect();
           zbe = new ZoomBlurEffect();
           mbe = new MotionBlurEffect();
           dbo = new UserBlendOps.DifferenceBlendOp();
       }

       public enum PropertyNames
       {
           Grain,
           GrainAngle,
           GrainSeed, //This value isn't actually used. Clicking the button only forces a rerender, since Add Noise handles its own reseeding.
           Distortion,
           Offset,
           Discoloration,
           Button
       }

       BrightnessAndContrastAdjustment baca;
       AddNoiseEffect ane;
       ZoomBlurEffect zbe;
       MotionBlurEffect mbe;
       PropertyBasedEffectConfigToken aneToken1;
       PropertyBasedEffectConfigToken aneToken2;
       PropertyBasedEffectConfigToken mbeToken;
       UserBlendOps.DifferenceBlendOp dbo;

       double Grain;
       double GrainAngle;
       double Distortion;
       Pair Offset;
       double Discoloration;
       int Button;

       bool OnRenderCalled;

       Surface grainoverlay;
       RenderArgs goArgs;

       bool changed;

       protected override void OnRender(Rectangle[] rois, int startIndex, int length)
       {
           OnRenderCalled = true;
           if (SetRenderInfoCalled)
           {
               zbe.Render(rois, startIndex, length);
               baca.Render(rois, startIndex, length);

               PdnRegion selection = EnvironmentParameters.GetSelection(SrcArgs.Bounds);
               if (changed && grainoverlay != null)
               {
                   grainoverlay.Dispose();
                   grainoverlay = null;
               }
               if (grainoverlay == null)
               {
                   Rectangle[] gorois = selection.GetRegionScansInt();
                   grainoverlay = new Surface(SrcArgs.Size);

                       grainoverlay.Clear(ColorBgra.Black);
                       goArgs = new RenderArgs(grainoverlay);

                       ane.SetRenderInfo(aneToken1, goArgs, goArgs);
                       ane.Render(gorois, 0, gorois.Length);

                       changed = false;

                   ane.SetRenderInfo(aneToken2, goArgs, goArgs);
                   mbe.SetRenderInfo(mbeToken, goArgs, goArgs);


               } mbe.Render(rois, startIndex, length);
               ane.Render(rois, startIndex, length);
               dbo.Apply(DstArgs.Surface, grainoverlay, rois, startIndex, length);
           }
       }


       protected override PropertyCollection OnCreatePropertyCollection()
       {
           List props = new List();

           props.Add(new DoubleProperty(PropertyNames.Grain, 1, 0, 2));
           props.Add(new Int32Property(PropertyNames.GrainSeed, 0, 0, 255));
           props.Add(new DoubleProperty(PropertyNames.GrainAngle, 90, -180, 180));
           props.Add(new DoubleProperty(PropertyNames.Discoloration, 1, 0, 2));
           props.Add(new DoubleProperty(PropertyNames.Distortion, 1, 0, 2));
           props.Add(new DoubleVectorProperty(PropertyNames.Offset,
               Pair.Create(0.0, 0.0),
               Pair.Create(-2.0, -2.0),
               Pair.Create(2.0, 2.0)));
           props.Add(new Int32Property(PropertyNames.Button, 0, 0, 255));

           return new PropertyCollection(props);
       }

       protected override ControlInfo OnCreateConfigUI(PropertyCollection props)
       {
           ControlInfo configUI = CreateDefaultConfigUI(props);
           configUI.SetPropertyControlValue(PropertyNames.Discoloration, ControlInfoPropertyNames.UpDownIncrement, 0.1);
           configUI.SetPropertyControlValue(PropertyNames.Discoloration, ControlInfoPropertyNames.SliderLargeChange, 0.2);
           configUI.SetPropertyControlValue(PropertyNames.Discoloration, ControlInfoPropertyNames.SliderSmallChange, 0.05);
           configUI.SetPropertyControlValue(PropertyNames.Distortion, ControlInfoPropertyNames.UpDownIncrement, 0.1);
           configUI.SetPropertyControlValue(PropertyNames.Distortion, ControlInfoPropertyNames.SliderLargeChange, 0.2);
           configUI.SetPropertyControlValue(PropertyNames.Distortion, ControlInfoPropertyNames.SliderSmallChange, 0.05);
           configUI.SetPropertyControlValue(PropertyNames.Grain, ControlInfoPropertyNames.UpDownIncrement, 0.1);
           configUI.SetPropertyControlValue(PropertyNames.Grain, ControlInfoPropertyNames.SliderLargeChange, 0.2);
           configUI.SetPropertyControlValue(PropertyNames.Grain, ControlInfoPropertyNames.SliderSmallChange, 0.05);
           configUI.SetPropertyControlType(PropertyNames.GrainAngle, PropertyControlType.AngleChooser);
           configUI.SetPropertyControlValue(PropertyNames.GrainAngle, ControlInfoPropertyNames.DisplayName, "Grain Angle");
           configUI.SetPropertyControlType(PropertyNames.GrainSeed, PropertyControlType.IncrementButton);
           configUI.SetPropertyControlValue(PropertyNames.GrainSeed, ControlInfoPropertyNames.DisplayName, "");
           configUI.SetPropertyControlValue(PropertyNames.GrainSeed, ControlInfoPropertyNames.ButtonText, "Randomize Grain");
           configUI.SetPropertyControlValue(PropertyNames.Offset, ControlInfoPropertyNames.DisplayName, "Focus");
           configUI.SetPropertyControlValue(PropertyNames.Offset, ControlInfoPropertyNames.UpDownIncrementX, 0.01);
           configUI.SetPropertyControlValue(PropertyNames.Offset, ControlInfoPropertyNames.SliderLargeChangeX, 0.1);
           configUI.SetPropertyControlValue(PropertyNames.Offset, ControlInfoPropertyNames.SliderSmallChangeX, 0.05);
           configUI.SetPropertyControlValue(PropertyNames.Offset, ControlInfoPropertyNames.UpDownIncrementY, 0.01);
           configUI.SetPropertyControlValue(PropertyNames.Offset, ControlInfoPropertyNames.SliderLargeChangeY, 0.1);
           configUI.SetPropertyControlValue(PropertyNames.Offset, ControlInfoPropertyNames.SliderSmallChangeY, 0.05);

           Bitmap bmp = this.EnvironmentParameters.SourceSurface.CreateAliasedBitmap();
           ImageResource ir = ImageResource.FromImage(bmp);
           configUI.SetPropertyControlValue(PropertyNames.Offset, ControlInfoPropertyNames.StaticImageUnderlay, ir);
           configUI.SetPropertyControlType(PropertyNames.Button, PropertyControlType.IncrementButton);
           configUI.SetPropertyControlValue(PropertyNames.Button, ControlInfoPropertyNames.DisplayName, "");
           configUI.SetPropertyControlValue(PropertyNames.Button, ControlInfoPropertyNames.ButtonText, "Donate!");

           return configUI;
       }

       protected override void OnSetRenderInfo(PropertyBasedEffectConfigToken newToken, RenderArgs dstArgs, RenderArgs srcArgs)
       {
           this.Grain = newToken.GetProperty(PropertyNames.Grain).Value;
           this.GrainAngle = newToken.GetProperty(PropertyNames.GrainAngle).Value;
           this.Discoloration = newToken.GetProperty(PropertyNames.Discoloration).Value;
           this.Distortion = newToken.GetProperty(PropertyNames.Distortion).Value;
           this.Offset = newToken.GetProperty(PropertyNames.Offset).Value;
           this.changed = true;

           int newButtonVal = newToken.GetProperty(PropertyNames.Button).Value;
           if (OnRenderCalled && newButtonVal != this.Button)
           {
               PdnInfo.OpenUrl(null, "http://pyrochild.110mb.com/donate.html");
           }

           PropertyCollection bacaProps = baca.CreatePropertyCollection();
           PropertyCollection aneProps = ane.CreatePropertyCollection();
           PropertyCollection zbeProps = zbe.CreatePropertyCollection();
           PropertyCollection mbeProps = mbe.CreatePropertyCollection();
           PropertyBasedEffectConfigToken bacaToken = new PropertyBasedEffectConfigToken(bacaProps);
           PropertyBasedEffectConfigToken zbeToken = new PropertyBasedEffectConfigToken(zbeProps);
           aneToken1 = new PropertyBasedEffectConfigToken(aneProps);
           aneToken2 = new PropertyBasedEffectConfigToken(aneProps);
           mbeToken = new PropertyBasedEffectConfigToken(mbeProps);

           bacaToken.SetPropertyValue(BrightnessAndContrastAdjustment.PropertyNames.Brightness, (int)(-30 * this.Discoloration));
           bacaToken.SetPropertyValue(BrightnessAndContrastAdjustment.PropertyNames.Contrast, (int)(50 * this.Discoloration));
           baca.SetRenderInfo(bacaToken, dstArgs, dstArgs);

           aneToken1.SetPropertyValue(AddNoiseEffect.PropertyNames.Intensity, (int)(40 * this.Grain));
           aneToken1.SetPropertyValue(AddNoiseEffect.PropertyNames.Saturation, 0);

           aneToken2.SetPropertyValue(AddNoiseEffect.PropertyNames.Intensity, (int)(20 * this.Grain));
           aneToken2.SetPropertyValue(AddNoiseEffect.PropertyNames.Saturation, 0);

           zbeToken.SetPropertyValue(ZoomBlurEffect.PropertyNames.Amount, (int)(10 * this.Distortion));
           zbeToken.SetPropertyValue(ZoomBlurEffect.PropertyNames.Offset, this.Offset);
           zbe.SetRenderInfo(zbeToken, dstArgs, srcArgs);

           mbeToken.SetPropertyValue(MotionBlurEffect.PropertyNames.Angle, GrainAngle);
           mbeToken.SetPropertyValue(MotionBlurEffect.PropertyNames.Distance, 25);

           this.Button = newButtonVal;

           base.OnSetRenderInfo(newToken, dstArgs, srcArgs);
       }
   }
}

Share this post


Link to post
Share on other sites

Thank you guys!

Ed, I got your point, blur effect requires complete source image before start, but in my case it receives source image strip by strip, hence distorted results.

pyrochild, does your Film plugin contain complete workaround for this?

If so, then I'm a bit confused of Ed's comment:


  • ...
    [*:1vbysz8a]Even if you were to find a way to create a work surface only once (a dispose of it in a timely fashion), you will never have the full image available for the blur.

I will study your Film plugin code thoroughly, very helpful indeed!

Did I guess the sequence of six main operations correctly?

Could you kindly explain logic of the tricks implemented there, six places I marked with "???":

... // comments by Tanel:
protected override void OnRender(Rectangle[] rois, int startIndex, int length)
       {
           OnRenderCalled = true;
           if (SetRenderInfoCalled) // ???
           {
               zbe.Render(rois, startIndex, length); // 1. Apply ZoomBlurEffect from srcArgs to dstArgs
               baca.Render(rois, startIndex, length); // 2. Apply BrightnessAndContrastAdjustment from dstArgs to dstArgs

               PdnRegion selection = EnvironmentParameters.GetSelection(SrcArgs.Bounds);
               if (changed && grainoverlay != null) // ???
               {
                   grainoverlay.Dispose(); // ???
                   grainoverlay = null; // ???
               }
               if (grainoverlay == null) // ???
               {
                   Rectangle[] gorois = selection.GetRegionScansInt(); // build new surface "grainoverlay"
                   grainoverlay = new Surface(SrcArgs.Size);

                       grainoverlay.Clear(ColorBgra.Black); // apply black color to surface "grainoverlay"
                       goArgs = new RenderArgs(grainoverlay); // RenderArgs for surface "grainoverlay"

                       ane.SetRenderInfo(aneToken1, goArgs, goArgs);
                       ane.Render(gorois, 0, gorois.Length); // 3. render AddNoiseEffect on "grainoverlay" (from goArgs to goArgs)

                       changed = false; // ???

                   ane.SetRenderInfo(aneToken2, goArgs, goArgs); // (*)
                   mbe.SetRenderInfo(mbeToken, goArgs, goArgs); // (**)


               } mbe.Render(rois, startIndex, length); // 4. render MotionBlurEffect (**) on "grainoverlay" 
               ane.Render(rois, startIndex, length); // 5. render second AddNoiseEffect (*) on "grainoverlay" 

               dbo.Apply(DstArgs.Surface, grainoverlay, rois, startIndex, length);  // 6. DifferenceBlendOp - grainoverlay (goArgs) over DstArgs
           }
       }
...

Thanks!

Share this post


Link to post
Share on other sites
Thank you guys!

Ed, I got your point, blur effect requires complete source image before start, but in my case it receives source image strip by strip, hence distorted results.

Exactly...

pyrochild, does your Film plugin contain complete workaround for this?

No, it does not. There is no workable way around your problem... :(

Pyrochild's code attempts to get around the first issue I raised and only create one copy of a work surface. Unfortunately there are a couple of problems with his approach.

  1. [*:28w4i20h]The code is not thread-safe, and it is quite possible that more than one work surface will be created :shock:
    [*:28w4i20h]The work surface that he creates is never disposed, and so the memory is not released until some later, indeterminate, time.

Share this post


Link to post
Share on other sites
pyrochild, does your Film plugin contain complete workaround for this?

I will study your Film plugin code thoroughly, very helpful indeed!

Did I guess the sequence of six main operations correctly?

Could you kindly explain logic of the tricks implemented there, six places I marked with "???":

Thanks!

It is a working workaround, yes, but as Ed Harvey stated before me, it's not perfect. But since I have neither had nor heard of any problems with this so far, I don't consider it "bad." Why fix something that's broken, but will never be noticed? ;)

Anyhow, I notated your ??? spots below:

... // comments by Tanel:
protected override void OnRender(Rectangle[] rois, int startIndex, int length)
       {
           OnRenderCalled = true;
           if (SetRenderInfoCalled) // ??? This just makes sure that OnSetRenderInfo() has been called, otherwise none of our variables are set to the user's settings yet, and it's pointless to render anything
           {
               zbe.Render(rois, startIndex, length); // 1. Apply ZoomBlurEffect from srcArgs to dstArgs
               baca.Render(rois, startIndex, length); // 2. Apply BrightnessAndContrastAdjustment from dstArgs to dstArgs

               PdnRegion selection = EnvironmentParameters.GetSelection(SrcArgs.Bounds);
               if (changed && grainoverlay != null) // ??? I set changed = true in OnSetRenderInfo(), to indicate that the user has changed settings and therefore, invalidated our grainOverlay. But if it's null it doesn't matter.
               {
                   grainoverlay.Dispose(); // ??? Clear up our resource usage. We have to be nice to our user's RAM.
                   grainoverlay = null; // ??? Then set it to null so the next ifblock gets executed. I could combine this more elegantly, but the code here evolved organically from the old version, and I'm lazy.
               }
               if (grainoverlay == null) // ??? If we don't have a grainoverlay at this point (whether it's because we never made one, or just invalidated it above), we need to make one.
               {
                   Rectangle[] gorois = selection.GetRegionScansInt(); // build new surface "grainoverlay"
                   grainoverlay = new Surface(SrcArgs.Size);

                       grainoverlay.Clear(ColorBgra.Black); // apply black color to surface "grainoverlay"
                       goArgs = new RenderArgs(grainoverlay); // RenderArgs for surface "grainoverlay"

                       ane.SetRenderInfo(aneToken1, goArgs, goArgs);
                       ane.Render(gorois, 0, gorois.Length); // 3. render AddNoiseEffect on "grainoverlay" (from goArgs to goArgs)

                       changed = false; // ??? Set changed to false since we've made our grainOverlay and it's currently valid, until changed gets set to true again. This prevents us from creating a new overlay every time OnRender() is called

                   ane.SetRenderInfo(aneToken2, goArgs, goArgs); // (*)
                   mbe.SetRenderInfo(mbeToken, goArgs, goArgs); // (**)


               } mbe.Render(rois, startIndex, length); // 4. render MotionBlurEffect (**) on "grainoverlay" 
               ane.Render(rois, startIndex, length); // 5. render second AddNoiseEffect (*) on "grainoverlay" 

               dbo.Apply(DstArgs.Surface, grainoverlay, rois, startIndex, length);  // 6. DifferenceBlendOp - grainoverlay (goArgs) over DstArgs
           }
       }
...

Share this post


Link to post
Share on other sites

You should not use the SetRenderInfoCalled property. I don't think you're even supposed to be able to, since it is marked as internal (you may have skated past if you compiled before the final release of 3.20).

Instead, just rely on the fact that your implementation of OnSetRenderInfo() is thread-affinitized: it is only called once whenever a new token needs to be delivered, and it is called after any calls to Render() have completed (which were using the old token), and before any new calls to Render() are started (which would use the new token). So just do a null-check against whatever resource you're looking to do a one-time initialization on.

Share this post


Link to post
Share on other sites
Great, thanks for advice pyrochild! :D

It starts to make sense for me now.

Will go and try it out.

Anything Rick says (see above) takes precedence over my advice.

I'm a 17 year old kid programming in my free time. I have no car, no job, and write a plugin once in a while. I'm failing my English class.

Rick is a MIT graduate, drives a BMW, works at Microsoft, and is the lead developer of a program that gets downloaded millions of times per release.

I also failed my Into to PE class my freshman year, but that was mainly because the teacher was a ridiculously obese moron who had absolutely no business teaching anything at all, much less PE. And I certainly made my opinion clear to him. ;)

Yeah.

Share this post


Link to post
Share on other sites

It works!!! :D

Plugin will be up soon.

Anything Rick says (see above) takes precedence over my advice.

I took Rick's advice and removed SetRenderInfoCalled property. It seems to be redundant indeed.

Share this post


Link to post
Share on other sites

yeah, I just went back and checked that... I guess I always get MIT in my head 'cause PdN is under the MIT license.

(And because MIT is my dream college...)

Share this post


Link to post
Share on other sites

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

Sign in to follow this